diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..b066e9e0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: gchristnsn +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.gitignore b/.gitignore index fdddc5b8..adfd0760 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,16 @@ -bin +.idea +.local +*.iml +*.bak node_modules -image/donate/ +/package.json +/package-lock.json +/build +addon/.web-extension-id +addon/_metadata +__pycache__ +backend/build/ +backend/dist/ +backend/*.exe +backend/*.zip +backend/*.tgz diff --git a/AUTOMATION.md b/AUTOMATION.md new file mode 100644 index 00000000..dc2caa61 --- /dev/null +++ b/AUTOMATION.md @@ -0,0 +1,462 @@ +## Automation + +Automation is a powerful feature that allows to programmatically create, modify, and delete Scrapyard bookmarks +from [iShell](https://gchristensen.github.io/ishell/) commands or your own extensions. For example, with this API you +can import hierarchical content, manage TODO lists, or create something similar to the legacy Firefox "Live Bookmarks". + +All automation features are implemented through the WebExtensions +[runtime messaging API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendMessage). +The ES6 module available by [this](https://raw.githubusercontent.com/GChristensen/ishell/master/addon/api/scrapyard.js) link provides a +JavaScript wrapper which is used in the examples below. + +To call Scrapyard API from regular extensions, automation should be enabled manually at the Scrapyard advanced settings +page (**ext+scrapyard://advanced**). It is not necessary to enable automation to use the code below from iShell commands. + +The following messages are currently available: + +#### SCRAPYARD_GET_VERSION + +Returns Scrapyard version. Useful for testing if Scrapyard presents in the browser: + +```javascript +import {getVersion} from "./scrapyard.js"; + +try { + let version = await getVersion(); + console.log(`Scrapyard version: ${version}`); +} +catch (e) { + сonsole.log("Scrapyard is not installed or automation is disabled"); +} +``` + +#### SCRAPYARD_OPEN_BATCH_SESSION, SCRAPYARD_CLOSE_BATCH_SESSION + +To optimize disk operations on the Scrapyard storage, these messages need to be issued +when creating or modifying multiple bookmark or archive items. + +```javascript +import {openBatchSession, closeBatchSession} from "./scrapyard.js"; + +try { + await openBatchSession(); + + // Procesing... +} +finally { + await closeBatchSession(); +} +``` + +#### SCRAPYARD_ADD_BOOKMARK + +Creates a bookmark. + +```js +import {addBookmark} from "./scrapyard.js"; + +const uuid = await addBookmark({ + url: "http://example.com", // Bookmark URL + title: "Bookmark Title", // Bookmark title + icon: "http://example.com/favicon.ico", // URL of bookmark favicon + path: "shelf/my/directory", // Bookmark shelf and directory + tags: "comma, separated", // List of bookmark tags + details: "Bookmark details", // Bookmark details + todo_state: "TODO", // One of the following strings: + // TODO, WAITING, POSTPONED, DONE, CANCELLED + todo_date: "YYYY-MM-DD", // TODO expiration date + comments: "comment text", // Bookmark comments + container: "firefox-container-1", // cookieStoreId of a Firefox Multi-Account container + select: true // Select the newly created bookmark in the interface +}); +``` + +All parameters are optional. Directories in the bookmark path will be created automatically, if not exist. +The relevant undefined parameters (`url`, `title`, `icon`) will be captured from the active tab. + +The icon URL is used by Scrapyard only to store its image in the database, so it may be a URL from a local server, or a +[data-URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). +If this parameter is explicitly set to an empty string, the default icon will be +used. + +If `title` or `icon` parameters are explicitly set to `true`, bookmark title or icon will be extracted from the page +defined by the `url` parameter. + +Returns UUID of the newly created bookmark. + +Note: if you have [Python](https://www.python.org) installed, a local server could be started by +executing `python3 -m http.server` in the desired directory. + +#### SCRAPYARD_ADD_ARCHIVE + +Creates an archive. + +```js +import {addArchive} from "./scrapyard.js"; + +const uuid = await addArchive({ + url: "http://example.com", // Bookmark URL + title: "Bookmark Title", // Bookmark title + icon: "http://example.com/favicon.ico", // URL of bookmark favicon + path: "shelf/my/directory", // Bookmark shelf and directory + tags: "comma, separated", // List of bookmark tags + details: "Bookmark details", // Bookmark details + todo_state: "TODO", // One of the following strings: + // TODO, WAITING, POSTPONED, DONE, CANCELLED + todo_date: "YYYY-MM-DD", // TODO expiration date + comments: "comment text", // Bookmark comments + container: "firefox-container-1", // cookieStoreId of a Firefox Multi-Account container + content: "

Archive content

", // A String or ArrayBuffer, representing the text or bytes of the archived content + // HTML-pages, images, PDF-documents, and other files could be stored + content_type: "text/html", // MIME-type of the stored content + pack: true, // Pakck and store the page specified by the 'url' parameter, + // the 'content' parameter is ignored + local: true, // The 'url' parameter contains path to a local file, 'pack' is ignored + hide_tab: false, // Hide tab necessary to pack the page + select: true // Select the newly created bookmark in the interface +}); +``` + +All parameters are optional. Directories in the bookmark path will be created automatically, if not exist. +The relevant missing parameters (`url`, `title`, `icon`, `content`, `content_type`) will be captured +from the active tab. If the `url` parameter is explicitly set to an empty string, +it will remain empty. + +If `title` or `icon` parameters are explicitly set to `true`, bookmark title or icon will be extracted from the page +defined by the `url` parameter. + +When the `pack` parameter is specified, this API ignores the `content`, `title`, and `icon` parameters, +and packs, then stores the page defined by the `url` parameter. "text/html" content type is assumed. +A new tab is created, which is required for page packing. This tab could be hidden through the `hide_tab` message option. + +The `pack` and `content` parameters are ignored if the `local` parameter is set to `true`. In this case, the addon will perform +packing of the HTML content as the `pack` option, or store binary content otherwise. The `title` and `icon` parameters are +taken into account. + +If the `pack` or `local` parameters are set, bookmark icon and title will be assigned automatically. + +Returns UUID of the newly created archive. + +#### SCRAPYARD_ADD_NOTES + +Creates notes. + +```js +import {addNotes} from "./scrapyard.js"; + +const uuid = await addNotes({ + title: "Bookmark Title", // Bookmark title + path: "shelf/my/directory", // Bookmark shelf and directory + tags: "comma, separated", // List of bookmark tags + details: "Bookmark details", // Bookmark details + todo_state: "TODO", // One of the following strings: + // TODO, WAITING, POSTPONED, DONE, CANCELLED + todo_date: "YYYY-MM-DD", // TODO expiration date + comments: "comment text", // Bookmark comments + content: "Notes content", // A string + format: "text", // One of the following strings: text, markdown, org + select: true // Select the newly created bookmark in the interface +}); +``` + +Returns UUID of the newly created notes. + +#### SCRAPYARD_ADD_SEPARATOR + +Creates separator. + +```js +import {addSeparator} from "./scrapyard.js"; + +const uuid = await addSeparator({ + path: "shelf/my/directory", // Separator shelf and directory + select: true // Select the newly created bookmark in the interface +}); +``` + +Returns UUID of the newly created separator. + +#### SCRAPYARD_PACK_PAGE + +Packs content of all resources (images, CSS, etc.) referenced by a web-page into a single HTML string. +When displayed in the browser, such a page will not rely on any external dependencies. +Use this API, for example, when it is necessary to somehow modify the captured page. + +This API creates a new tab which is required for its operation. +This tab could be hidden through the `hide_tab` message option. + +```js +import {packPage} from "./scrapyard.js"; + +const {html, title, icon} = await packPage({ + url: "http://example.com", // URL of the page to be packed + local: true, // The 'url' parameter contains path to a local file + hide_tab: false // Hide the tab used by the API +}); +``` + +Returns an object with the following properties: + +* html - HTML string with the content of the specified page and all its referenced resources. +* title - title of the captured page. +* icon - page favicon URL. + +#### SCRAPYARD_GET_UUID + +Retrieves the properties of a bookmark or archive defined by the `uuid` parameter. + +```js +import {getItem} from "./scrapyard.js"; + +const item = await getItem({ + uuid: "F0D858C6ED40416AA402EB2C3257EA17" +}); +``` + +Returns an object with the following properties: + +* type +* uuid +* title +* url +* icon +* tags +* details +* todo_state +* todo_date +* comments +* container +* path + +Only `type`, `uuid`, and `title` properties are always present. + +#### SCRAPYARD_GET_UUID_CONTENT + +Retrieves the content of an archive or notes defined by the `uuid` parameter. + +```js +import {getItemContent} from "./scrapyard.js"; + +const item = await getItemContent({ + uuid: "F0D858C6ED40416AA402EB2C3257EA17" +}); +``` + +Returns an object with the following properties: + +* content - content of the archive or notes, may be a string or array-buffer. +* content_type - content MIME-type. +* contains - may be one of the following strings: text, bytes, files. +* format - format of the notes. + +Only content property is always present. The type of the content depends on the value of the `contains` property. +When `contains` is equal to "files", `content` property contains an array-buffer with the bytes of a ZIP-archive. + +#### SCRAPYARD_GET_SELECTION + +Retrieves the properties of bookmarks selected in the Scrapyard sidebar. + +```js +import {getSelection} from "./scrapyard.js"; + +const items = await getSelection(); +``` + +#### SCRAPYARD_LIST_UUID, SCRAPYARD_LIST_PATH + +Lists the direct descendants of a shelf or folder defined by the `uuid` or `path` parameter. + +```js +import {listItems} from "./scrapyard.js"; + +const items = await listItems({ + uuid: null, // the uuid and path parameters are mutually exclusive + path: "/" +}); +``` + +If `null` is specified as the value of the `uuid` parameter, the list of all shelves is returned. + +#### SCRAPYARD_UPDATE_UUID + +Updates the properties of a bookmark, archive, or folder represented by the given UUID. + +```js +import {updateItem} from "./scrapyard.js"; + +await updateItem({ + uuid: "F0D858C6ED40416AA402EB2C3257EA17", + title: "Bookmark Title", // Bookmark title + url: "http://example.com", // Bookmark URL + icon: "http://example.com/favicon.ico", // URL of bookmark favicon + tags: "comma, separated", // List of bookmark tags + details: "Bookmark details", // Bookmark details + todo_state: "TODO", // One of the following strings: + // TODO, WAITING, POSTPONED, DONE, CANCELLED + todo_date: "YYYY-MM-DD", // TODO expiration date + comments: "comment text", // Bookmark comments + container: "firefox-container-1", // cookieStoreId of a Firefox Multi-Account container + refresh: true // Refresh the sidebar +}); +``` + +All parameters are optional. If the `icon` parameter is explicitly set to an empty string, the default icon will be used. +It is preferable to use the `refresh` parameter only on the last invocation in the chain of updates. + +#### SCRAPYARD_REMOVE_UUID + +Removes a bookmark or archive defined by the given UUID. + +```js +import {deleteItem} from "./scrapyard.js"; + +const items = await deleteItem({ + uuid: "F0D858C6ED40416AA402EB2C3257EA17", + refresh: true // Refresh the sidebar +}); +``` + +It is preferable to use the `refresh` parameter only on the last invocation in the chain of updates. + +#### SCRAPYARD_BROWSE_UUID + +Opens a bookmark or archive defined by the UUID, which could be found at the bookmark property dialog. + +```js +import {browseItem} from "./scrapyard.js"; + +await browseItem({ + uuid: "F0D858C6ED40416AA402EB2C3257EA17" +}); +``` + +### Examples + +In the following examples we use iShell commands to interact with Scrapyard. With iShell, there is no need to +create separate add-ons for the Scrapyard automation in the most cases. See the iShell +[tutorial](https://gchristensen.github.io/ishell/addon/ui/options/tutorial.html) for more details on command authoring. + +#### Uploading Local Files to Scrapyard + +In the following example, we create a command that stores a local file +in Scrapyard under the folder specified by the `to` argument. + +In iShell, the global object `cmdAPI.scrapyard` provides the same methods as the ES6 wrapper referenced above, so +there is no need to import anything. + +```javascript +/** + Being placed in the iShell command editor, this code + creates the command named "upload-file". + + # Syntax + **upload-file** _file path_ **to** _folder path_ + + # Arguments + - _file path_ - a local file path + - _folder path_ - a full path of a folder in Scrapyard + + # Examples + **upload-file** *d:/documents/my file.pdf* **to** *papers/misc* + + @command + @markdown + @icon /ui/icons/scrapyard.svg + @description Stores a local file under the specified Scrapyard folder. + @uuid 674BF919-3BCA-4378-AB8F-C873F8CFE42A + */ +class UploadFile { + constructor(args) { + args[OBJECT] = {nountype: noun_arb_text, label: "path"}; + // cmdAPI.scrapyard.noun_type_directory provides the list of all Scrapyard directories + // to autocompletion. A precaution is taken in the case of missing Scrapyard add-on. + const directory_noun = cmdAPI.scrapyard?.noun_type_directory || {suggest: () => ({})}; + args[TO] = {nountype: directory_noun, label: "directory"}; + } + + preview({OBJECT, TO}, display) { + display.text(`Upload file ${OBJECT?.text} to the ${AT?.text} folder in Scrapyard.`); + } + + async execute({OBJECT, TO}) { + if (!OBJECT?.text) + return; + + const localPath = OBJECT.text; + + let title = localPath.replaceAll("\\", "/").split("/"); + title = title[title.length - 1]; // use file name as the default bookmark title + + cmdAPI.scrapyard.addArchive({ + title: title, + url: localPath, + path: TO?.text, + local: true, + select: true + }); + } +} +``` + +Command example: + +**upload-file** *d:/documents/my file.pdf* **to** *papers/misc* + +#### Processing Archives with Python + +The iShell command demonstrated below sends the content of the currently selected archives for processing in +the iShell backend application written in Python. There you can develop your own [Flask](https://flask.palletsprojects.com/) +handlers. + +```javascript +/** + This command has no arguments. + + @command + @icon /ui/icons/scrapyard.svg + @description Process with Python the curretnly selected Scrapyard archives. + @uuid 37B60EBB-F216-4A36-88DA-4703579A6457 +*/ +class ConvertToMarkdown { + async execute() { + const items = await cmdAPI.scrapyard.getSelection(); + + for (const item of items) { + if (item.type === "archive") { + try { + const content = await cmdAPI.scrapyard.getItemContent({uuid: item.uuid}); + const doc = cmdAPI.parseHtml(content.content); + + $("style, script", doc).remove(); + content.content = doc.outerHTML; + + const payload = JSON.stringify({item, content}); + const headers = {"content-type": "application/json"}; + + await cmdAPI.backendFetch("/convert_to_markdown", {method: "post", body: payload, headers}); + } catch (e) { + console.error(e); + } + } + } + + } +} +``` + +The Flask handler: + +```python +import markdownify # 3-rd party library +from flask import request + +@app.route("/convert_to_markdown", methods=['POST']) +def convert_to_markdown(): + title, content = request.json["item"]["title"], request.json["content"]["content"] + markdown = markdownify.markdownify(content) + + with open(f"d:/markdown/{title}.md", "w", encoding="utf-8") as file: + file.write(markdown) + + return "", 204 +``` diff --git a/LICENSE b/LICENSE index a612ad98..d159169d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,373 +1,339 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index d82e5131..cafa817d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ -# ScrapBee -- An Extension For Firefox Quantum +# Scrapyard -Captures web page into local storage and manages captured pages in sidebar. Compatible with ScrapBook documents. Win/Mac/Linux supported. +A Scrapbook alternative for Firefox Quantum. + +This is a development page. Please visit the main site at: https://gchristensen.github.io/scrapyard/ + +ScrapBook was a brilliant extension. I haven't found a usable alternative, and I hope that I was able +to make an extension that is as usable as the legacy ScrapBook itself. + +### Credits + +* vclfence's [ScrapBee](https://github.com/vctfence/scrapbee) was used as a starting point, +although currently almost no code from ScrapBee is left in Scrapyard. +* The modified engine of [SavePageWe](https://addons.mozilla.org/en-US/firefox/addon/save-page-we/) +is used to capture pages. + + +### JSON Scrapbook File Format + +The JSON Scrapbook archive format used by Scrapyard is documented +[here](https://github.com/GChristensen/scrapyard/wiki/JSON-Scrapbook-File-Format). diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000..e86c23f0 --- /dev/null +++ b/_config.yml @@ -0,0 +1,3 @@ +theme: jekyll-theme-minimal +title: Scrapyard +logo: addon/icons/scrapyard.svg diff --git a/_layouts/default.html b/_layouts/default.html new file mode 100644 index 00000000..c2a44177 --- /dev/null +++ b/_layouts/default.html @@ -0,0 +1,56 @@ + + + + + + + + {% seo %} + + + + + + + +
+
+

{{ site.title | default: site.github.repository_name }}

+ + {% if site.logo %} + Logo + {% endif %} + +

{{ site.description | default: site.github.project_tagline }}

+ + {% if site.github.is_project_page %} +

View the Project on GitHub {{ site.github.repository_nwo }}

+ {% endif %} + +

Software by g/christensen gchristensen.github.io

+ + {% if site.github.is_user_page %} +

View My GitHub Profile

+ {% endif %} + +

❤️ Support the Project

+ + {% if site.show_downloads %} + + {% endif %} +
+
+ {{ content }} +
+
+
+
+ + + diff --git a/_locales/en/help.html b/_locales/en/help.html deleted file mode 100644 index 936ab647..00000000 --- a/_locales/en/help.html +++ /dev/null @@ -1,94 +0,0 @@ -
Only Firefox 60 and later are supported now
-
-
Hint since v1.3.2: Please re-download and install backend to make ScrapBee safer
-

What's ScrapBee?

-ScrapBee is a Firefox Quantum extension trying to inherit some properties of ScrapBook, -at least, it can read/write data left over from ScrapBook (of course, we can create new). For ScrapBook do not support Firefox Quantum, ScrapBee is expected to be -an extension acts like ScrapBook under Firefox Quantum. -

With ScrapBee, you can save web page scraps to local storage, and manage scraps in browser sidebar。

-

What's Rdf?

-Rdf is ScrapBee/ScrapBook's data file in which properties of scraps stored in. And downloaded scraps will be saved in "data" folder beside the rdf file. -

Install/Setup

-After ScrapBee installed, please just finish Initiate steps before using ScrapBee. -

How to use?

- -

Click on ScrapBee Icon, ScrapBee will be opened in sidebar(Mark1), create folders in sidebar if needed.(Mark2)

- -

Full load the page you want to capture, click on menu item "Capture Page" in content menu to capture the page(Mark3)

- -

You can also save selected part of the page, and click on menu item Capture Selection to save it(Mark4)

- -

When capture started, an entity will be showed up in sidebar(Mark5), - and it's icon will be showed in status waiting until capture finished, then you can open local content(Mark6) by - clicking on it, you can also open origin URL by click on the origin URL button(Mark7).

-

Be Ware

-Whatever you are capturing, please keep ScrapBee opened in sidebar, entity of scraps captured will be saved to position in sidebar currently selected(if no folder/entity selected, it will be saved to the end of top level). Also, you can move entities around by dragging them. -

Trouble Shooting

-

Port

-If ScrapBee stop working, and if you see error message in Log window like: -
error: listen tcp :9900: bind: address already in use
-Please try to kill the process binds on that port manually, e.g. under Linux you can do: -
fuser -k 9900/tcp
-Or try another backend port in Settings -

File system permission

-Please do not register rdfs under a directory without read/write permission for current user, -e.g. common users have no write permission of Windows root directory like c:\, but sub directory of that like c:\foobar works in general. -

Reported as Trojan malware

-Backend of ScrapBee is likely treated as Trojan by scanners, please add ScrapBee to white list of scanners to prevent miss-block/mis-kill, please contact me if you have a better solution。 -

Download installation scripts fails

-If you can not download the installation scripts for some reason, e.g. mis-blocked by security tools. -Please try to re-download the backend: -
Close Firefox->Delete files under “FIREFOX-DOWNLOAD-DIRECTORY/scrapbee/”->Start Firefox->Download again -

If this not helps, please create the scripts manually in “FIREFOX-DOWNLOAD-DIRECTORY/scrapbee/”: -

-scrapbee_backend.json: (Important: replace "FIREFOX-DOWNLOAD-DIRECTORY" to your real FireFox DOWNLOAD DIRECTORY) -
- {
- "allowed_extensions": [
- "scrapbee@scrapbee.org"
- ],
- "description": "Scrapbee backend",
- "name": "scrapbee_backend",
- "path": "FIREFOX-DOWNLOAD-DIRECTORY/scrapbee/scrapbee_backend",
- "type": "stdio"
- }
-
-install.bat(Windows, CODEPAGE UTF-8): -
- chcp 65001
- reg delete "HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\scrapbee_backend" /f
- reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\scrapbee_backend" /d "FIREFOX-DOWNLOAD-DIRECTORY\scrapbee\scrapbee_backend.json" /f
- reg delete "HKEY_CURRENT_USER\Software\Mozilla\NativeMessagingHosts\scrapbee_backend" /f
- reg add "HKEY_CURRENT_USER\Software\Mozilla\NativeMessagingHosts\scrapbee_backend" /d "FIREFOX-DOWNLOAD-DIRECTORY\scrapbee\scrapbee_backend.json" /f
- echo done
- pause
-
-install.sh(Linux) -
- #!/bin/bash
- chmod +x scrapbee_backend
- dest="${HOME}/.mozilla/native-messaging-hosts"
- if [ ! -d "$dest" ];then
-     mkdir -p "$dest"
- fi
- cp scrapbee_backend.json "$dest"
- echo done
-
-install.sh(Mac) -
- #!/bin/bash
- chmod +x scrapbee_backend
- dest="${HOME}/Library/Application Support/Mozilla/NativeMessagingHosts"
- if [ ! -d $dest ];then
-     mkdir -p $dest
- fi
- cp scrapbee_backend.json $dest
- echo done
-
-

Still do not work?

-Check the log, there are maybe infomation shows what happend. -
Restart Firefox, this helps sometime. -
Or, you can make questions in our github repo: https://github.com/vctfence/scrapbee -
Or email us to scrapbee@163.com -

Backup

-ScrapBee provides no backup function, please backup rdfs and scrap files manually often :). diff --git a/_locales/en/messages.json b/_locales/en/messages.json deleted file mode 100644 index 2c9ef93b..00000000 --- a/_locales/en/messages.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "extensionName": { - "message": "ScrapBee", - "description": "Name of the extension." - }, - "extensionDescription": { - "message": "Captures the web page into local storage and manages captured pages in browser sidebar. Compatible with ScrapBook documents.", - "description": "Description of the extension." - }, - "Firefox":{ - "message": "Firefox" - }, - "DLG_OK": { - "message": "OK" - }, - "DLG_CANCEL": { - "message": "CANCEL" - }, - "Setting": { - "message": "Settings" - }, - "Reload": { - "message": "Reload" - }, - "Search": { - "message": "Search" - }, - "Help": { - "message": "Help" - }, - "Folder": { - "message": "Folder" - }, - "Open Folder": { - "message": "Open Folder" - }, - "Title": { - "message": "Title" - }, - "Rename": { - "message": "Rename" - }, - "Step": { - "message": "Step" - }, - "Name": { - "message": "Name" - }, - "Save": { - "message": "Save" - }, - "MODIFY_DOM_ON": { - "message": "Page Cleaning" - }, - "MODIFY_DOM_OFF": { - "message": "Stop Page Cleaning" - }, - "MARK_PEN": { - "message": "Mark Pen" - }, - "File": { - "message": "File" - }, - "Donate": { - "message": "Donate" - }, - "DONATE_WITH_PAYPAL": { - "message": "Donate With PayPal" - }, - "New Folder": { - "message": "New Folder" - }, - "New Separator": { - "message": "New Separator" - }, - "CapturePage": { - "message": "CapturePage" - }, - "As Root Node": { - "message": "As Root Node" - }, - "At Current Position": { - "message": "At Current Position" - }, - "CaptureSelection": { - "message": "Capture Selection" - }, - "CaptureUrl": { - "message": "Capture Url" - }, - "Download": { - "message": "Download" - }, - "ToggleSidebar": { - "message": "Toggle Sidebar" - }, - "Delete": { - "message": "Delete" - }, - "Initiate": { - "message": "Initiate" - }, - "History": { - "message": "History" - }, - "Log": { - "message": "Log" - }, - "Rdf Files":{ - "message": "Rdf File" - }, - "Backend Port":{ - "message": "Backend Port" - }, - "Background": { - "message": "Background" - }, - "FIREFOX_DOWNLOAD_DIRECTORY":{ - "message": "Firefox Download Directory" - }, - "STEP1_TEXT_1": { - "message": "Download ScrapBee backend executable and 2 installation files to\"{FIREFOX_DOWNLOAD_DIRECTORY}/screepbee/\", by just click on the button below." - }, - "STEP1_TEXT_2": { - "message": "You maybe meet problem that Firefox does not download the 3 files correctly, then you can close Firefox and remove files under\"{FIREFOX_DOWNLOAD_DIRECTORY}/screepbee/\", start Firefox and download again. (Please use standard version of {Firefox} 60 and later)" - }, - "STEP2_TEXT_1": { - "message": "Run the installation script downloaded above manually:" - }, - "STEP2_TEXT_2": { - "message": "Run the commands in your terminal:" - }, - "STEP2_TEXT_3": { - "message": "You maybe need to run the script as administrator" - }, - "STEP3_TEXT_1": { - "message": "Finish" - }, - "STEP4_TEXT_1": { - "message": "Restart {Firefox}" - }, - "STEP5_TEXT_1": { - "message": "Enjoy" - }, - "BASIC_TEXT": { - "message": "Basic text" - }, - "BOOKMARK_TEXT": { - "message": "Bookmark text" - }, - "ENTITY_SIZE": { - "message": "Entity Size" - }, - "Language": { - "message": "lanugage" - }, - "Separator": { - "message": "Separator" - }, - "RDF_FILES_TIP": { - "message": "Set absolute path rdf files ending with extension '.rdf' to register information of scraps,downloaded scraps will be saved in \"data\" folder beside the rdf file.

For example: c:\\scrapbee\\test.rdf (Windows)
or:
/home/userID/scrapbee/test.rdf (Linux)
/Users/userID/scrapbee/test.rdf (Mac)

You can setup multiple rdf files to classify your scraps,
You can have different rdfs inside same directory.
No need to create these files maunally.

Good news for ScrapBook user: ScrapBook rdfs are also supported." - }, - "BACKEND_PORT_TIP":{ - "message": "TCP port ScrapBee backend listens. e.g. 9900" - }, - "_COLOR":{ - "message": " Color" - }, - "Appearance":{ - "message": "Appearance" - }, - "Scrap":{ - "message": "scrap" - }, - "COLOR_TIP":{ - "message": "Change color of some elements to make ScrapBee a better looking.
These colors should be in format: #FFFFFF or #FFF" - }, - "ALREADY_DOWNLOADED_TO": { - "message": "already downloaded to" - }, - "Behavior":{ - "message": "Behavior" - }, - "OPEN_IN_CURRENT_TAB":{ - "message": "Open in current tab" - }, - "OPEN_IN_CURRENT_TAB_TIP":{ - "message": "By default, ScrapBee opens each scrap in new tab when clicking on an entity,
and open scraps in current tab by holding control(meta for Mac) while clicking.
You can reverse this by turn on this option. " - }, - "DONATE_WORDS":{ - "message": "To make ScrapBee grows better,
your donations are very welcome,
thanks for your support !!!" - }, - "MORE_DONATE_WAY_TOGGLE":{ - "message": "In other way" - }, - "MORE_DONATE_WAY":{ - "message": "" - }, - "RESULTS_FOUND": { - "message": " RESULT(S) FOUND" - }, - "NOT_EXISTS": { - "message": "do not exists" - }, - "Yes": { - "message": "Yes" - }, - "No": { - "message": "No" - }, - "CREATE_OR_NOT": { - "message": "create it" - }, - "NewDirectory": { - "message": "New Directory" - }, - "ConfirmDeleteDirectory": { - "message": "This is a folder, do you really want delete it and items within it?" - }, - "ConfirmDeleteItem": { - "message": "Realy delete the selected item?" - }, - "Warning": { - "message": "Warning" - }, - "Confirm": { - "message": "Confirm" - }, - "Loading...": { - "message": "Loading..." - }, - "SAME_RDF_MODIFIED": { - "message": "Same rdf modified, click OK to refresh" - }, - "NO_SELECTION_ACTIVATED": { - "message": "No selection activated" - } -} diff --git a/_locales/zh-CN/help.html b/_locales/zh-CN/help.html deleted file mode 100644 index 1a040d5a..00000000 --- a/_locales/zh-CN/help.html +++ /dev/null @@ -1,91 +0,0 @@ -
现在,ScrapBee仅支持火狐60以后的版本
-
-
1.3.2及以后版本提示: 请重新下载和安装后端使ScrapBee更加安全
-

ScrapBee是什么?

-ScrapBee 是一个继承了一些ScrapBook特性的火狐Quantum扩展, -至少,它可以读写ScrapBook遗留下来的的文档(当然,也可以新建)。
由于ScrapBook没有支持火狐Quantum,所以在期望中,ScrapBee是一个用于替代ScrapBook的火狐Quantum扩展. - -

有了ScrapBee,您可以保存网页内容到本地存储,并且可以在火狐侧边栏中进行管理。

-

什么是Rdf?

-Rdf是ScrapBee及ScrapBook的主要数据文件,用于登记已下载数据的各类相关信息。同时,下载内容被保存到同一目录下的"data"文件夹。 -

安装/设置

-安装ScrapBee之后, 只需要简单的完成初始化即可开始使用ScrapBee。 -

使用

- -

点击ScrapBee图标,在测边栏中打开ScrapBee(图示1),需要的话,在侧边栏中新建所需文件夹(图示2)

- -

打开并充分加载想保存的网页内容,在其上通过右键菜单抓取页面(图示3)

- -

您也可以保存选中的局部内容,通过右键菜单抓取选区(图示4)

- -

抓取内容时,将在侧边栏中显示相应条目(图示5),并且其图标为等待状态,直到抓取完成,这时可以通过点击条目打开本地内容(图示6),也可以通过点击原始地址按钮(图示7),打开原始链接

-

请注意

-无论抓取什么内容,都请保持ScrapBee在侧边栏中打开,抓取内容将保存到ScrapBee侧边栏中当前所选位置(如果没有选中任何文件夹,将保存到顶层目录)。另外,您可以在抓取内容后通过拖动来移动条目位置。 -

问题解决

-

端口

-如果ScrapBee工作不正常, 同时发现在日志窗口中有: -
error: listen tcp :9900: bind: address already in use
-请手工结束占用此端口的进程,比如在Linux中你可以执行: -
fuser -k 9900/tcp
-或者在设置中换用其它端口 -

文件系统权限

-请不要注册当前用户不具有读写权限的目录中的Rdf文件, -比如,对于Windows,普通用户通常没有c:\目录的写权限,而像c:\foobar这样的子目录通常可行。 -

安全软件误报

-ScrapBee的后端软件及安装脚本容易被杀毒、防木马软件误报为风险软件,请将ScrapBee后端列入其白名单来防止误报、误杀。如果您有好的改进方案,请联系我。 -

下载安装脚本失败

-由于某些原因,比如安全软件,可能会影响后端安装脚本正常下载,此时请尝试重新下载: -
关闭火狐->删除“火狐下载目录/scrapbee/”中的所有文件->启动火狐->再次下载 -

如果仍然失败,请在“火狐下载目录/scrapbee/”中尝试手工建立这些文件 -

-scrapbee_backend.json: (重要:请替换"火狐下载目录"为你的实际火狐下载目录) -
- {
- "allowed_extensions": [
- "scrapbee@scrapbee.org"
- ],
- "description": "Scrapbee backend",
- "name": "scrapbee_backend",
- "path": "火狐下载目录/scrapbee/scrapbee_backend",
- "type": "stdio"
- }
-
-install.bat(Windows, CODEPAGE UTF-8): -
- chcp 65001
- reg delete "HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\scrapbee_backend" /f
- reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\NativeMessagingHosts\scrapbee_backend" /d "FIREFOX-DOWNLOAD-DIRECTORY\scrapbee\scrapbee_backend.json" /f
- reg delete "HKEY_CURRENT_USER\Software\Mozilla\NativeMessagingHosts\scrapbee_backend" /f
- reg add "HKEY_CURRENT_USER\Software\Mozilla\NativeMessagingHosts\scrapbee_backend" /d "FIREFOX-DOWNLOAD-DIRECTORY\scrapbee\scrapbee_backend.json" /f
- echo done
- pause
-
-install.sh(Linux) -
- #!/bin/bash
- chmod +x scrapbee_backend
- dest="${HOME}/.mozilla/native-messaging-hosts"
- if [ ! -d "$dest" ];then
-     mkdir -p "$dest"
- fi
- cp scrapbee_backend.json "$dest"
- echo done
-
-install.sh(Mac) -
- #!/bin/bash
- chmod +x scrapbee_backend
- dest="${HOME}/Library/Application Support/Mozilla/NativeMessagingHosts"
- if [ ! -d $dest ];then
-     mkdir -p $dest
- fi
- cp scrapbee_backend.json $dest
- echo done
-
-

仍然有未解决问题?

-查看日志,你可能找到一些线索. -
重启火狐有时也能解决一些问题 -
也可以在github代码库中提问 https://github.com/vctfence/scrapbee -
或者发邮件联系我们scrapbee@163.com -

备份

-ScrapBee不提供备份功能,请经常手工备份Rdf和下载数据 :) diff --git a/_locales/zh-CN/messages.json b/_locales/zh-CN/messages.json deleted file mode 100644 index cbe62829..00000000 --- a/_locales/zh-CN/messages.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "extensionName": { - "message": "ScrapBee", - "description": "Name of the extension." - }, - "extensionDescription": { - "message": "抓取网页内容到本地存储,并可以在浏览器侧边栏中进行管理。兼容ScrapBook文档。", - "description": "Description of the extension." - }, - "Firefox":{ - "message": "火狐" - }, - "DLG_OK": { - "message": "确认" - }, - "DLG_CANCEL": { - "message": "取消" - }, - "Setting": { - "message": "设置" - }, - "Reload": { - "message": "刷新" - }, - "Search": { - "message": "搜索" - }, - "Help": { - "message": "帮助" - }, - "Folder": { - "message": "文件夹" - }, - "Open Folder": { - "message": "打开文件夹" - }, - "Title": { - "message": "标题" - }, - "Rename": { - "message": "重命名" - }, - "Step": { - "message": "步骤" - }, - "Name": { - "message": "名称" - }, - "Save": { - "message": "保存" - }, - "MODIFY_DOM_ON": { - "message": "页面清理" - }, - "MODIFY_DOM_OFF": { - "message": "停止页面清理" - }, - "MARK_PEN": { - "message": "记号笔" - }, - "File": { - "message": "文件" - }, - "Donate": { - "message": "捐助" - }, - "DONATE_WITH_PAYPAL": { - "message": "通过 PayPal 捐助" - }, - "New Folder": { - "message": "新建文件夹" - }, - "New Separator": { - "message": "新建分隔条" - }, - "CapturePage": { - "message": "抓取页面" - }, - "As Root Node": { - "message": "作为根目录" - }, - "At Current Position": { - "message": "在当前位置" - }, - "CaptureSelection": { - "message": "抓取选区" - }, - "CaptureUrl": { - "message": "抓取网址" - }, - "Download": { - "message": "下载" - }, - "ToggleSidebar": { - "message": "Toggle Sidebar" - }, - "Delete": { - "message": "删除" - }, - "Initiate": { - "message": "初始化" - }, - "History": { - "message": "更新历史" - }, - "Log": { - "message": "日志" - }, - "Rdf Files":{ - "message": "Rdf文件" - }, - "Backend Port":{ - "message": "端口" - }, - "Background": { - "message": "背景" - }, - "FIREFOX_DOWNLOAD_DIRECTORY":{ - "message": "火狐下载目录" - }, - "STEP1_TEXT_1": { - "message": "下载ScrapBee后端可执行文件和2个安装文件到\"{FIREFOX_DOWNLOAD_DIRECTORY}/screepbee/\",只需点击下方按钮。" - }, - "STEP1_TEXT_2": { - "message": "您可能会遇到文件不能正确下载的问题,此时可以尝试关闭{Firefox}并删除\"{FIREFOX_DOWNLOAD_DIRECTORY}/screepbee/\"中的所有文件,重启{Firefox}再次下载。另外,请使用{Firefox}60及以后的标准版{Firefox}。" - }, - "STEP2_TEXT_1": { - "message": "手工运行上面下载的安装脚本:" - }, - "STEP2_TEXT_2": { - "message": "在终端中执行:" - }, - "STEP2_TEXT_3": { - "message": "你可能需要以管理员身份运行此脚本" - }, - "STEP3_TEXT_1": { - "message": "完成" - }, - "STEP4_TEXT_1": { - "message": "重启{Firefox}" - }, - "STEP5_TEXT_1": { - "message": "敬请使用" - }, - "BASIC_TEXT": { - "message": "基础文本" - }, - "BOOKMARK_TEXT": { - "message": "书签文本" - }, - "ENTITY_SIZE": { - "message": "条目大小" - }, - "Language": { - "message": "语言" - }, - "Separator": { - "message": "分隔符" - }, - "RDF_FILES_TIP": { - "message": "设置rdf文件的绝对路径,用于登记下载信息,以'.rdf'为扩展名(下载数据将保存到rdf文件所在目录下的data文件夹)

例如: c:\\scrapbee\\test.rdf (Windows)
或者:
/home/userID/scrapbee/test.rdf (Linux)
/Users/userID/scrapbee/test.rdf (Mac)

您可以设置多个rdf文件来实现{Scrap}的分类,可以在同一文件夹下放置多个rdf文件,不需要手工创建这些文件。

ScrapBee也支持读写ScrapBook的rdf文件,这对于使用过ScrapBook的用户是一个好消息。" - }, - "BACKEND_PORT_TIP":{ - "message": "后端程序监听的TCP端口,比如9900" - }, - "_COLOR":{ - "message": "颜色" - }, - "Appearance":{ - "message": "外观" - }, - "Scrap":{ - "message": "收藏内容" - }, - "COLOR_TIP":{ - "message": "通过改变某些元素的颜色来美化ScrapBee,颜色格式应为 #FFFFFF 或 #FFF" - }, - "ALREADY_DOWNLOADED_TO": { - "message": "已下载到" - }, - "Behavior":{ - "message": "行为" - }, - "OPEN_IN_CURRENT_TAB":{ - "message": "在当前标签打开" - }, - "OPEN_IN_CURRENT_TAB_TIP":{ - "message": "点击条目时,ScrapBook默认在新标签页中打开{Scrap},
如果按住Control(苹果系统中使用Meta),则在当前标签中打开,
你可以通过此选项来切换两种操作" - }, - "DONATE_WORDS":{ - "message": "为了使ScrapBee更健康地成长,
任何数量的捐助都是非常欢迎的,
感谢您的支持!!!" - }, - "MORE_DONATE_WAY_TOGGLE":{ - "message": "其它方式" - }, - "MORE_DONATE_WAY":{ - "message": "您也可以通过以下方式捐助:



" - }, - "RESULTS_FOUND": { - "message": "个结果" - }, - "NOT_EXISTS": { - "message": "不存在" - }, - "Yes": { - "message": "是" - }, - "No": { - "message": "否" - }, - "CREATE_OR_NOT": { - "message": "是否生成" - }, - "NewDirectory": { - "message": "新建文件夹" - }, - "ConfirmDeleteDirectory": { - "message": "这是一个文件夹, 真的要删除它和其中所有内容?" - }, - "ConfirmDeleteItem": { - "message": "真的要删除所选项?" - }, - "Warning": { - "message": "警告" - }, - "Confirm": { - "message": "确认" - }, - "Loading...": { - "message": "加载中..." - }, - "SAME_RDF_MODIFIED": { - "message": "同一RDF被修改,点击确认后刷新" - }, - "NO_SELECTION_ACTIVATED": { - "message": "没有激活选区" - } -} diff --git a/addon/_locales/en/messages.json b/addon/_locales/en/messages.json new file mode 100644 index 00000000..ce2cae10 --- /dev/null +++ b/addon/_locales/en/messages.json @@ -0,0 +1,10 @@ +{ + "extensionName": { + "message": "Scrapyard", + "description": "Name of the extension." + }, + "extensionDescription": { + "message": "Advanced bookmark manager with page archiving functionality.", + "description": "Description of the extension." + } +} diff --git a/addon/background.html b/addon/background.html new file mode 100644 index 00000000..f28d3941 --- /dev/null +++ b/addon/background.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + +
You have got to this page by accident.
+ + diff --git a/addon/background_worker.js b/addon/background_worker.js new file mode 100644 index 00000000..89da8c44 --- /dev/null +++ b/addon/background_worker.js @@ -0,0 +1,6 @@ +import "./global.js"; +import "./runtime_listeners.js"; +import "./lib/xhr-shim.js"; +import "./mv3_persistent.js"; +import "./core.js"; +import "./savepage/background.js"; diff --git a/addon/bookmarking.js b/addon/bookmarking.js new file mode 100644 index 00000000..e0c9e531 --- /dev/null +++ b/addon/bookmarking.js @@ -0,0 +1,523 @@ +import {settings} from "./settings.js"; +import { + getActiveTab, + hasCSRPermission, + injectCSSFile, + injectScriptFile, + showNotification, + isHTMLTab, askCSRPermission, ACTION_ICONS +} from "./utils_browser.js"; +import {capitalize, getMimetypeByExt, sleep} from "./utils.js"; +import {send, sendLocal} from "./proxy.js"; +import { + ARCHIVE_TYPE_FILES, CHROME_BOOKMARK_TOOLBAR, + DEFAULT_SHELF_ID, + FIREFOX_BOOKMARK_TOOLBAR, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK +} from "./storage.js"; +import {fetchText, fetchWithTimeout} from "./utils_io.js"; +import {Node} from "./storage_entities.js"; +import {getFaviconFromContent, getFaviconFromTab} from "./favicon.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import * as crawler from "./crawler.js"; +import {Folder} from "./bookmarks_folder.js"; +import {isHTMLLink, parseHtml} from "./utils_html.js"; +import {getSidebarWindow, toggleSidebarWindow} from "./utils_sidebar.js"; +import {helperApp} from "./helper_app.js"; + +const SCRAPYARD_FOLDER_NAME = "Scrapyard"; + +export function formatShelfName(name) { + if (name && settings.capitalize_builtin_shelf_names()) + return capitalize(name); + + return name; +} + +export function isSpecialPage(url) { + return (url.startsWith("about:") + || url.startsWith("view-source:") || url.startsWith("moz-extension:") + || url.startsWith("https://addons.mozilla.org") || url.startsWith("https://support.mozilla.org") + || url.startsWith("chrome:") || url.startsWith("chrome-extension:") + || url.startsWith("https://chrome.google.com/webstore") + || url.startsWith(helperApp.url("/"))); +} + +export function notifySpecialPage() { + showNotification("Scrapyard cannot be used with special or already captured pages."); +} + +export async function getTabMetadata(tab) { + const result = { + name: tab.title, + uri: tab.url + }; + + const favicon = await getFaviconFromTab(tab); + if (favicon) + result.icon = favicon; + + return result; +} + +export async function getActiveTabMetadata() { + const tab = await getActiveTab(); + return await getTabMetadata(tab); +} + +export async function captureTab(tab, bookmark) { + if (isSpecialPage(tab.url)) + notifySpecialPage(); + else { + if (await isHTMLTab(tab)) + await captureHTMLTab(tab, bookmark) + else + await captureNonHTMLTab(tab, bookmark); + } +} + +async function extractSelection(tab, bookmark) { + const frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + let selection; + + for (let frame of frames) { + try { + await injectScriptFile(tab.id, {file: "/content_selection.js", frameId: frame.frameId}); + selection = await browser.tabs.sendMessage( + tab.id, + {type: "CAPTURE_SELECTION", options: bookmark}, + {frameId: frame.frameId} + ); + + if (selection) + break; + } catch (e) { + console.error(e); + } + } + + return selection; +} + +async function captureHTMLTab(tab, bookmark) { + if (!_BACKGROUND_PAGE) + await injectScriptFile(tab.id, {file: "/lib/browser-polyfill.js", allFrames: true}); + + let response; + const selection = await extractSelection(tab, bookmark); + try { response = await startSavePageCapture(tab, bookmark, selection); } catch (e) {} + + if (typeof response == "undefined") { /* no response received - content script not loaded in active tab */ + let onScriptInitialized = async (message, sender) => { + if (message.type === "CAPTURE_SCRIPT_INITIALIZED" && tab.id === sender.tab.id) { + browser.runtime.onMessage.removeListener(onScriptInitialized); + + try { + response = await startSavePageCapture(tab, bookmark, selection); + } catch (e) { + console.error(e); + } + + if (typeof response == "undefined") + showNotification("Cannot initialize the capture script, please retry."); + } + }; + browser.runtime.onMessage.addListener(onScriptInitialized); + + await injectSavePageScripts(tab) + } +} + +function startSavePageCapture(tab, bookmark, selection) { + if (settings.save_unpacked_archives()) + bookmark.contains = ARCHIVE_TYPE_FILES; + + return browser.tabs.sendMessage(tab.id, { + type: "performAction", + menuaction: 1, + saveditems: 2, + bookmark, + selection + }); +} + +async function injectSavePageScripts(tab, onError) { + if (!await hasCSRPermission()) + return; + + try { + try { + await injectScriptFile(tab.id, {file: "/savepage/content-frame.js", allFrames: true}); + await injectScriptFile(tab.id, {file: "/savepage/content-fontface.js", allFrames: true}); + } catch (e) { + console.error(e); + } + + await injectScriptFile(tab.id, {file: "/savepage/content.js"}); + } + catch (e) { + console.error(e); + + if (onError) + onError(e); + } +} + +async function captureNonHTMLTab(tab, bookmark) { + if (/^file:/i.exec(tab.url) && settings.platform.firefox) { + showNotification("Firefox version of Scrapyard can not be used with file:// URLs."); + return; + } + + try { + const headers = {"Cache-Control": "no-store"}; + const response = await fetchWithTimeout(tab.url, {timeout: 60000, headers}); + + if (response.ok) { + let contentType = response.headers.get("content-type"); + + if (!contentType) + contentType = getMimetypeByExt(new URL(tab.url).pathname) || "application/pdf"; + + bookmark.content_type = contentType; + + await Bookmark.storeArchive(bookmark, await response.arrayBuffer(), contentType); + } + } + catch (e) { + console.error(e); + } + + finalizeCapture(bookmark); +} + +export function finalizeCapture(bookmark) { + if (bookmark?.__automation && bookmark?.select) + send.bookmarkCreated({node: bookmark}); + else if (bookmark && !bookmark.__automation && !bookmark.__type_change) + send.bookmarkAdded({node: bookmark}); +} + +export async function archiveBookmark(node) { + const bookmark = await Node.get(node.id); + bookmark.type = NODE_TYPE_ARCHIVE; + await Node.idb.update(bookmark); // storage updated in Archive.add + + const isHTML = await isHTMLLink(bookmark.uri); + if (isHTML === true) { + bookmark.__type_change = true; + await packPage(bookmark.uri, bookmark, () => null, () => null, false); + } + else if (isHTML === false) { + let response; + try { + response = await fetchWithTimeout(bookmark.uri); + } catch (e) { + console.error(e); + } + + if (response.ok) + await Bookmark.storeArchive(bookmark, await response.arrayBuffer(), response.headers.get("content-type")); + } +} + +export async function showSiteCaptureOptions(tab, bookmark) { + try { + if (!_BACKGROUND_PAGE) + await injectScriptFile(tab.id, {file: "/lib/browser-polyfill.js", allFrames: true}); + + await injectScriptFile(tab.id, {file: "/savepage/content-frame.js", allFrames: true}); + await injectCSSFile(tab.id, {file: "/ui/site_capture_content.css"}); + await injectScriptFile(tab.id, {file: "/ui/site_capture_content.js", frameId: 0}); + browser.tabs.sendMessage(tab.id, {type: "storeBookmark", bookmark}); + } catch (e) { + console.error(e); + } +} + +export async function performSiteCapture(bookmark) { + if (crawler.initialize(bookmark)) { + const folder = await Folder.addSite(bookmark.parent_id, bookmark.name); + bookmark.parent_id = folder.id; + + sendLocal.createArchive({node: bookmark}); + } +} + +export function startCrawling(bookmark) { + bookmark.__site_capture.level = 0; + + crawler.crawl(bookmark); + + send.startProcessingIndication({noWait: true}); + send.toggleAbortMenu({show: true}); +} + +export function abortCrawling() { + crawler.abort(); +} + +export async function packPage(url, bookmark, initializer, resolver, hide_tab) { + return new Promise(async (resolve, reject) => { + let initializationListener; + let changedTab; + let packing; + + let completionListener = function (message, sender, sendResponse) { + if (message.type === "storePageHtml" && message.bookmark.__tab_id === packingTab.id) { + removeListeners(); + browser.tabs.remove(packingTab?.id); + + resolve(resolver(message, changedTab)); + } + }; + + let tabRemovedListener = function (tabId) { + if (tabId === changedTab.id) { + removeListeners(); + const message = {bookmark}; + resolve(resolver(message, changedTab)); + } + }; + + browser.runtime.onMessage.addListener(completionListener); + + var tabUpdateListener = async (id, changed, tab) => { + if (!changedTab && id === packingTab.id) + changedTab = tab; + if (id === packingTab.id && changed.favIconUrl) + changedTab.favIconUrl = changed.favIconUrl; + if (id === packingTab.id && changed.title) + changedTab.title = changed.title; + if (id === packingTab.id && changed.status === "complete") { // may be invoked several times + if (packing) + return; + packing = true; + + initializationListener = async function (message, sender, sendResponse) { + if (message.type === "CAPTURE_SCRIPT_INITIALIZED" && sender.tab.id === packingTab.id) { + if (initializer) + await initializer(bookmark, tab); + bookmark.__tab_id = packingTab.id; + + try { + await startSavePageCapture(packingTab, bookmark); + } catch (e) { + console.error(e); + reject(e); + } + } + }; + + browser.runtime.onMessage.addListener(initializationListener); + + if (!_BACKGROUND_PAGE) + await injectScriptFile(packingTab.id, {file: "/lib/browser-polyfill.js", allFrames: true}); + + await injectSavePageScripts(packingTab, reject); + } + }; + + function removeListeners() { + browser.tabs.onUpdated.removeListener(tabUpdateListener); + browser.tabs.onRemoved.removeListener(tabRemovedListener); + browser.runtime.onMessage.removeListener(completionListener); + browser.runtime.onMessage.removeListener(initializationListener); + } + + browser.tabs.onUpdated.addListener(tabUpdateListener); + browser.tabs.onRemoved.addListener(tabRemovedListener); + + var packingTab = await browser.tabs.create({url: url, active: false}); + + if (hide_tab) + browser.tabs.hide(packingTab.id) + }); +} + +export async function packUrl(url, hide_tab) { + return packPage(url, {}, b => b.__url_packing = true, m => m.html, hide_tab); +} + +export async function packUrlExt(url, hide_tab) { + let resolver = (m, t) => ({html: m.html, title: url.endsWith(t.title)? undefined: t.title, icon: t.favIconUrl}); + return packPage(url, {}, b => b.__url_packing = true, resolver, hide_tab); +} + +export function addBookmarkOnCommand(command) { + let type = command === "archive_to_default_shelf"? NODE_TYPE_ARCHIVE: NODE_TYPE_BOOKMARK; + + if (settings.platform.firefox) + addBookmarkOnCommandFirefox(type); + else + addBookmarkOnCommandNonFirefox(type); +} + +function addBookmarkOnCommandFirefox(type) { + if (localStorage.getItem("option-open-sidebar-from-shortcut") === "open") { + localStorage.setItem("sidebar-select-shelf", DEFAULT_SHELF_ID); + browser.sidebarAction.open(); + } + + if (type === NODE_TYPE_ARCHIVE) + askCSRPermission() // requires non-async function + .then(response => { + if (response) + addBookmarkOnCommandSendPayload(type); + }) + .catch(e => console.error(e)); + else + addBookmarkOnCommandSendPayload(type); +} + +async function addBookmarkOnCommandNonFirefox(type) { + const payload = await getActiveTabMetadata(); + await addBookmarkOnCommandSendPayload(type, payload); + + await settings.load(); + if (settings.open_sidebar_from_shortcut()) { + const window = await getSidebarWindow(); + if (!window) { + await browser.storage.session.set({"sidebar-select-shelf": DEFAULT_SHELF_ID}); + await toggleSidebarWindow(); + } + } +} + +async function addBookmarkOnCommandSendPayload(type, payload) { + if (!payload) + payload = await getActiveTabMetadata(); + + payload.type = type; + payload.parent_id = DEFAULT_SHELF_ID; + + return sendLocal.captureHighlightedTabs({options: payload}); +} + +export async function createBookmarkFromURL (url, parentId) { + let options = { + parent_id: parentId, + uri: url, + name: "Untitled" + }; + + if (!/^https?:\/\/.*/.exec(options.uri)) + options.uri = "http://" + options.uri; + + sendLocal.startProcessingIndication(); + + try { + const html = await fetchText(options.uri); + + let doc; + if (html) + doc = parseHtml(html); + + if (doc) { + const title = doc.getElementsByTagName("title")[0]?.textContent; + options.name = title || options.uri; + + const icon = await getFaviconFromContent(options.uri, doc); + if (icon) + options.icon = icon; + } + } + catch (e) { + console.error(e); + } + + const bookmark = await Bookmark.add(options, NODE_TYPE_BOOKMARK); + await sendLocal.stopProcessingIndication(); + sendLocal.bookmarkCreated({node: bookmark}); +} + +export async function addToBookmarksToolbar(node) { + let scrapyardFolder = await findScrapyardBookmarkFolder(); + + if (!scrapyardFolder) + scrapyardFolder = await createScrapyardBookmarkFolder(); + + await browser.bookmarks.create({ + parentId: scrapyardFolder.id, + title: node.name, + url: createScrapyardToolbarReference(node.uuid), + index: 0 + }); + + const maxReferences = settings.number_of_bookmarks_toolbar_references(); + + if (maxReferences) { + const references = await browser.bookmarks.getChildren(scrapyardFolder.id); + + if (references.length > maxReferences) { + const refsToDelete = references.slice(maxReferences, references.length); + + for (const ref of refsToDelete) + browser.bookmarks.remove(ref.id); + } + } +} + +async function findScrapyardBookmarkFolder() { + const scrapyardFolders = await browser.bookmarks.search({title: SCRAPYARD_FOLDER_NAME}); + + return scrapyardFolders.find(f => !f.url && + (settings.platform.firefox && f.parentId === FIREFOX_BOOKMARK_TOOLBAR + || settings.platform.chrome && f.parentId === CHROME_BOOKMARK_TOOLBAR)); +} + +async function createScrapyardBookmarkFolder() { + let parentId = FIREFOX_BOOKMARK_TOOLBAR; + + if (settings.platform.chrome) + parentId = "1"; + + const options = { + parentId: parentId, + title: SCRAPYARD_FOLDER_NAME + }; + + if (settings.platform.firefox) + options.type = "folder"; + + return browser.bookmarks.create(options); +} + +export function createScrapyardToolbarReference(uuid = "") { + let referenceURL = `ext+scrapyard://${uuid}`; + + return browser.runtime.getURL(`/reference.html?toolbar#${referenceURL}`); +} + +export async function removeFromBookmarksToolbar(uuid) { + const scrapyardFolder = await findScrapyardBookmarkFolder(); + + if (scrapyardFolder) { + const references = await browser.bookmarks.getChildren(scrapyardFolder.id); + const refToRemove = references.find(r => r.url.endsWith(uuid)); + + if (refToRemove) + return browser.bookmarks.remove(refToRemove.id); + } +} + +export async function setBookmarkedActionIcon(url) { + const action = _MANIFEST_V3? browser.action: browser.browserAction; + + if (!url) + url = (await getActiveTab())?.url; + + if (await Node.urlExists(url)) { + if (settings.platform.firefox) + action.setIcon({path: "/icons/scrapyard-star.svg"}); + else + action.setIcon({path: "/icons/scrapyard-star.png"}); + } + else { + if (settings.platform.firefox) + action.setIcon({path: "/icons/scrapyard.svg"}); + else + action.setIcon({path: ACTION_ICONS}); + } +} diff --git a/addon/bookmarks.js b/addon/bookmarks.js new file mode 100644 index 00000000..6d6360ab --- /dev/null +++ b/addon/bookmarks.js @@ -0,0 +1,131 @@ +import {isContainerNode} from "./storage.js"; +import {Node} from "./storage_entities.js"; +import {Query} from "./storage_query.js"; + +// a proxy class that calls handlers of the registered external backends if they are implemented +// an external backend may have the "initialize" method which is called after the settings are loaded +// the corresponding backend is chosen by the first found value of the "external" field in any argument +export class PluginContainer { + constructor() { + this.externalBackends = {}; + + this._addHandler("createBookmarkFolder"); + this._addHandler("createBookmark"); + this._addHandler("renameBookmark"); + this._addHandler("moveBookmarks"); + this._addHandler("beforeBookmarkCopied") + this._addHandler("copyBookmarks"); + this._addHandler("deleteBookmarks"); + this._addHandler("updateBookmark"); + this._addHandler("updateBookmarks"); + this._addHandler("reorderBookmarks"); + this._addHandler("storeBookmarkData"); + this._addHandler("updateBookmarkData"); + this._addHandler("storeBookmarkNotes"); + this._addHandler("storeBookmarkComments"); + } + + registerPlugin(name, backend) { + if (backend.initialize) + backend.initialize(); + this.externalBackends[name] = backend; + } + + unregisterExternalBackend(name) { + delete this.externalBackends[name]; + } + + _addHandler(methodName) { + const handler = async (...args) => { + const external = this._findExternal(args); + + if (external) { + const backend = this.externalBackends[external]; + if (backend[methodName]) + await backend[methodName].apply(backend, args); + } + }; + + const proto = Object.getPrototypeOf(this); + proto[methodName] = handler; + } + + _findExternal(args) { + let external; + for (const arg of args) { + if (Array.isArray(arg)) { + const node = arg.find(n => n.hasOwnProperty("external")); + if (node) { + external = node.external; + break; + } + } + else { + if (arg?.hasOwnProperty("external")) { + external = arg.external; + break; + } + } + } + return external; + } +} + +export let plugins = new PluginContainer(); + +// the base class for high-level bookmarking entities: Bookmark, Shelf, etc. +export class EntityManager { + constructor() { + this.plugins = plugins; + } + + async traverse(root, visitor) { + let doTraverse = async (parent, root) => { + await visitor(parent, root); + let children = isContainerNode(root) + ? await Node.getChildren(root.id) + : null; + if (children) + for (let c of children) + await doTraverse(root, c); + }; + + return doTraverse(null, root); + } + + async ensureUniqueName(parentId, name, oldName) { + if (!name) + return ""; + + let children; + + if (parentId) + children = (await Node.getChildren(parentId)).map(c => c.name); + else + children = (await Query.allShelves()).map(c => c.name); + + children = children.filter(c => !!c); + + if (oldName) + children = children.filter(c => c !== oldName); + + children = children.map(c => c.toLocaleUpperCase()); + + let uname = name.toLocaleUpperCase(); + let original = name; + let n = 1; + + let m = original.match(/.*( \(\d+\))$/); + + if (m) + original = original.replace(m[1], ""); + + while (children.some(c => c === uname)) { + name = original + " (" + n + ")"; + uname = name.toLocaleUpperCase(); + n += 1 + } + + return name; + } +} diff --git a/addon/bookmarks_bookmark.js b/addon/bookmarks_bookmark.js new file mode 100644 index 00000000..9070a542 --- /dev/null +++ b/addon/bookmarks_bookmark.js @@ -0,0 +1,597 @@ +import {EntityManager} from "./bookmarks.js"; +import { + byDateAddedDesc, + byPosition, + DEFAULT_SHELF_UUID, + DONE_SHELF_NAME, + nodeHasSomeContent, isVirtualShelf, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, + NODE_TYPE_FOLDER, NODE_TYPE_NOTES, + NODE_TYPE_SEPARATOR, + NODE_TYPE_SHELF, NON_IMPORTABLE_SHELVES, + TODO_SHELF_NAME, DEFAULT_POSITION, + RDF_EXTERNAL_TYPE, EVERYTHING_SHELF_NAME, ARCHIVE_TYPE_FILES +} from "./storage.js"; +import {indexString} from "./utils_html.js"; +import {Query} from "./storage_query.js"; +import {Path} from "./path.js"; +import {Folder} from "./bookmarks_folder.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {cleanObject, getMimetypeByExt} from "./utils.js"; +import {getFaviconFromContent} from "./favicon.js"; +import {Archive, Comments, Icon, Node, Notes} from "./storage_entities.js"; +import {undoManager} from "./bookmarks_undo.js"; + +export class BookmarkManager extends EntityManager { + _Node; + + static newInstance() { + const instance = new BookmarkManager(); + + instance.idb = new BookmarkManager(); + + return instance; + } + + configure() { + this._Node = Node; + this.idb._Node = Node.idb; + } + + _splitTags(tags, separator = ",") { + if (tags && typeof tags === "string") + return tags.split(separator) + .filter(t => !!t) + .map(t => t.trim()) + .map(t => t.toLocaleUpperCase()); + + return tags; + } + + setTentativeId(node) { + node.__tentative_id = "tentative_" + Math.floor(Math.random() * 1000); + return node.__tentative_id; + } + + async add(data, nodeType = NODE_TYPE_BOOKMARK) { + if (data.parent_id) + data.parent_id = parseInt(data.parent_id); + else + throw new Error("No bookmark parent id"); + + const parent = await Node.get(data.parent_id); + + if (nodeType === NODE_TYPE_BOOKMARK && parent.external === RDF_EXTERNAL_TYPE) + throw new Error("Only archives could be added to an RDF file."); + + data.external = parent.external; + data.name = await this.ensureUniqueName(data.parent_id, data.name); + + data.type = nodeType; + if (data.tags) + data.tag_list = this._splitTags(data.tags); + //await this.addTags(data.tag_list); + + const [iconId, dataUrl] = await this.storeIcon(data); + + if (iconId) + data.content_modified = new Date(); + + const node = await this._Node.add(data); + + if (iconId) { + await Icon.update(iconId, {node_id: node.id}); + await Icon.persist(node, dataUrl); + } + + await this.plugins.createBookmark(node, parent); + + return node; + } + + async addSeparator(parentId) { + const parent = await Node.get(parentId); + const options = { + name: "-", + type: NODE_TYPE_SEPARATOR, + parent_id: parentId, + external: parent.external + }; + + let node = await this._Node.add(options); + + try { + await this.plugins.createBookmark(node, parent); + } + catch (e) { + console.error(e); + } + + return node; + } + + async addNotes(parentId, name) { + let folder = await Node.get(parentId); + let node = await this._Node.add({ + parent_id: parentId, + name: name, + //has_notes: true, + type: NODE_TYPE_NOTES, + external: folder.external + }); + + try { + await this.plugins.createBookmark(node, folder); + } + catch (e) { + console.error(e); + } + + return node; + } + + async import(data, sync) { + if (data.uuid === DEFAULT_SHELF_UUID) + return; + + if (data.type !== NODE_TYPE_SHELF) + data.parent_id = data.parent_id || (await Folder.getOrCreateByPath(data.path)).id; + + data = Object.assign({}, data); + + if (data.tags) + data.tag_list = this._splitTags(data.tags); + //this.addTags(data.tag_list); + + const exists = await Node.exists(data); + let forceNewUuid = data.uuid && (!sync && (exists || NON_IMPORTABLE_SHELVES.some(uuid => uuid === data.uuid))); + + const now = new Date(); + + if (!data.date_added) + data.date_added = now; + + // sync uses date_modified field to track changes, so this field should be updated on a regular import, + // but it should not be touched when performing a sync import + if (!sync) { + data.date_modified = now; + if (data.content_modified || nodeHasSomeContent(data)) + data.content_modified = data.date_modified; + } + + if (!data.uuid || forceNewUuid) + Node.setUUID(data); + + let result; + + if (sync && exists) { + const node = await Node.getByUUID(data.uuid); + data.id = node.id; + result = this._Node.update(data, false); + } + else + result = this._Node.import(data); + + return result; + } + + async update(data) { + let update = {}; + Object.assign(update, data); + + //update.name = await this.ensureUniqueName(update.parent_id, update.name) + + if (data.tags) + update.tag_list = this._splitTags(update.tags); + //this.addTags(update.tag_list); + + await this.plugins.updateBookmark(update); + + return this._Node.update(update); + } + + clean(bookmark) { + cleanObject(bookmark, true); + + if (!bookmark.name) + bookmark.name = ""; + } + + async list(options //{search, // filter by node name or URL + // path, // filter by hierarchical node group path (string), the first item in the path is a name of a shelf + // tags, // filter for node tags (string, containing comma separated list) + // date, // filter nodes by date + // date2, // the second date in query + // period, // chronological period: "between", "before", "after" + // types, // filter for node types (array of integers) + // limit, // limit for the returned record number + // depth, // specify depth of search: "group" (sic!, a folder, used in external api), "subtree" or "root+subtree" + // order // order mode to sort the output if specified: "custom", "todo", "date_desc" + // content // search in content instead of node name (boolean) + // index // index to use: "content", "comments", "notes" + // partial // partially match words (boolean) + //} + ) { + const path = options.path || EVERYTHING_SHELF_NAME; + let folder = isVirtualShelf(path)? null: await Folder.getByPath(path); + + if (!options.depth) + options.depth = "subtree"; + + if (options.tags) + options.tags = this._splitTags(options.tags); + + let result; + + if (options.content && options.search) { + const search = indexString(options.search); + + let subtree; + if (path) { + subtree = []; + + if (path.toLowerCase() === EVERYTHING_SHELF_NAME) + subtree = null; + else if (path.toUpperCase() === TODO_SHELF_NAME) + subtree = (await Query.todo()).map(n => n.id); + else if (path.toUpperCase() === DONE_SHELF_NAME) + subtree = (await Query.done()).map(n => n.id); + else + await Query.selectAllChildrenIdsOf(folder.id, subtree); + } + + result = await Query.nodesByIndex(subtree, search, options.index, options.partial); + } + else { + result = await Query.nodes(folder, options); + } + + if (path?.toUpperCase() === TODO_SHELF_NAME || path?.toUpperCase() === DONE_SHELF_NAME) { + for (let node of result) { + node.__extended_todo = true; + let pathList = await Path.compute(node); + + node.__path = []; + for (let i = 0; i < pathList.length - 1; ++i) { + node.__path.push(pathList[i].name) + } + } + } + + result.forEach(n => n.__filtering = true); + + if (options.order === "custom") + result.sort(byPosition); + else if (options.order === "date_desc") + result.sort(byDateAddedDesc); + + return result; + } + + async reorder(positions, posProperty = "pos") { + try { + await this.plugins.reorderBookmarks(positions); + } + catch (e) { + console.error(e); + } + + const id2pos = new Map(positions.map(n => [n.id, n[posProperty]])); + await this._Node.batchUpdate(n => n[posProperty] = id2pos.get(n.id), Array.from(id2pos.keys())); + } + + async move(ids, destId, moveLast) { + const dest = await Node.get(destId); + const nodes = await Node.get(ids); + + try { + await this.plugins.moveBookmarks(dest, nodes); + } + catch (e) { + console.error(e); + + if (e.name === "EScrapyardPluginError") + throw e; + } + + // a check for circular references + const ascendants = new Set(await Query.ascendantIdsOf(dest)); + ascendants.add(destId); + + for (const node of nodes) + if (ascendants.has(node.id)) { + const error = new Error("A circular reference while moving nodes"); + error.name = "EScrapyardCircularReference"; + throw error; + } + + for (let n of nodes) { + n.parent_id = destId; + n.name = await this.ensureUniqueName(destId, n.name); + + if (moveLast) + n.pos = DEFAULT_POSITION; + + await this._Node.update(n); + } + + if (nodes.some(n => n.type === NODE_TYPE_FOLDER)) + ishellConnector.invalidateCompletion(); + + return Query.fullSubtree(ids, true); + } + + async copy(ids, destId, moveLast) { + const dest = await Node.get(destId); + let sourceNodes = await Query.fullSubtree(ids, true); + let newNodes = []; + + for (let newNode of sourceNodes) { + const sourceNode = {...newNode}; + const sourceNodeId = newNode.source_node_id = newNode.id; + + if (ids.some(id => id === sourceNodeId)) { + newNode.parent_id = destId; + newNode.name = await this.ensureUniqueName(destId, newNode.name); + } + else { + let newParent = newNodes.find(nn => nn.source_node_id === newNode.parent_id); + if (newParent) + newNode.parent_id = newParent.id; + } + + delete newNode.id; + delete newNode.date_modified; + + if (moveLast && ids.some(id => id === newNode.source_node_id)) + newNode.pos = DEFAULT_POSITION; + + await this.plugins.beforeBookmarkCopied(dest, newNode); + + newNodes.push(Object.assign(newNode, await this._Node.add(newNode))); + + try { + await this.copyContent(sourceNode, newNode); + } catch (e) { + console.error(e); + } + } + + let rootNodes = newNodes.filter(n => ids.some(id => id === n.source_node_id)); + + try { + await this.plugins.copyBookmarks(dest, rootNodes); + + if (rootNodes.some(n => n.type === NODE_TYPE_FOLDER)) + ishellConnector.invalidateCompletion(); + } + catch (e) { + console.error(e); + } + + return newNodes; + } + + async copyContent(sourceNode, newNode) { + if (sourceNode.stored_icon) { + let icon = await Icon.get(sourceNode); + if (icon) + await Icon.add(newNode, icon); + } + + if (sourceNode.type === NODE_TYPE_ARCHIVE) { + let archive = await Archive.get(sourceNode); + + if (archive) { + const index = await Archive.fetchIndex(sourceNode); + await Archive.add(newNode, archive, index); + } + } + + if (sourceNode.has_notes) { + let notes = await Notes.get(sourceNode); + if (notes) { + delete notes.id; + notes.node_id = newNode.id; + await Notes.add(newNode, notes); + } + } + + if (sourceNode.has_comments) { + let comments = await Comments.get(sourceNode); + if (comments) + await Comments.add(newNode, comments); + } + } + + async _delete(nodes, deletef) { + try { + await this.plugins.deleteBookmarks(nodes); + } + catch (e) { + console.error(e); + } + + await deletef(nodes); + + if (nodes.some(n => n.type === NODE_TYPE_FOLDER || n.type === NODE_TYPE_SHELF)) + ishellConnector.invalidateCompletion(); + } + + async _hardDelete(nodes) { + return this._delete(nodes, nodes => this._Node.delete(nodes)); + } + + async delete(ids) { + const nodes = await Query.fullSubtree(ids); + + return this._hardDelete(nodes); + } + + async softDelete(ids) { + const nodes = await Query.fullSubtree(ids); + + if (nodes.some(n => n.external === RDF_EXTERNAL_TYPE)) + return this._hardDelete(nodes); + + return this._delete(nodes, this._undoDelete.bind(this)); + } + + async _undoDelete(nodes, ids) { + await undoManager.pushDeleted(ids, nodes); + + return this._Node.deleteShallow(nodes); + } + + async deleteChildren(id) { + let all_nodes = await Query.fullSubtree(id); + + await this._Node.delete(all_nodes.filter(n => n.id !== id)); + + ishellConnector.invalidateCompletion(); + } + + async restore(node) { + await this._Node.put(node); + + if (node.parent_id) { + const parent = await Node.get(node.parent_id); + await this.plugins.createBookmark(node, parent); + } + } + + async storeIcon(node, iconData, contentType) { + const convertAndStore = async (iconData, contentType) => { + if (iconData.byteLength && contentType && contentType.startsWith("image")) { + const byteArray = new Uint8Array(iconData); + + let binaryString = ""; + for (let i = 0; i < byteArray.byteLength; i++) + binaryString += String.fromCharCode(byteArray[i]); + + contentType = contentType.split(";")[0]; + + let iconUrl = `data:${contentType};base64,${btoa(binaryString)}`; + + const id = await Icon.import.add(node, iconUrl); + + return [id, iconUrl]; + } + }; + + const updateNode = async (node, iconUrl) => { + node.stored_icon = true; + node.icon = await Icon.computeHash(iconUrl); + if (node.id) + await this._Node.updateContentModified(node); + }; + + let iconId; + let dataUrl; + + if (node.icon) { + try { + if (node.icon.startsWith("data:")) { + iconId = await Icon.import.add(node, node.icon); + dataUrl = node.icon; + await updateNode(node, node.icon); + } + else { + if (iconData && contentType) { + const [id, iconUrl] = await convertAndStore(iconData, contentType); + await updateNode(node, iconUrl); + iconId = id; + dataUrl = iconUrl; + } + else { + try { + const response = await fetch(node.icon); + + if (response.ok) { + let type = response.headers.get("content-type"); + + if (!type) { + let iconUrl = new URL(node.icon); + type = getMimetypeByExt(iconUrl.pathname); + } + + if (type.startsWith("image")) { + const buffer = await response.arrayBuffer(); + if (buffer.byteLength) { + const [id, iconUrl] = await convertAndStore(buffer, type); + await updateNode(node, iconUrl); + iconId = id; + dataUrl = iconUrl; + } + } + } + } + catch (e) { + node.icon = undefined; + node.stored_icon = undefined; + if (node.id) + await this._Node.update(node); + console.error(e); + } + } + } + } + catch (e) { + console.error(e); + } + } + + return [iconId, dataUrl]; + } + + async storeIconFromURI(node) { + try { + node.icon = await getFaviconFromContent(node.uri); + await this.storeIcon(node); + } catch (e) { + console.error(e); + } + } + + async storeArchive(node, data, contentType, index) { + const archive = Archive.entity(node, data, contentType); + + if (node.contains === ARCHIVE_TYPE_FILES) { + await Archive.storeIndex(node, index.words); + await Archive.saveFile(node, "index.html", data); + await Archive.updateContentModified(node, archive); + } + else + await Archive.add(node, archive, index); + + await this.plugins.storeBookmarkData(node, data, contentType); + } + + async updateArchive(uuid, data) { + const node = await Node.getByUUID(uuid); + await Archive.updateHTML(node, data); + await this.plugins.updateBookmarkData(node, data); + } + + async storeNotes(options, propertyChange) { + const node = await Node.get(options.node_id); + await Notes.add(node, options, propertyChange); + await this.plugins.storeBookmarkNotes(node, options, propertyChange); + } + + async storeComments(nodeId, comments) { + const node = await Node.get(nodeId); + await Comments.add(node, comments); + await this.plugins.storeBookmarkComments(node, comments); + } + + async isSitePage(node) { + const parent = await Node.get(node.parent_id); + return parent.site; + } +} + +export let Bookmark = BookmarkManager.newInstance(); + + diff --git a/addon/bookmarks_folder.js b/addon/bookmarks_folder.js new file mode 100644 index 00000000..0d6565e9 --- /dev/null +++ b/addon/bookmarks_folder.js @@ -0,0 +1,168 @@ +import {Query} from "./storage_query.js"; +import {Path} from "./path.js"; +import {EntityManager} from "./bookmarks.js"; +import {NODE_TYPE_FOLDER, NODE_TYPE_SHELF} from "./storage.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {Node} from "./storage_entities.js"; + +class FolderManager extends EntityManager { + #Node; + + static newInstance() { + const instance = new FolderManager(); + + instance.idb = new FolderManager(); + + return instance; + } + + configure() { + this.#Node = Node; + this.idb.#Node = Node.idb; + } + + async add(parent, name, nodeType = NODE_TYPE_FOLDER) { + if (parent && typeof parent === "number") + parent = await this.#Node.get(parent); + + return this._addNode({ + name, + type: nodeType, + parent_id: parent?.id + }, parent); + } + + async addSite(parentId, name) { + const parent = await this.#Node.get(parentId); + + return this._addNode({ + name, + type: NODE_TYPE_FOLDER, + parent_id: parentId, + site: true + }, parent); + } + + async _addNode(node, parent) { + node.name = await this.ensureUniqueName(parent?.id, node.name); + node.external = parent?.external; + node = await this.#Node.add(node); + + try { + ishellConnector.invalidateCompletion(); + + if (parent) + await this.plugins.createBookmarkFolder(node, parent); + } + catch (e) { + console.error(e); + } + + return node; + } + + // returns map of folders the function was able to find in the path + async _queryFolders(pathList) { + pathList = [...pathList]; + + let folders = []; + let shelfName = pathList.shift(); + let shelf = await Query.shelf(shelfName); + + if (shelf) + folders.push(shelf); + else + return []; + + let parent = shelf; + for (let name of pathList) { + if (parent) { + let folder = await Query.subfolder(parent.id, name); + folders.push(folder); + parent = folder; + } + else + break; + } + + return folders; + } + + // returns the last folder in the path if it exists + async getByPath(path) { + let pathList = Path.split(path); + let folders = await this._queryFolders(pathList); + + return folders.at(-1); + } + + // creates all non-existent folders in the path + async getOrCreateByPath(path) { + let pathList = Path.split(path); + let folders = await this._queryFolders(pathList); + let shelfName = pathList.shift(); + let parent = folders.shift(); + + if (!parent) { + parent = await this.#Node.add({ + name: shelfName, + type: NODE_TYPE_SHELF + }); + ishellConnector.invalidateCompletion(); + } + + let ctr = 0; + for (let name of pathList) { + let folder = folders[ctr++]; + + if (folder) { + parent = folder; + } + else { + let node = await this.#Node.add({ + parent_id: parent.id, + external: parent.external, + name: name, + type: NODE_TYPE_FOLDER + }); + + try { + await this.plugins.createBookmarkFolder(node, parent); + ishellConnector.invalidateCompletion(); + } + catch (e) { + console.error(e); + } + + parent = node; + } + } + + return parent; + } + + async rename(id, newName) { + let folder = await Node.get(id); + + if (folder.name !== newName) { + if (folder.name.toLocaleUpperCase() !== newName.toLocaleUpperCase()) + folder.name = await this.ensureUniqueName(folder.parent_id, newName, folder.name); + else + folder.name = newName; + + try { + await this.plugins.renameBookmark(folder); + ishellConnector.invalidateCompletion(); + } + catch (e) { + console.error(e); + } + + await this.#Node.update(folder); + } + return folder; + } + +} + +export let Folder = FolderManager.newInstance(); diff --git a/addon/bookmarks_init.js b/addon/bookmarks_init.js new file mode 100644 index 00000000..1cd1491b --- /dev/null +++ b/addon/bookmarks_init.js @@ -0,0 +1,25 @@ +import {settings} from "./settings.js"; +import {BROWSER_EXTERNAL_TYPE, CLOUD_EXTERNAL_TYPE, FILES_EXTERNAL_TYPE, RDF_EXTERNAL_TYPE} from "./storage.js"; +import {browserShelf} from "./plugin_browser_shelf.js"; +import {cloudShelf} from "./plugin_cloud_shelf.js"; +import {rdfShelf} from "./plugin_rdf_shelf.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {plugins} from "./bookmarks.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Folder} from "./bookmarks_folder.js"; +import {filesShelf} from "./plugin_files_shelf.js"; + +export let systemInitialization = new Promise(async resolve => { + await settings.load(); + + Bookmark.configure(); + Folder.configure(); + + plugins.registerPlugin(FILES_EXTERNAL_TYPE, filesShelf); + plugins.registerPlugin(BROWSER_EXTERNAL_TYPE, browserShelf); + plugins.registerPlugin(CLOUD_EXTERNAL_TYPE, cloudShelf); + plugins.registerPlugin(RDF_EXTERNAL_TYPE, rdfShelf); + plugins.registerPlugin("ishell", ishellConnector); + + resolve(true); +}); diff --git a/addon/bookmarks_shelf.js b/addon/bookmarks_shelf.js new file mode 100644 index 00000000..f7c2781a --- /dev/null +++ b/addon/bookmarks_shelf.js @@ -0,0 +1,39 @@ +import {EntityManager} from "./bookmarks.js"; +import { + byPosition, + EVERYTHING_SHELF_NAME, + NODE_TYPE_SHELF, + NODE_TYPE_UNLISTED +} from "./storage.js"; +import {Query} from "./storage_query.js"; +import {Folder} from "./bookmarks_folder.js"; +import {Node} from "./storage_entities.js"; + +class ShelfManager extends EntityManager { + + add(name) { + return Folder.add(null, name, NODE_TYPE_SHELF); + } + + async listContent(shelfName) { + let nodes = []; + + if (shelfName === EVERYTHING_SHELF_NAME) + nodes = await Node.get(); + else { + const shelfNode = await Query.shelf(shelfName); + if (shelfNode) + nodes = await Query.fullSubtree(shelfNode.id); + } + + if (nodes) { + nodes = nodes.filter(n => !(n._unlisted || n.type === NODE_TYPE_UNLISTED)); + nodes.sort(byPosition); + } + + return nodes; + } + +} + +export let Shelf = new ShelfManager(); diff --git a/addon/bookmarks_todo.js b/addon/bookmarks_todo.js new file mode 100644 index 00000000..02e04c5e --- /dev/null +++ b/addon/bookmarks_todo.js @@ -0,0 +1,71 @@ +import {Query} from "./storage_query.js"; +import {Path} from "./path.js"; + +import {EntityManager} from "./bookmarks.js"; +import {Node} from "./storage_entities.js"; +import {byTODOPosition} from "./storage.js"; + +class TODOManager extends EntityManager { + + async setState(states) { + await Node.update(states); + return this.plugins.updateBookmarks(states); + } + + async listTODO() { + let todo = await Query.todo(); + todo.reverse(); + todo.sort(byTODOPosition); + todo.sort((a, b) => a.todo_state - b.todo_state); + + let now = new Date(); + now.setUTCHours(0, 0, 0, 0); + + for (let node of todo) { + let todo_date; + + if (node.todo_date && node.todo_date != "") + try { + todo_date = new Date(node.todo_date); + todo_date.setUTCHours(0, 0, 0, 0); + } + catch (e) { + } + + if (todo_date && now >= todo_date) + node.__overdue = true; + + let path = await Path.compute(node); + + node.__path = []; + for (let i = 0; i < path.length - 1; ++i) { + node.__path.push(path[i].name) + } + + node.__extended_todo = true; + } + + return todo.filter(n => n.__overdue).concat(todo.filter(n => !n.__overdue)); + } + + async listDONE() { + let done = await Query.done(); + done.sort(byTODOPosition); + done.sort((a, b) => a.todo_state - b.todo_state); + + for (let node of done) { + let path = await Path.compute(node); + + node.__path = []; + for (let i = 0; i < path.length - 1; ++i) { + node.__path.push(path[i].name) + } + + node.__extended_todo = true; + } + + return done; + } +} + +export let TODO = new TODOManager(); diff --git a/addon/bookmarks_undo.js b/addon/bookmarks_undo.js new file mode 100644 index 00000000..c298de69 --- /dev/null +++ b/addon/bookmarks_undo.js @@ -0,0 +1,79 @@ +import {Undo} from "./storage_undo.js"; +import {NODE_TYPE_SHELF, UNDO_DELETE} from "./storage.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Query} from "./storage_query.js"; +import {Node} from "./storage_entities.js"; + +class UndoManager { + + async canUndo() { + return (await Undo.peek()).stack >= 0; + } + + async undo() { + const undoTop = await Undo.peek(); + + if (undoTop.stack >= 0) + switch (undoTop.operation) { + case UNDO_DELETE: + return this.#undoDelete(); + } + } + + async pushDeleted(ids, subtree) { + const stackIndex = (await Undo.peek()).stack + 1; + + let ctr = 0; + for (const node of subtree) { + const undoItem = { + stack: stackIndex, + operation: UNDO_DELETE, + node, + selectedIDs: ctr++ === 0? ids: undefined // (!) currently not used + }; + + await Undo.add(undoItem); + } + } + + async #undoDelete() { + const batch = await Undo.pop(); + const selectedIDs = batch[0].selectedIDs; + + let shelf; + for (const undo of batch) { + if (undo.node.type === NODE_TYPE_SHELF) + shelf = undo.node; + + await Bookmark.restore(undo.node); + } + + if (!shelf) + shelf = await Query.rootOf(batch[0].node); + + return {operation: UNDO_DELETE, selectedIDs, shelf}; + } + + async commit() { + let batch = await Undo.pop(); + + while (batch) { + switch (batch[0].operation) { + case UNDO_DELETE: + await this.#commitDelete(batch); + break; + } + + batch = await Undo.pop(); + } + } + + async #commitDelete(batch) { + const nodes = batch.map(u => u.node); + + await Node.deleteDependencies(nodes); + } + +} + +export const undoManager = new UndoManager(); diff --git a/addon/browse.js b/addon/browse.js new file mode 100644 index 00000000..b7a38133 --- /dev/null +++ b/addon/browse.js @@ -0,0 +1,323 @@ +import { + CLOUD_EXTERNAL_TYPE, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, NODE_TYPE_FILE, + NODE_TYPE_FOLDER, + NODE_TYPE_NOTES, + RDF_EXTERNAL_TYPE +} from "./storage.js"; +import {Archive, Node} from "./storage_entities.js"; +import {Query} from "./storage_query.js"; +import { + injectCSSFile, + injectScriptFile, + openContainerTab, + openPage, showNotification, + updateTabURL +} from "./utils_browser.js"; +import {settings} from "./settings.js"; +import {HELPER_APP_v2_1_IS_REQUIRED, HELPER_APP_v2_IS_REQUIRED, helperApp} from "./helper_app.js"; +import {send, sendLocal} from "./proxy.js"; +import {rdfShelf} from "./plugin_rdf_shelf.js"; +import {Bookmark} from "./bookmarks_bookmark.js" + +function configureArchiveTab(node, archiveTab) { + var tabUpdateListener = async (id, changed, tab) => { + if (tab.id === archiveTab.id) { + if (changed?.hasOwnProperty("attention")) + return; + + if (changed.status === "complete") + await configureArchivePage(tab, node); + } + }; + + browser.tabs.onUpdated.addListener(tabUpdateListener); + + function tabRemoveListener(tabId) { + if (tabId === archiveTab.id) { + if (settings.storage_mode_internal()) + revokeTrackedObjectURLs(tabId); + + browser.tabs.onRemoved.removeListener(tabRemoveListener); + browser.tabs.onUpdated.removeListener(tabUpdateListener); + } + } + + browser.tabs.onRemoved.addListener(tabRemoveListener); +} + +async function configureArchivePage(tab, node) { + if (node.external === CLOUD_EXTERNAL_TYPE && Archive.isUnpacked(node)) + return; + + await injectCSSFile(tab.id, {file: "ui/edit_toolbar.css"}); + await injectScriptFile(tab.id, {file: "lib/jquery.js", frameId: 0}); + if (!_BACKGROUND_PAGE) + await injectScriptFile(tab.id, {file: "lib/browser-polyfill.js", frameId: 0}); + await injectScriptFile(tab.id, {file: "ui/edit_toolbar.js", frameId: 0}); + + if ((tab.url?.startsWith("blob:") || tab.url?.startsWith(helperApp.url("/browse"))) + && settings.open_bookmark_in_active_tab()) { + const uuid = tab.url.split("/").at(-2); + node = await Node.getByUUID(uuid); + } + else if (settings.open_bookmark_in_active_tab()) + node = undefined; + + if (node && await Bookmark.isSitePage(node)) + await configureSiteLinks(node, tab); +} + +async function configureSiteLinks(node, tab) { + await injectScriptFile(tab.id, {file: "content_site.js", allFrames: true}); + const siteMap = await buildSiteMap(node); + browser.tabs.sendMessage(tab.id, {type: "CONFIGURE_SITE_LINKS", siteMap, useProtocol: _BACKGROUND_PAGE}); +} + +async function buildSiteMap(node) { + const archives = await listSiteArchives(node); + return archives.reduce((acc, n) => { + acc[n.uri] = n.uuid; + return acc; + }, {}); +} + +function openURL(url, options, newtabf = openPage) { + if (options?.tab) + return updateTabURL(options.tab, url, options.preserveHistory); + + return newtabf(url, options?.container); +} + +function browseBookmark(node, options) { + let url = node.uri; + if (url) { + try { + new URL(url); + } catch (e) { + url = "http://" + url; + } + + if (options) + options.container = options.container || node.container; + else + options = {container: node.container}; + + return openURL(url, options, openContainerTab); + } +} + +var archiveTabs = {}; + +function trackArchiveTab(tabId, url) { + let urls = archiveTabs[tabId]; + if (!urls) { + urls = new Set([url]); + archiveTabs[tabId] = urls; + } + else + urls.add(url); +} + +function isArchiveTabTracked(tabId) { + return !!archiveTabs[tabId]; +} + +function revokeTrackedObjectURLs(tabId) { + const objectURLs = archiveTabs[tabId]; + + if (objectURLs) { + delete archiveTabs[tabId]; + + for (const url of objectURLs) + if (url.startsWith("blob:")) + URL.revokeObjectURL(url); + } +} + +async function browseArchiveIDB(node, options) { + if (node.__tentative) + return; + + if (node.external === RDF_EXTERNAL_TYPE) + return browseRDFArchive(node, options); + + const archive = await Archive.get(node); + if (archive) { + let objectURL = await getBlobURL(archive); + + if (objectURL) { + const archiveURL = objectURL + "#/" + node.uuid + "/"; + const archiveTab = await openURL(archiveURL, options); + const tabTracked = isArchiveTabTracked(archiveTab.id); + + // configureArchiveTab depends on the tracked url + trackArchiveTab(archiveTab.id, objectURL); + + if (!tabTracked) + configureArchiveTab(node, archiveTab); + } + } + else + showNotification({message: "No data is stored."}); +} + +async function browseRDFArchive(node, options) { + const helper = await helperApp.hasVersion("2.0", HELPER_APP_v2_IS_REQUIRED); + + if (helper) { + const url = helperApp.url(`/rdf/browse/${node.uuid}/`); + return openURL(url, options); + } +} + +async function getBlobURL(archive) { + if (archive.data) { // legacy string content + let object = new Blob([await Archive.reify(archive)], + {type: archive.type? archive.type: "text/html"}); + return URL.createObjectURL(object); + } + else + return URL.createObjectURL(archive.object); +} + +async function browseArchiveHelper(node, options) { + if (node.__tentative) + return; + + const helper = await helperApp.hasVersion("2.0", HELPER_APP_v2_IS_REQUIRED); + + if (helper) { + let urlPrefix = ""; + if (node.external === RDF_EXTERNAL_TYPE) + urlPrefix = "/rdf"; + + const archiveURL = helperApp.url(`${urlPrefix}/browse/${node.uuid}/`); + const archiveTab = await openURL(archiveURL, options); + return configureArchiveTab(node, archiveTab); + } +} + +helperApp.addMessageHandler("REQUEST_ARCHIVE", onRequestArchiveMessage); + +export async function onRequestArchiveMessage(msg) { + const result = {type: "ARCHIVE_INFO", kind: "empty"}; + + try { + const node = await Node.getByUUID(msg.uuid); + + if (node) { + result.data_path = settings.data_folder_path() || null; + + if (node.external === CLOUD_EXTERNAL_TYPE) { + try { + const archive = await Archive.get(node); + + if (archive) { + const content = await Archive.reify(archive, true); + + result.kind = "content"; + result.name = node.name; + result.uuid = node.uuid; + result.content_type = node.content_type || "text/html"; + result.content = content; + result.contains = node.contains || null; + } + } catch (e) { + console.error(e); + } + } + else { + result.kind = "metadata"; + result.name = node.name; + result.content_type = node.content_type || "text/html"; + result.contains = node.contains || null; + } + } + } catch (e) { + console.error(e); + } + + return result; +} + +helperApp.addMessageHandler("REQUEST_RDF_PATH", onRequestRdfPathMessage); + +export async function onRequestRdfPathMessage(msg) { + try { + const node = await Node.getByUUID(msg.uuid); + + if (node) { + const path = await rdfShelf.getRDFArchiveDir(node); + + return { + type: "RDF_PATH", + uuid: node.uuid, + rdf_archive_path: path + }; + } + } catch (e) { + console.error(e); + } +} + +async function browseFolder(node, options) { + if (node.__filtering) + send.selectNode({node, open: true, forceScroll: true}); + else if (node.site) { + const archives = await listSiteArchives(node); + const page = archives[0]; + if (page) + return browseNode(page, options); + } +} + +async function listSiteArchives(node) { + const parentId = node.type === NODE_TYPE_ARCHIVE? node.parent_id: node.id; + const parent = await Node.get(parentId); + const pages = await Query.fullSubtree(parent.id, true); + return pages.filter(n => n.type === NODE_TYPE_ARCHIVE); +} + +async function browseFile(node) { + const helper = await helperApp.hasVersion("2.1", HELPER_APP_v2_1_IS_REQUIRED); + + if (helper) { + return helperApp.postJSON("/files/shell_open_asset", { + path: node.uri + }); + } +} + +export async function browseNodeBackground(node, options) { + switch (node.type) { + case NODE_TYPE_BOOKMARK: + return browseBookmark(node, options); + + case NODE_TYPE_ARCHIVE: + if (settings.storage_mode_internal()) + return browseArchiveIDB(node, options); + else + return browseArchiveHelper(node, options); + + case NODE_TYPE_NOTES: { + const edit = options?.edit? "?edit": ""; + return openURL(`ui/notes.html${edit}#` + node.uuid, options); + } + + case NODE_TYPE_FOLDER: + return browseFolder(node, options); + + case NODE_TYPE_FILE: + return browseFile(node); + } +} + +export async function browseNode(node) { + if (!_BACKGROUND_PAGE && settings.storage_mode_internal()) + return sendLocal.browseNode({node}); + else + return browseNodeBackground(node); +} + diff --git a/addon/cloud_client_base.js b/addon/cloud_client_base.js new file mode 100644 index 00000000..4ba6e0af --- /dev/null +++ b/addon/cloud_client_base.js @@ -0,0 +1,195 @@ +import {CloudStorage} from "./cloud_node_db.js"; + +const OBJECT_DIRECTORY = "objects"; +const NODE_OBJECT_FILE = "item.json"; +const ICON_OBJECT_FILE = "icon.json"; +const ARCHIVE_INDEX_OBJECT_FILE = "archive_index.json"; +const ARCHIVE_OBJECT_FILE = "archive.json"; +const ARCHIVE_CONTENT_FILE = "archive_content.blob"; +const NOTES_INDEX_OBJECT_FILE = "notes_index.json"; +const NOTES_OBJECT_FILE = "notes.json"; +const COMMENTS_INDEX_OBJECT_FILE = "comments_index.json"; +const COMMENTS_OBJECT_FILE = "comments.json"; + +export class CloudError { + constructor(message) { + this.message = message; + } +} + +export class CloudItemNotFoundError extends CloudError { +} + +export class CloudClientBase { + static CLOUD_SHELF_PATH = "/Cloud"; + static CLOUD_SHELF_INDEX = "cloud.jsbk"; + static REDIRECT_URL = "https://gchristensen.github.io/scrapyard/"; + + constructor() { + this._assetMethods = this._createAssetMethods(); + } + + get assets() { + return this._assetMethods; + } + + async authenticate() { + return new Promise(async (resolve, reject) => { + if (this.isAuthenticated()) + resolve(true); + else { + try { + let authTab = await browser.tabs.create({url: await this._getAuthorizationUrl()}); + + let listener = async (id, changed, tab) => { + if (id === authTab.id && changed.url?.startsWith(CloudClientBase.REDIRECT_URL)) { + await browser.tabs.onUpdated.removeListener(listener); + browser.tabs.remove(authTab.id); + + if (changed.url.includes("code=")) { + try { + await this._obtainRefreshToken(changed.url); + resolve(true); + } catch (e) { + console.error(e); + resolve(false); + } + } + else + resolve(false); + } + }; + + browser.tabs.onUpdated.addListener(listener); + } catch (e) { + console.error(e); + resolve(false); + } + } + }); + } + + _getObjectDirectory(uuid) { + return `${CloudClientBase.CLOUD_SHELF_PATH}/${OBJECT_DIRECTORY}/${uuid}`; + } + + _getAssetPath(uuid, asset) { + return `${CloudClientBase.CLOUD_SHELF_PATH}/${OBJECT_DIRECTORY}/${uuid}/${asset}`; + } + + _createAssetMethods() { + const storeAsset = asset => { + return async (uuid, data) => { + try { + const path = this._getAssetPath(uuid, asset); + await this.uploadFile(path, data); + } catch (e) { + console.error(e); + } + }; + } + + const storeFile = async (uuid, data, file) => { + try { + const path = this._getAssetPath(uuid, file); + await this.uploadFile(path, data); + } catch (e) { + console.error(e); + } + }; + + const fetchAsset = (asset, binary) => { + return async (uuid) => { + try { + const path = this._getAssetPath(uuid, asset); + return await this.downloadFile(path, binary); + } + catch (e) { + console.error(e); + } + }; + } + + const fetchFile = async (uuid, file) => { + try { + const path = this._getAssetPath(uuid, file); + return this.downloadFile(path, file); + } catch (e) { + console.error(e); + } + }; + + const fetchBinaryAsset = (asset, binary) => fetchAsset(asset, true); + + let methods = {}; + + methods.storeNode = storeAsset(NODE_OBJECT_FILE); + + methods.storeNotes = storeAsset(NOTES_OBJECT_FILE); + methods.fetchNotes = fetchAsset(NOTES_OBJECT_FILE); + methods.storeNotesIndex = storeAsset(NOTES_INDEX_OBJECT_FILE); + methods.fetchNotesIndex = fetchAsset(NOTES_INDEX_OBJECT_FILE); + + methods.storeArchiveObject = storeAsset(ARCHIVE_OBJECT_FILE); + methods.fetchArchiveObject = fetchAsset(ARCHIVE_OBJECT_FILE); + methods.storeArchiveContent = storeAsset(ARCHIVE_CONTENT_FILE); + methods.storeArchiveFile = storeFile; + methods.fetchArchiveContent = fetchBinaryAsset(ARCHIVE_CONTENT_FILE); + methods.fetchArchiveFile = fetchFile; + methods.storeArchiveIndex = storeAsset(ARCHIVE_INDEX_OBJECT_FILE); + methods.fetchArchiveIndex = fetchAsset(ARCHIVE_INDEX_OBJECT_FILE); + + methods.storeIcon = storeAsset(ICON_OBJECT_FILE); + methods.fetchIcon = fetchAsset(ICON_OBJECT_FILE); + + methods.storeComments = storeAsset(COMMENTS_OBJECT_FILE); + methods.fetchComments = fetchAsset(COMMENTS_OBJECT_FILE); + methods.storeCommentsIndex = storeAsset(COMMENTS_INDEX_OBJECT_FILE); + methods.fetchCommentsIndex = fetchAsset(COMMENTS_INDEX_OBJECT_FILE); + + return methods; + } + + async deleteAssets(uuids) { + for (const uuid of uuids) { + try { + const path = this._getObjectDirectory(uuid); + await this.deleteFile(path); + } catch (e) { + console.error(e); + } + } + } + + async downloadDB() { + let storage = null; + + try { + const path = `${CloudClientBase.CLOUD_SHELF_PATH}/${CloudClientBase.CLOUD_SHELF_INDEX}`; + const content = await this.downloadFile(path); + storage = CloudStorage.deserialize(content); + } + catch (e) { + if (e instanceof CloudItemNotFoundError) { + storage = new CloudStorage(); + } + else if (e instanceof CloudError) + throw e; + } + + if (storage) + Object.assign(storage, this._assetMethods); + + return storage; + } + + async persistDB(db) { + const path = `${CloudClientBase.CLOUD_SHELF_PATH}/${CloudClientBase.CLOUD_SHELF_INDEX}`; + const content = db.serialize(); + this.uploadFile(path, content); + } + + _replaceSpecialChars(filename) { + return filename.replace(/[\\\/:*?"<>|\[\]()^#%&!@:+={}'~]/g, "_"); + } +} diff --git a/addon/cloud_client_dropbox.js b/addon/cloud_client_dropbox.js new file mode 100644 index 00000000..a2fe15c7 --- /dev/null +++ b/addon/cloud_client_dropbox.js @@ -0,0 +1,133 @@ +import {send} from "./proxy.js"; +import {settings} from "./settings.js"; +import DropboxAuth from "./lib/dropbox/auth.js"; +import Dropbox from "./lib/dropbox/dropbox.js" +import {readBlob} from "./utils_io.js"; +import {CloudClientBase, CloudItemNotFoundError} from "./cloud_client_base.js"; + +const APP_KEY = "0y7co3j1k4oc7up"; + +export class DropboxClient extends CloudClientBase { + constructor() { + super() + this.ID = "dropbox"; + this.dbxAuth = new DropboxAuth({clientId: APP_KEY}); + this.dbx = new Dropbox({auth: this.dbxAuth}); + } + + initialize() { + let refreshToken = settings.dropbox_refresh_token(); + if (refreshToken) { + this.dbxAuth.setRefreshToken(refreshToken); + } + else { + browser.runtime.onMessage.addListener((request) => { + if (request.type === "dropboxAuthenticated") { + this.dbxAuth.setRefreshToken(request.refreshToken); + } + }); + } + } + + isAuthenticated() { + return !!settings.dropbox_refresh_token(); + } + + signOut() { + settings.dropbox_refresh_token(null); + } + + _getAuthorizationUrl() { + return this.dbxAuth.getAuthenticationUrl(CloudClientBase.REDIRECT_URL, undefined, + 'code', 'offline', undefined, undefined, true); + } + + async _obtainRefreshToken(url) { + const code = url.match(/.*code=(.*)$/i)[1]; + let response = await this.dbxAuth.getAccessTokenFromCode(CloudClientBase.REDIRECT_URL, code); + const refreshToken = response.result.refresh_token; + this.dbxAuth.setRefreshToken(refreshToken); + + await settings.dropbox_refresh_token(refreshToken); + send.dropboxAuthenticated({refreshToken}); + + if (settings.dropbox___dbat()) + settings.dropbox___dbat(null); + } + + async uploadFile(path, data) { + await this.dbx.filesUpload({ + path, + contents: data, + mode: "overwrite", + mute: true + }); + } + + async downloadFile(path, binary) { + let result = null; + + try { + const {result: {fileBlob}} = await this.dbx.filesDownload({path}); + result = readBlob(fileBlob, binary? "binary": null); + } + catch (e) { + if (e.status === 409) { // no index.js file + if (e.error.error_summary.startsWith("path/not_found")) + throw new CloudItemNotFoundError(); + } + else + console.error(e); + } + + return result; + } + + async deleteFile(path) { + await this.dbx.filesDeleteV2({path}); + } + + async share(path, filename, content) { + await this.authenticate(); + return this.dbx.filesUpload({ + path: path + this._replaceSpecialChars(filename), + mode: "add", + autorename: true, + mute: false, + strict_conflict: false, + contents: content + }); + }; + + async reset() { + try { + const {result: {entries}} = await this.dbx.filesListFolder({path: CloudClientBase.CLOUD_SHELF_PATH}); + + if (entries && entries.length) { + const files = {entries: entries.map(f => ({path: f.path_display}))}; + await this.dbx.filesDeleteBatch(files); + } + } + catch (e) { + console.error(e); + } + } + + async getLastModified() { + try { + const {result: meta} = await this.dbx.filesGetMetadata({ + path: `${CloudClientBase.CLOUD_SHELF_PATH}/${CloudClientBase.CLOUD_SHELF_INDEX}` + }); + + if (meta && meta.server_modified) + return new Date(meta.server_modified); + } + catch (e) { + console.error(e); + } + + return null; + } +} + +export let dropboxClient = new DropboxClient(); diff --git a/addon/cloud_client_onedrive.js b/addon/cloud_client_onedrive.js new file mode 100644 index 00000000..069f989c --- /dev/null +++ b/addon/cloud_client_onedrive.js @@ -0,0 +1,274 @@ +// TODO: has a known issue with multiple login attempts: state string from storage and url differ, currently unresolved +// to reproduce log out from OneDrive and try to log in again; some wait time (probably lifetime of a refresh token) helps +// to login successfully + +import {CloudClientBase, CloudError, CloudItemNotFoundError} from "./cloud_client_base.js"; +import {PKCE} from "./lib/PKCE.js"; +import {settings} from "./settings.js"; +import {send} from "./proxy.js"; + +const GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"; + +export class OneDriveClient extends CloudClientBase { + constructor() { + super() + this.ID = "onedrive" + this._pkce = new PKCE({ + client_id: "c4d0a237-f00c-41a4-ac9f-f7aa4d88e857", + redirect_uri: 'https://gchristensen.github.io/scrapyard', + authorization_endpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize', + token_endpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token', + requested_scopes: 'offline_access User.Read Files.ReadWrite Files.ReadWrite.AppFolder', + }); + } + + initialize() { + let refreshToken = settings.onedrive_refresh_token(); + if (refreshToken) { + this._refreshToken = refreshToken; + } + else { + browser.runtime.onMessage.addListener((request) => { + if (request.type === "onedriveAuthenticated") { + this._refreshToken = request.refreshToken; + } + }); + } + } + + isAuthenticated() { + return !!settings.onedrive_refresh_token(); + } + + signOut() { + settings.onedrive_refresh_token(null); + } + + _getAuthorizationUrl() { + return this._pkce.getAuthorizationUrl(); + } + + async _applyTokens(response) { + if (response.access_token) { + this._refreshToken = response.refresh_token; + this._accessToken = response.access_token; + this._accessTokenExpires = Date.now() + response.expires_in * 1000; + + await settings.onedrive_refresh_token(this._refreshToken); + } + else if (response.error?.code) { // MS Graph error on refresh token + this._refreshToken = null; + this._accessToken = null; + + await settings.onedrive_refresh_token(null); + } + } + + async _obtainRefreshToken(url) { + const response = await this._pkce.exchangeForAccessToken(url); + await this._applyTokens(response); + send.onedriveAuthenticated({refreshToken: this._refreshToken}); + } + + async _refreshAccessToken() { + const response = await this._pkce.refreshAccessToken(this._refreshToken); + await this._applyTokens(response); + } + + _injectToken(params) { + if (!this._accessToken) + throw new CloudError("OneDrive is not authorized."); + + params = params || {}; + params.headers = params.headers || {} + params.headers["Authorization"] = `Bearer ${this._accessToken}`; + return params; + } + + async _makeGraphRequest(path, params) { + params = this._injectToken(params); + const response = await fetch(`${GRAPH_API_ENDPOINT}/${path}`, params); + if (response.ok) + return response; + else + throw await response.json(); + } + + async _makeRequest(path, params) { + if (this._accessToken && this._accessTokenExpires - 5000 > Date.now()) { + try { + return this._makeGraphRequest(path, params); + } + catch (e) { + if (e.error?.code === "InvalidAuthenticationToken") { + await this._refreshAccessToken(); + return this._makeGraphRequest(path, params); + } + else + throw e; + } + } + else { + await this._refreshAccessToken(); + return this._makeGraphRequest(path, params); + } + } + + async _makeTextRequest(path, params) { + const response = await this._makeRequest(path, params); + return response.text(); + } + + async _makeBinaryRequest(path, params) { + const response = await this._makeRequest(path, params); + return response.arrayBuffer(); + } + + async _makeJSONRequest(path, params) { + const response = await this._makeRequest(path, params); + return response.json(); + } + + _getDrivePath(path) { + return `/me/drive/special/approot:${path}`; + } + + _uploadSmallFile(path, bytes) { + const headers = {"Content-Type": "text/plain"}; + return this._makeRequest(path + ":/content", {method: "put", body: bytes, headers}); + } + + async _uploadLargeFile(path, bytes, mode = "replace") { + const sessionParams = { + item: { + "@microsoft.graph.conflictBehavior": mode + } + }; + + const sessionPath = path + ":/createUploadSession"; + const session = await this._makeJSONRequest(sessionPath, { + method: "post", + body: JSON.stringify(sessionParams), + headers: {"Content-Type": "application/json"} + }); + + const CHUNK_SIZE = 60 * 320 * 1024; + let fullChunks = Math.floor(bytes.byteLength / CHUNK_SIZE); + + for (let i = 0; i < fullChunks; ++i) { + const start = i * CHUNK_SIZE; + const end = start + CHUNK_SIZE; + const chunk = bytes.slice(start, end); + + await this._sendSessionBytes(session.uploadUrl, chunk, start, end - 1, bytes.byteLength); + } + + const remained = bytes.byteLength - fullChunks * CHUNK_SIZE; + if (remained > 0) { + const start = fullChunks * CHUNK_SIZE; + const end = bytes.byteLength; + const chunk = bytes.slice(start, end); + + await this._sendSessionBytes(session.uploadUrl, chunk, start, end - 1, bytes.byteLength); + } + } + + async _sendSessionBytes(url, bytes, start, end, size) { + const headers = { + "Content-Length": `${bytes.byteLength}`, + "Content-Range": `bytes ${start}-${end}/${size}` + }; + return fetch(url, {method: "put", body: bytes, headers}); + } + + async uploadFile(path, data) { + const requestPath = this._getDrivePath(path); + let bytes = data; + + if (typeof data === "string") { + const encoder = new TextEncoder(); + bytes = encoder.encode(data); + } + + if (bytes.byteLength < 4 * 1024 * 1024) + return this._uploadSmallFile(requestPath, bytes) + else + return this._uploadLargeFile(requestPath, bytes); + } + + async downloadFile(path, binary) { + const requestPath = this._getDrivePath(path) + ":/content"; + try { + if (binary) + return await this._makeBinaryRequest(requestPath); + else + return await this._makeTextRequest(requestPath); + } + catch (e) { + if (e instanceof CloudError) + throw e; + else if (e.error?.code === "itemNotFound") + throw new CloudItemNotFoundError(); + + console.error(e) + } + } + + async deleteFile(path) { + const requestPath = this._getDrivePath(path) + return this._makeRequest(requestPath, {method: "delete"}); + } + + async share(path, filename, content) { + await this.authenticate(); + + let bytes = content; + if (content instanceof Blob) + bytes = await content.arrayBuffer(); + else if (typeof content === "string") { + const encoder = new TextEncoder(); + bytes = encoder.encode(content); + } + + if (path === "/") + path = ""; + + filename = this._replaceSpecialChars(filename); + const requestPath = this._getDrivePath(`${path}/${filename}`); + return this._uploadLargeFile(requestPath, bytes, "rename"); + }; + + async reset() { + try { + // TODO: add suport for @odata.nextLink + const requestPath = this._getDrivePath(CloudClientBase.CLOUD_SHELF_PATH) + ":/children"; + const driveItems = await this._makeJSONRequest(requestPath); + + for (let item of driveItems.value) { + const path = `${CloudClientBase.CLOUD_SHELF_PATH}/${item.name}`; + await this.deleteFile(path); + } + } + catch (e) { + console.error(e); + } + } + + async getLastModified() { + const requestPath = + this._getDrivePath(`${CloudClientBase.CLOUD_SHELF_PATH}/${CloudClientBase.CLOUD_SHELF_INDEX}`); + + try { + const driveItem = await this._makeJSONRequest(requestPath); + return new Date(driveItem.lastModifiedDateTime); + } + catch (e) { + console.error(e); + } + + return null; + } +} + + +export let oneDriveClient = new OneDriveClient(); diff --git a/addon/cloud_node_db.js b/addon/cloud_node_db.js new file mode 100644 index 00000000..ce979b48 --- /dev/null +++ b/addon/cloud_node_db.js @@ -0,0 +1,124 @@ +import { + CLOUD_SHELF_UUID, + NODE_TYPE_SHELF, + createJSONScrapBookMeta, + updateJSONScrapBookMeta, + JSON_SCRAPBOOK_FOLDERS +} from "./storage.js"; +import UUID from "./uuid.js"; + +export class CloudStorage { + constructor() { + this._objects = new Map(); + } + + static deserialize(jsonLines) { + const storage = new CloudStorage(); + + const lines = jsonLines.split("\n").filter(s => !!s); + storage._meta = lines.length? JSON.parse(lines.shift()): {}; + + if (lines.length > 0) { + for (const line of lines) { + const object = JSON.parse(line); + storage._objects.set(object.uuid, object); + } + } + + return storage; + } + + serialize() { + const hasMeta = !!this._meta; + this._meta = this._meta || createJSONScrapBookMeta("cloud"); + + if (!hasMeta) + this._meta.uuid = UUID.numeric(); + + const objects = this._treeSortObjects(); + + updateJSONScrapBookMeta(this._meta, objects.length); + + let lines = [JSON.stringify(this._meta)]; + lines = [...lines, ...objects.map(o => JSON.stringify(o))]; + return lines.join("\n"); + } + + _sanitized(object) { + object = {...object}; + + delete object.external; + delete object.external_id; + + return object; + } + + addNode(object) { + if (object.type !== NODE_TYPE_SHELF) + this._objects.set(object.uuid, this._sanitized(object)); + } + + getNode(uuid) { + return this._objects.get(uuid); + } + + updateNode(object) { + if (object.type !== NODE_TYPE_SHELF) { + const existing = this._objects.get(object.uuid); + + if (existing) { + const node = existing; + Object.assign(node, object); + this.addNode(node); + } + else + this.addNode(object); + } + } + + deleteNodes(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + for (let node of nodes) + this._objects.delete(node.uuid); + } + + get meta() { + return this._meta; + } + + get nodes() { + return Array.from(this._objects.values()); + } + + get sortedNodes() { + return this._treeSortObjects(); + } + + _treeSortObjects() { + const children = new Map(); + children.set(CLOUD_SHELF_UUID, []); + + for (const object of this._objects.values()) { + if (children.has(object.parent)) + children.get(object.parent).push(object.uuid); + else + children.set(object.parent, [object.uuid]); + } + + const getSubtree = (parentUUID, acc = []) => { + const childrenUUIDs = children.get(parentUUID); + + if (childrenUUIDs) + for (const uuid of childrenUUIDs) { + acc.push(this._objects.get(uuid)); + getSubtree(uuid, acc); + } + + return acc; + } + + return getSubtree(CLOUD_SHELF_UUID); + } +} diff --git a/addon/content_edit_frames.js b/addon/content_edit_frames.js new file mode 100644 index 00000000..4a5f4d13 --- /dev/null +++ b/addon/content_edit_frames.js @@ -0,0 +1,8 @@ + +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + switch (message.type) { + case "GET_FRAME_HTML": + configureSiteLinks(message.siteMap); + break; + } +}); diff --git a/addon/content_favicon.js b/addon/content_favicon.js new file mode 100644 index 00000000..d20c1fcb --- /dev/null +++ b/addon/content_favicon.js @@ -0,0 +1 @@ +document.querySelector("link[rel*='icon'], link[rel*='shortcut']")?.href diff --git a/addon/content_mark.js b/addon/content_mark.js new file mode 100644 index 00000000..b6d8cc7e --- /dev/null +++ b/addon/content_mark.js @@ -0,0 +1 @@ +console.log(location.href) diff --git a/addon/content_selection.js b/addon/content_selection.js new file mode 100644 index 00000000..97b6b4a2 --- /dev/null +++ b/addon/content_selection.js @@ -0,0 +1,115 @@ + +function captureSelection(options) { + const EXTRACTION_ID_ATTR = "scrapyard-selection-extraction-id"; + + let sel = window.getSelection(); + let root = null; + let id = 1; + + function retainStructure(parent, content) { + let parents = []; + + if (parent.nodeType === 3) + parent = parent.parentNode; + + // mark all encountered parents including + while (parent && parent.localName !== "html") { + let parentId = parent.getAttribute(EXTRACTION_ID_ATTR); + if (!parentId) + parent.setAttribute(EXTRACTION_ID_ATTR, id++); + + parents.push(parent.cloneNode(false)); + parent = parent.parentElement; + } + + parents.reverse(); + parents.shift(); // drop ; + + if (parents.length) { + let next = parents.shift(); + + // traverse parents, drop ones that already attached to root + while (parents.length + && root.querySelector(`*[${EXTRACTION_ID_ATTR}='${parents[0].getAttribute(EXTRACTION_ID_ATTR)}']`)) { + next = parents.shift(); + } + + let existing = root.querySelector(`*[${EXTRACTION_ID_ATTR}='${next.getAttribute(EXTRACTION_ID_ATTR)}']`); + + if (!existing) + root.appendChild(next) + else + next = existing; + + // append all unseen nodes + for (let parent of parents) { + next.appendChild(parent); + next = parent; + } + + next.appendChild(content); + } + else + root.appendChild(content); + } + + + if ((!sel || sel.isCollapsed) && (options._selector || options._filter)) { + root = document.createElement("div") + + let parts = options._selector + ? Array.prototype.slice.call(document.querySelectorAll(options._selector)) + : Array.prototype.slice.call(document.body.childNodes); + + for (let part of parts) { + retainStructure(part.parentNode, part.cloneNode(true)); + } + + if (options._filter) { + let filtered = root.querySelectorAll(options._filter); + + filtered.forEach(n => { + n.parentNode.removeChild(n); + }) + } + } + else if (sel && !sel.isCollapsed) { + root = document.createElement("div") + + for (let i = 0; i < sel.rangeCount; ++i) { + let range = sel.getRangeAt(i); + + if (range.isCollapsed) + continue; + + retainStructure(range.commonAncestorContainer, range.cloneContents()); + } + } + + document.querySelectorAll(`*[${EXTRACTION_ID_ATTR}]`) + .forEach(e => e.removeAttribute(EXTRACTION_ID_ATTR)); + + let html; + + if (root) { + root.querySelectorAll(`*[${EXTRACTION_ID_ATTR}]`).forEach(e => e.removeAttribute(EXTRACTION_ID_ATTR)); + + html = root.innerHTML; + + if (options._style) { + let style = options._style.replace(//g, '>') + html = `` + html; + } + } + + return html; +} + +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + switch (message.type) { + case "CAPTURE_SELECTION": + sendResponse(captureSelection(message.options)); + break; + } +}); diff --git a/addon/content_site.js b/addon/content_site.js new file mode 100644 index 00000000..3a55be8d --- /dev/null +++ b/addon/content_site.js @@ -0,0 +1,75 @@ +var configured; + +function makeReferenceURL(uuid, useProtocol) { + let referenceURL = `ext+scrapyard://${uuid}`; + + if (!useProtocol) + referenceURL = browser.runtime.getURL(`/reference.html#${referenceURL}`); + + return referenceURL; +} + +function parseHtml(htmlText) { + let doc = document.implementation.createHTMLDocument("") + , doc_elt = doc.documentElement + , first_elt; + + doc_elt.innerHTML = htmlText; + first_elt = doc_elt.firstElementChild; + + if (doc_elt.childElementCount === 1 + && first_elt.localName.toLowerCase() === "html") { + doc.replaceChild(first_elt, doc_elt); + } + + return doc; +} + +function installScrapyardURLs(doc, siteMap, blank, useProtocol) { + doc.querySelectorAll("a").forEach( + function (element) { + const url = element.getAttribute("data-scrapyard-href"); + const uuid = siteMap[url]; + if (uuid) { + element.href = makeReferenceURL(uuid, useProtocol); + if (blank) + element.setAttribute("target", "_blank"); + } + }); +} + +function processIFrames(doc, siteMap, useProtocol) { + doc.querySelectorAll("iframe").forEach( + function (element) { + const html = element.srcdoc; + + if (html) { + const srcdoc = parseHtml(html); + processIFrames(srcdoc, siteMap, useProtocol); + installScrapyardURLs(srcdoc, siteMap, true, useProtocol); + element.srcdoc = "" + srcdoc.documentElement.outerHTML; + } + else if (element.contentWindow?.document) { + processIFrames(element.contentWindow.document, siteMap, useProtocol); + installScrapyardURLs(element.contentWindow.document, siteMap, true, useProtocol); + } + }); +} + +function configureSiteLinks(siteMap, useProtocol) { + installScrapyardURLs(document, siteMap, false, useProtocol); + processIFrames(document, siteMap, useProtocol); +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + switch (message.type) { + case "CONFIGURE_SITE_LINKS": + if (!configured) { + configured = true; + configureSiteLinks(message.siteMap, message.useProtocol); + } + break; + } +}); + +console.log("content_site.js loaded") diff --git a/addon/core.js b/addon/core.js new file mode 100644 index 00000000..ac78552e --- /dev/null +++ b/addon/core.js @@ -0,0 +1,99 @@ +import {grantPersistenceQuota, startupLatch} from "./utils_browser.js"; +import {receive, receiveExternal, sendLocal} from "./proxy.js"; +import {systemInitialization} from "./bookmarks_init.js"; +import {browserShelf} from "./plugin_browser_shelf.js"; +import {cloudShelf} from "./plugin_cloud_shelf.js"; +import {addBookmarkOnCommand, setBookmarkedActionIcon} from "./bookmarking.js"; +import {toggleSidebarWindow} from "./utils_sidebar.js"; +import {undoManager} from "./bookmarks_undo.js"; +import {helperApp} from "./helper_app.js"; +import {settings} from "./settings.js"; +import * as search from "./search.js"; +import "./core_bookmarking.js"; +import "./core_share.js"; +import "./core_backup.js" +import "./core_backends.js"; +import "./core_maintenance.js"; +import "./core_ishell.js"; +import "./core_automation.js"; +import "./core_sync.js"; +import "./core_transition.js"; +import {filesShelf} from "./plugin_files_shelf.js"; + +if (_BACKGROUND_PAGE) + import("./core_import.js"); + +if (_BACKGROUND_PAGE && _MANIFEST_V3) + import("./mv3_persistent.js"); + +receiveExternal.startListener(true); +receive.startListener(true); + +(async () => { + if (await grantPersistenceQuota()) { + await systemInitialization; + + await startupLatch(performStartupInitialization); + } +})(); + +async function performStartupInitialization() { + search.initializeOmnibox(); + + await browserShelf.reconcileBrowserBookmarksDB(); + + if (settings.enable_files_shelf()) { + await filesShelf.createIfMissing(); + } + + if (settings.cloud_enabled()) { + await cloudShelf.createIfMissing(); + await cloudShelf.enableBackgroundSync(settings.cloud_background_sync()); + } + + if (!settings.storage_mode_internal()) + await helperApp.probe(); + + await undoManager.commit(); + + if (!settings.storage_mode_internal() && settings.synchronize_storage_at_startup()) + await sendLocal.performSync(); + + console.log("==> core.js initialized"); +} + +if (browser.webRequest) { + // remove the Origin header from add-on fetch requests + function originWithId(header) { + return header.name.toLowerCase() === 'origin' && header.value.startsWith('moz-extension://'); + } + + browser.webRequest.onBeforeSendHeaders.addListener( + (details) => { + return { + requestHeaders: details.requestHeaders.filter(x => !originWithId(x)) + } + }, + {urls: [""]}, + ["blocking", "requestHeaders"] + ); +} + +browser.commands.onCommand.addListener(function(command) { + if (!_SIDEBAR && command === "toggle_sidebar_window") + toggleSidebarWindow(); + else + addBookmarkOnCommand(command); +}); + +browser.tabs.onActivated.addListener(async activeInfo => { + const tab = await browser.tabs.get(activeInfo.tabId); + return setBookmarkedActionIcon(tab.url); +}); + +browser.tabs.onUpdated.addListener(async (tabId, changed, tab) => { + if (tab.status === "complete") + return setBookmarkedActionIcon(tab.url); +}); + +console.log("==> core.js loaded"); diff --git a/addon/core_automation.js b/addon/core_automation.js new file mode 100644 index 00000000..45504984 --- /dev/null +++ b/addon/core_automation.js @@ -0,0 +1,602 @@ +import {helperApp} from "./helper_app.js"; +import UUID from "./uuid.js"; +import { + isContainerNode, isBuiltInShelf, byPosition, + TODO_STATE_NAMES, TODO_STATES, + CLOUD_SHELF_NAME, CLOUD_SHELF_UUID, + DEFAULT_SHELF_NAME, DEFAULT_SHELF_UUID, + BROWSER_SHELF_NAME, BROWSER_SHELF_UUID, + NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK, NODE_TYPE_NOTES, NODE_TYPE_NAMES, ARCHIVE_TYPE_TEXT +} from "./storage.js"; +import {settings} from "./settings.js"; +import {getFaviconFromContent, getFaviconFromTab} from "./favicon.js"; +import {send, receiveExternal, sendLocal} from "./proxy.js"; +import {getActiveTab} from "./utils_browser.js"; +import {getMimetypeByExt} from "./utils.js"; +import {fetchText} from "./utils_io.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {captureTab, isSpecialPage, notifySpecialPage, packUrlExt} from "./bookmarking.js"; +import {Query} from "./storage_query.js"; +import {Path} from "./path.js"; +import {Folder} from "./bookmarks_folder.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Archive, Comments, Icon, Node, Notes} from "./storage_entities.js"; +import {browseNode} from "./browse.js"; +import {DiskStorage} from "./storage_external.js"; +import {notes2html} from "./notes_render.js"; + +export function isAutomationAllowed(sender) { + const extension_whitelist = settings.extension_whitelist(); + + return ishellConnector.isIShell(sender.id) + || (settings.enable_automation() && (!extension_whitelist + || extension_whitelist.some(id => id.toLowerCase() === sender.id.toLowerCase()))); +} + +const ALLOWED_API_FIELDS = ["type", "uuid", "title", "url", "icon", "path", "tags", "details", "todo_state", "todo_date", + "comments", "container", "contains", "content", "content_type", "pack", "local", "select", "refresh", + "hide_tab"]; + +function sanitizeIncomingObject(object) { + for (let key of Object.keys(object)) + if (!ALLOWED_API_FIELDS.some(k => k === key)) + delete object[key]; + + return object; +} + +receiveExternal.scrapyardGetVersion = (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + sendLocal.scrapyardIdRequested({senderId: sender.id}); + return browser.runtime.getManifest().version; +}; + +export async function createBookmarkNode(message, sender, activeTab) { + const node = {...message}; + + sanitizeIncomingObject(node); + + node.__automation = true; + + if (node.type === NODE_TYPE_NOTES || node.type === NODE_TYPE_ARCHIVE && node.url === "") + node.uri = undefined; + else if (!node.uri) + node.uri = node.url || activeTab.url; + + if (node.type !== NODE_TYPE_NOTES && (node.uri === null || node.uri === undefined || isSpecialPage(node.uri))) { + notifySpecialPage(); + return null; + } + + if (!node.name) + node.name = node.title || activeTab.title; + + if (node.icon === "" || node.pack) + node.icon = undefined; + else if (!node.icon && !node.local) + node.icon = await getFaviconFromTab(activeTab); + + if (node.todo_state) + node.todo_state = TODO_STATES[node.todo_state]; + + const path = Path.expand(node.path); + const folder = await Folder.getOrCreateByPath(path); + node.parent_id = folder.id; + delete node.path; + + return node; +} + +receiveExternal.scrapyardAddBookmark = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + message.type = NODE_TYPE_BOOKMARK; + + const node = await createBookmarkNode(message, sender, await getActiveTab()); + if (!node) + return; + + if (node.icon === true || node.title === true) { + try { + const content = await fetchText(node.uri); + + if (node.icon === true) + node.icon = await getFaviconFromContent(node.uri, content); + + if (node.title === true) { + const title = content.match(/]*>([^<]*) { + if (node.comments) + await Bookmark.storeComments(bookmark.id, node.comments); + + if (node.select) + send.bookmarkCreated({node: bookmark}); + + return bookmark.uuid; + }); +}; + +receiveExternal.scrapyardAddArchive = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + let activeTab = await getActiveTab(); + + message.type = NODE_TYPE_ARCHIVE; + + const node = await createBookmarkNode(message, sender, activeTab); + if (!node) + return; + + if (!node.name || node.name === true) + node.name = "Unnamed"; + + if (!node.content_type) + node.content_type = getMimetypeByExt(node.uri); + + let saveContent = (bookmark, content) => { + const contentType = node.pack? "text/html": node.content_type; + + return Bookmark.storeArchive(bookmark, content, contentType, bookmark.__index) + .then(() => { + if (node.select) + send.bookmarkCreated({node: bookmark}); + + return bookmark.uuid; + }); + }; + + return Bookmark.idb.add(node, NODE_TYPE_ARCHIVE) // added to storage in Archive.add + .then(async bookmark => { + + if (node.comments) + await Bookmark.storeComments(bookmark.id, node.comments); + + if (node.local) { + let content = await downloadLocalContent(bookmark); + + bookmark.uri = ""; + //await Bookmark.update(bookmark); + + return saveContent(bookmark, content); + } + else if (node.pack) { + const page = await packUrlExt(node.url, node.hide_tab); + + if (page.icon) { + bookmark.icon = page.icon + await Bookmark.storeIcon(bookmark); + } + + bookmark.name = page.title; + + //await Bookmark.update(bookmark); + + return saveContent(bookmark, page.html); + } + else if (node.content) { + return saveContent(bookmark, node.content) + } + else { + Object.assign(bookmark, node); + bookmark.__tab_id = activeTab.id; + captureTab(activeTab, bookmark); + + return bookmark.uuid; + } + }); +}; + +async function downloadLocalContent(node) { + const localURI = await setUpLocalFileCapture(node); + + let content; + if (node.content_type === "text/html") { + const page = await packUrlExt(localURI, node.hide_tab); + + if (page.icon && (node.icon === null || node.icon === undefined)) { + node.icon = page.icon + await Bookmark.storeIcon(node); + } + + if (page.title) + node.name = page.title; + + content = page.html; + } + else { + const response = await fetch(localURI); + if (response.ok) { + node.content_type = response.headers.get("content-type") || node.content_type; + content = await response.arrayBuffer(); + } + } + + await cleanUpLocalFileCapture(node); + return content; +} + +async function setUpLocalFileCapture(message) { + if (message.uri?.startsWith("http")) + throw new Error("HTTP URL is processed as a local path."); + + let local_uri; + if (await helperApp.probe()) { + message.uri = message.uri.replace(/^file:\/+/i, ""); + + message.__local_uuid = UUID.numeric(); + await helperApp.post(`/serve/set_path/${message.__local_uuid}`, {path: message.uri}); + local_uri = helperApp.url(`/serve/file/${message.__local_uuid}/`); + message.uri = ""; + return local_uri; + } + else { + throw new Error("Can not connect to the backend application."); + } +} + +async function cleanUpLocalFileCapture(message) { + if (message.local) + await helperApp.fetch(`/serve/release_path/${message.__local_uuid}`); +} + +receiveExternal.scrapyardAddNotes = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + message.type = NODE_TYPE_NOTES; + message.icon = ""; + + const node = await createBookmarkNode(message, sender, {}); + if (!node) + return; + + return Bookmark.add(node, NODE_TYPE_NOTES) + .then(async bookmark => { + const options = { + node_id: bookmark.id, + content: message.content || "", + format: message.format || "text" + }; + + options.html = notes2html(options); + + await Bookmark.storeNotes(options); + + if (node.comments) + await Bookmark.storeComments(bookmark.id, node.comments); + + if (node.select) + send.bookmarkCreated({node: bookmark}); + + return bookmark.uuid; + }); +}; + +receiveExternal.scrapyardAddSeparator = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + if (message.path) { + const path = Path.expand(message.path); + const folder = await Folder.getOrCreateByPath(path); + + const node = await Bookmark.addSeparator(folder.id); + + if (message.select) + send.bookmarkCreated({node}); + } +} + +async function nodeToAPIObject(node) { + const comments = + node.has_comments + ? await Comments.get(node) + : undefined; + + const icon = + node.stored_icon + ? await Icon.get(node) + : node.icon; + + const uuid = node.uuid; + + const options = { + type: NODE_TYPE_NAMES[node.type], + uuid: uuid, + title: node.name || "" + }; + + if (node.uri) + options.url = node.uri; + + if (icon) + options.icon = icon; + + if (node.tags) + options.tags = node.tags; + + if (node.details) + options.details = node.details; + + if (node.todo_state) + options.todo_state = TODO_STATE_NAMES[node.todo_state]; + + if (node.todo_date) + options.todo_date = node.todo_date; + + if (comments) + options.comments = comments; + + if (node.container) + options.container = node.container; + + if (node.contains) + options.contains = node.contains; + + options.path = await Path.asString(node); + + return options; +} + +receiveExternal.scrapyardGetUuid = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + const node = await Node.getByUUID(message.uuid); + + if (node) + return nodeToAPIObject(node); +}; + +receiveExternal.scrapyardGetUuidContent = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + const node = await Node.getByUUID(message.uuid); + let result = {}; + + if (node) { + if (node.type === NODE_TYPE_ARCHIVE) { + const archive = await Archive.get(node); + + if (archive) { + result.content = archive.object; + result.contains = node.contains || ARCHIVE_TYPE_TEXT; + result.content_type = node.content_type; + } + } + else if (node.type === NODE_TYPE_NOTES) { + const notes = await Notes.get(node); + + if (notes) { + result.content = notes.content; + result.format = notes.format; + } + } + } + + if (result.content) + return result; +}; + + +receiveExternal.scrapyardListUuid = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + let entries; + let container; + if (message.uuid === null) { + entries = await Query.allShelves(); + container = true; + } + else { + const API_UUID_TO_DB = { + [CLOUD_SHELF_NAME]: CLOUD_SHELF_UUID, + [BROWSER_SHELF_NAME]: BROWSER_SHELF_UUID, + [DEFAULT_SHELF_NAME]: DEFAULT_SHELF_UUID, + }; + + const uuid = + isBuiltInShelf(message.uuid) + ? API_UUID_TO_DB[message.uuid] + : message.uuid; + + const node = await Node.getByUUID(uuid); + container = node && isContainer(node); + if (container) + entries = await Node.getChildren(node.id); + else + entries = []; + } + + entries.sort(byPosition); + + let result = []; + + for (let entry of entries) { + result.push(await nodeToAPIObject(entry)); + } + + return container? result: undefined; +}; + +receiveExternal.scrapyardListPath = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + let entries; + let container; + + if (message.path === "/") { + entries = await Query.allShelves(); + container = true; + } + else { + const path = Path.expand(message.path); + const node = await Folder.getByPath(path); + + container = !!node; + + if (container) + entries = await Node.getChildren(node.id); + else + entries = []; + } + + entries.sort(byPosition); + + let result = []; + + for (let entry of entries) { + result.push(await nodeToAPIObject(entry)); + } + + return container? result: undefined; +}; + +receiveExternal.scrapyardGetSelection = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + let result = []; + + try { + const selectedNodes = await send.getTreeSelection(); + + for (let entry of selectedNodes) { + result.push(await nodeToAPIObject(entry)); + } + } catch (e) { + console.error(e); + } + + return result; +}; + +receiveExternal.scrapyardUpdateUuid = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + if (isBuiltInShelf(message.uuid)) + throw new Error("Can not modify built-in shelves."); + + const refresh = message.refresh; + + delete message.type; + sanitizeIncomingObject(message); + + if (message.url) { + message.uri = message.url; + delete message.url; + } + + message.name = message.title || ""; + delete message.title; + + if (message.todo_state) { + if (typeof message.todo_state === "number" + && (message.todo_state < TODO_STATE_TODO + || message.todo_state > TODO_STATE_CANCELLED)) { + message.todo_state = undefined; + } + else if (typeof message.todo_state === "string") + message.todo_state = TODO_STATES[message.todo_state.toUpperCase()]; + } + + const node = await Node.getByUUID(message.uuid); + + Object.assign(node, message); + + if (message.icon === "") { + message.icon = undefined; + message.stored_icon = undefined; + } + else if (message.icon) + await Bookmark.storeIcon(node); + + if (message.hasOwnProperty("comments")) { + await Bookmark.storeComments(node.id, message.comments); + delete node.comments; + } + + await Bookmark.update(node); + + if (refresh) + send.nodesUpdated(); +}; + +receiveExternal.scrapyardRemoveUuid = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + const node = await Node.getByUUID(message.uuid); + + if (node) + await Bookmark.delete(node.id); + + if (message.refresh) + send.nodesUpdated(); +}; + +receiveExternal.scrapyardPackPage = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + if (message.local) { + if (!message.uri) + message.uri = message.url; + + let local_uri = await setUpLocalFileCapture(message); + + let result = await packUrlExt(local_uri, message.hide_tab); + + await cleanUpLocalFileCapture(message); + + return result; + } + else + return packUrlExt(message.url, message.hide_tab); +}; + +receiveExternal.scrapyardBrowseUuid = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + const node = await Node.getByUUID(message.uuid); + if (node) + browseNode(node); +}; + +receiveExternal.scrapyardOpenBatchSession = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + return DiskStorage.openBatchSession(); +}; + +receiveExternal.scrapyardCloseBatchSession = async (message, sender) => { + if (!isAutomationAllowed(sender)) + throw new Error(); + + return DiskStorage.closeBatchSession(); +}; diff --git a/addon/core_backends.js b/addon/core_backends.js new file mode 100644 index 00000000..41d54dc5 --- /dev/null +++ b/addon/core_backends.js @@ -0,0 +1,70 @@ +import {browserShelf} from "./plugin_browser_shelf.js"; +import {settings} from "./settings.js"; +import {cloudShelf} from "./plugin_cloud_shelf.js"; +import {helperApp} from "./helper_app.js"; +import {receive} from "./proxy.js"; +import {filesShelf} from "./plugin_files_shelf.js"; + +receive.uiLockGet = message => { + browserShelf.getUILock(); +}; + +receive.uiLockRelease = message => { + browserShelf.releaseUILock(); +}; + +receive.memorizeUIBookmarks = message => { + browserShelf.lockUIBookmarks(message.bookmarks, message.category); +} + +receive.getListenerLockState = message => { + return browserShelf.isLockedByListeners(); +}; + +receive.reconcileBrowserBookmarkDb = async message => { + await settings.load() + browserShelf.reconcileBrowserBookmarksDB(); +}; + +receive.reconcileCloudBookmarkDb = async message => { + await settings.load(); + cloudShelf.reconcileCloudBookmarksDB(message.verbose); +}; + +receive.enableCloudBackgroundSync = async message => { + cloudShelf.enableBackgroundSync(message.enable); +}; + +receive.addFilesDirectory = async message => { + return filesShelf.addDirectory(message.options); +}; + +receive.reconcileExternalFiles = async message => { + return filesShelf.reconcileExternalFiles(); +}; + +receive.openWithEditor = async message => { + return filesShelf.openWithEditor(message.node); +}; + +receive.browseOrgWikiReference = message => { + return filesShelf.openExternalLink(message.link, message.node); +}; + +receive.helperAppProbe = message => { + return helperApp.probe(message.verbose); +}; + +receive.helperAppGetVersion = async message => { + await helperApp.probe(); + return helperApp.getVersion(); +}; + +receive.helperAppHasVersion = async message => { + return helperApp.hasVersion(message.version, message.alert); +}; + +receive.helperAppGetBackgroundAuth = message => { + return helperApp.auth; +}; + diff --git a/addon/core_backup.js b/addon/core_backup.js new file mode 100644 index 00000000..b80e13cc --- /dev/null +++ b/addon/core_backup.js @@ -0,0 +1,143 @@ +import {send} from "./proxy.js"; +import {helperApp} from "./helper_app.js"; +import {isVirtualShelf} from "./storage.js"; +import {receive} from "./proxy.js" +import UUID from "./uuid.js"; +import {sleep} from "./utils.js"; +import {Export, Import} from "./import.js"; +import {Query} from "./storage_query.js"; +import {LineStream} from "./utils_io.js"; + +receive.listBackups = message => { + let form = new FormData(); + form.append("directory", message.directory); + + return helperApp.fetchJSON(`/backup/list`, {method: "POST", body: form}); +}; + +receive.backupShelf = async message => { + let shelf, shelfName, shelfUUID; + + if (isVirtualShelf(message.shelf)) + shelf = shelfUUID = shelfName = message.shelf; + else { + shelf = await Query.shelf(message.shelf); + shelfUUID = shelf.uuid; + shelfName = shelf.name; + } + + let nodes = await Export.nodes(shelf); + + let backupFile = `${UUID.date()}_${shelfUUID}.jsonl` + + const process = helperApp.post("/backup/initialize", { + directory: message.directory, + file: backupFile, + compress: message.compress, + method: message.method, + level: message.level + }); + + const port = await helperApp.getPort(); + + const file = { + append: async function (text) { + port.postMessage({ + type: "BACKUP_PUSH_TEXT", + text: text + }) + } + }; + + await sleep(50); + + try { + const exporter = Export.create("json") + .setName(shelfName) + .setUUID(shelfUUID) + .setComment(message.comment) + .setReportProgress(true) + .setMuteSidebar(true) + .setObjects(nodes) + .setStream(file) + .build(); + + await exporter.export(); + } + finally { + port.postMessage({ + type: "BACKUP_FINISH" + }); + } + + await process; +}; + +receive.restoreShelf = async message => { + send.startProcessingIndication({noWait: true}); + + let error; + let shelf; + + try { + await helperApp.post("/restore/initialize", { + directory: message.directory, + file: message.meta.file + }); + + const Reader = class { + async* lines() { + while (true) { + const response = await helperApp.fetch("/restore/get_line"); + if (response.ok) { + const line = await response.text(); + if (line) + yield line; + else + break; + } + else + throw new Error("unknown error"); + } + } + }; + + const shelfName = message.new_shelf? message.meta.alt_name: message.meta.name; + const importer = Import.create("json") + .setName(shelfName) + .setReportProgress(true) + .setMuteSidebar(true) + .setStream(new LineStream(new Reader())) + .build(); + + shelf = await Import.transaction(importer); + } catch (e) { + console.log(e.stack); + error = e; + } + finally { + await helperApp.fetch("/restore/finalize"); + send.stopProcessingIndication(); + send.nodesImported({shelf}); + } + + if (error) + throw error; +}; + +receive.deleteBackup = async message => { + send.startProcessingIndication({noWait: true}); + + try { + await helperApp.post("/backup/delete", { + directory: message.directory, + file: message.meta.file + }); + } catch (e) { + console.error(e); + return false; + } + + send.stopProcessingIndication(); + return true; +} diff --git a/addon/core_bookmarking.js b/addon/core_bookmarking.js new file mode 100644 index 00000000..6ffb6b84 --- /dev/null +++ b/addon/core_bookmarking.js @@ -0,0 +1,439 @@ +import {formatBytes, getMimetypeByExt} from "./utils.js"; +import {receive, send, sendLocal} from "./proxy.js"; +import { + BROWSER_EXTERNAL_TYPE, + CLOUD_SHELF_ID, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, + NODE_TYPE_NOTES, + UNDO_DELETE +} from "./storage.js"; +import {askCSRPermission, getActiveTab, gettingStarted, showNotification, updateTabURL} from "./utils_browser.js"; +import {HELPER_APP_v2_IS_REQUIRED, helperApp} from "./helper_app.js"; +import {settings} from "./settings.js"; +import { + captureTab, + finalizeCapture, + isSpecialPage, + notifySpecialPage, + packUrlExt, + showSiteCaptureOptions, + performSiteCapture, + startCrawling, + abortCrawling, + archiveBookmark, addToBookmarksToolbar +} from "./bookmarking.js"; +import {fetchText} from "./utils_io.js"; +import {TODO} from "./bookmarks_todo.js"; +import {Folder} from "./bookmarks_folder.js"; +import {Shelf} from "./bookmarks_shelf.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Archive, Node} from "./storage_entities.js"; +import {undoManager} from "./bookmarks_undo.js"; +import {browseNodeBackground} from "./browse.js"; +import UUID from "./uuid.js"; +import {ensureSidebarWindow} from "./utils_sidebar.js"; +import {getFaviconFromContent, getFaviconFromTab} from "./favicon.js"; + +async function canUseBookmarking() { + return settings.storage_mode_internal() || await helperApp.hasVersion("2.0", HELPER_APP_v2_IS_REQUIRED); +} + +receive.createShelf = message => Shelf.add(message.name); + +receive.createFolder = message => Folder.add(message.parent, message.name); + +receive.renameFolder = message => Folder.rename(message.id, message.name); + +receive.addSeparator = message => Bookmark.addSeparator(message.parent_id); + +receive.createBookmark = async message => { + if (!settings.storage_mode_internal() && !settings.data_folder_path()) + return gettingStarted(); + + if (!await canUseBookmarking()) + return; + + const node = message.node; + + if (isSpecialPage(node.uri)) + return notifySpecialPage(); + + async function addBookmark() { + try { + const bookmark = await Bookmark.add(node, NODE_TYPE_BOOKMARK); + + if (settings.add_to_bookmarks_toolbar()) + await addToBookmarksToolbar(bookmark); + + send.bookmarkAdded({node: bookmark}); + return bookmark; + } + catch (e) { + showNotification(e.message); + send.bookmarkCreationFailed({node}); + } + } + + Bookmark.setTentativeId(node); + node.type = NODE_TYPE_BOOKMARK; // needed for beforeBookmarkAdded + return send.beforeBookmarkAdded({node}) + .then(addBookmark) + .catch(addBookmark); +}; + +receive.updateBookmark = message => Bookmark.update(message.node); + +receive.createArchive = async message => { + if (!settings.storage_mode_internal() && !settings.data_folder_path()) + return gettingStarted(); + + if (!await canUseBookmarking()) + return; + + const node = message.node; + const tab = message.tab || await getActiveTab(); + + if (isSpecialPage(node.uri)) + return notifySpecialPage(); + + async function addBookmark() { + try { + const bookmark = await Bookmark.idb.add(node, NODE_TYPE_ARCHIVE); // added to the storage on archive content update + + if (settings.add_to_bookmarks_toolbar()) + await addToBookmarksToolbar(bookmark); + + bookmark.__tab_id = tab.id; + captureTab(tab, bookmark); // !sic + return bookmark; + } + catch (e) { + showNotification(e.message); + send.bookmarkCreationFailed({node}); + } + } + + if (node.__crawl && !node.__site_capture) { + const tab = await getActiveTab(); + showSiteCaptureOptions(tab, node); + return; + } + + Bookmark.setTentativeId(node); + node.type = NODE_TYPE_ARCHIVE; // needed for beforeBookmarkAdded + return send.beforeBookmarkAdded({node: node}) + .then(addBookmark) + .catch(addBookmark); +}; + +receive.archiveBookmarks = async message => { + if (!await canUseBookmarking()) + return; + + send.startProcessingIndication(); + + try { + for (const node of message.nodes) { + if (node.type === NODE_TYPE_BOOKMARK) + await archiveBookmark(node); + } + } + finally { + send.nodesUpdated(); + send.stopProcessingIndication(); + } +}; + +receive.updateArchive = message => Bookmark.updateArchive(message.uuid, message.data); + +receive.createNotes = async message => { + if (!settings.storage_mode_internal() && !settings.data_folder_path()) + return gettingStarted(); + + if (!await canUseBookmarking()) + return; + + const node = message.node; + + async function addNotes() { + try { + const bookmark = await Bookmark.addNotes(node.parent_id, node.name); + + if (settings.add_to_bookmarks_toolbar()) + await addToBookmarksToolbar(bookmark); + + send.bookmarkAdded({node: bookmark}); + return bookmark; + } + catch (e) { + showNotification(e.message); + send.bookmarkCreationFailed({node}); + } + } + + Bookmark.setTentativeId(node); + node.type = NODE_TYPE_NOTES; // needed for beforeBookmarkAdded + + return send.beforeBookmarkAdded({node}) + .then(addNotes) + .catch(addNotes); +}; + +receive.captureHighlightedTabs = async message => { + const options = message.options; + const highlightedTabs = await browser.tabs.query({highlighted: true, currentWindow: true}); + let parentNode; + + if (options.type === NODE_TYPE_ARCHIVE) { + parentNode = await Node.get(options.parent_id); + + if (parentNode.external === BROWSER_EXTERNAL_TYPE) { + showNotification({title: "Warning", message: "Only bookmarks are saved to the browser bookmarks folder."}); + } + } + + if (highlightedTabs.length === 1) + return captureBookmarkForTab(highlightedTabs[0], message.options, parentNode); + else + for (const tab of highlightedTabs) { + const node = {...message.options}; + + node.uri = tab.url; + node.name = tab.title; + node.icon = await getFaviconFromTab(tab); + + await captureBookmarkForTab(tab, node, parentNode); + } +} ; + +async function captureBookmarkForTab(tab, node, parentNode) { + if (node.type === NODE_TYPE_ARCHIVE) { + if (parentNode?.external === BROWSER_EXTERNAL_TYPE) + return sendLocal.createBookmark({node}); + else + return sendLocal.createArchive({node, tab}); + } + else + return sendLocal.createBookmark({node}); +} + +receive.setTODOState = message => TODO.setState(message.nodes); + +receive.getBookmarkInfo = async (message, sender) => { + const node = await Node.getByUUID(message.uuid); + + node.__formatted_size = node.size ? formatBytes(node.size) : null; + node.__formatted_date = node.date_added + ? node.date_added.toString().replace(/:[^:]*$/, "") + : null; + node.__tab_id = sender.tab.id; + + return node; +}; + +receive.closeNotes = async message => browser.tabs.sendMessage(message.tabId, {type: "SCRAPYARD_CLOSE_NOTES"}); + +receive.getHideToolbarSetting = async message => { + await settings.load(); + return settings.do_not_show_archive_toolbar(); +}; + +receive.copyNodes = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.copy(message.node_ids, message.dest_id, message.move_last); +}; + +receive.shareToCloud = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.copy(message.node_ids, CLOUD_SHELF_ID, true); +} + +receive.moveNodes = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.move(message.node_ids, message.dest_id, message.move_last); +}; + +receive.deleteNodes = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.delete(message.node_ids); +}; + +receive.softDeleteNodes = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.softDelete(message.node_ids); +}; + +receive.reorderNodes = async message => { + if (!await canUseBookmarking()) + return; + + return Bookmark.reorder(message.positions, message.posProperty); +}; + +receive.storePageHtml = message => { + if (message.bookmark.__url_packing) + return; + + return Bookmark.storeArchive(message.bookmark, message.html, "text/html", message.bookmark.__index) + .then(() => { + if (!message.bookmark.__mute_ui) { + browser.tabs.sendMessage(message.bookmark.__tab_id, {type: "UNLOCK_DOCUMENT"}); + + finalizeCapture(message.bookmark); + + if (message.bookmark.__crawl) + startCrawling(message.bookmark); + } + }) + .catch(e => { + console.error(e); + if (!message.bookmark.__mute_ui) { + chrome.tabs.sendMessage(message.bookmark.__tab_id, {type: "UNLOCK_DOCUMENT"}); + showNotification("Error archiving page."); + } + }); +}; + +receive.addNotes = message => Bookmark.addNotes(message.parent_id, message.name); + +receive.storeNotes = message => Bookmark.storeNotes(message.options, message.property_change); + +receive.uploadFiles = async message => { + send.startProcessingIndication(); + + try { + const helper = await helperApp.hasVersion("0.4", `Scrapyard backend application v0.4+ is required for this feature.`); + + if (helper) { + const fileUUID = UUID.numeric(); + await helperApp.post(`/serve/set_path/${fileUUID}`, {path: message.file_name}); + + //const uuids = await helperApp.fetchJSON("/upload/open_file_dialog"); + const uuids = {[fileUUID]: message.file_name}; + + for (const [uuid, file] of Object.entries(uuids)) { + const url = helperApp.url(`/serve/file/${uuid}/`); + const isHtml = /\.html?$/i.test(file); + + let bookmark = {uri: "", parent_id: message.parent_id}; + + bookmark.name = file.replaceAll("\\", "/").split("/"); + bookmark.name = bookmark.name[bookmark.name.length - 1]; + + let content; + let contentType = getMimetypeByExt(file); + + try { + if (isHtml) { + const page = await packUrlExt(url); + bookmark.name = page.title || bookmark.name; + bookmark.icon = page.icon; + content = page.html; + } + else { + const response = await fetch(url); + if (response.ok) { + contentType = response.headers.get("content-type") || contentType; + content = await response.arrayBuffer(); + } + } + + bookmark = await Bookmark.add(bookmark, NODE_TYPE_ARCHIVE); + if (content) + await Bookmark.storeArchive(bookmark, content, contentType); + else + throw new Error(); + } catch (e) { + console.error(e); + showNotification(`Can not upload ${bookmark.name}`); + } + + await helperApp.fetch(`/serve/release_path/${uuid}`); + } + if (Object.entries(uuids).length) + send.nodesUpdated(); + } + } + finally { + send.stopProcessingIndication(); + } +} + +// receive.browseNode = async message => { +// return browseNode(message.node, message); +// }; + +receive.browseNode = async message => { + if (!_BACKGROUND_PAGE && settings.storage_mode_internal()) { + await ensureSidebarWindow(); + return send.browseNodeSidebar(message); + } + else + return browseNodeBackground(message.node, message); +}; + +receive.browseNotes = message => { + (message.tab + ? updateTabURL(message.tab, "ui/notes.html#" + message.uuid, false) + : browser.tabs.create({"url": "ui/notes.html#" + message.uuid})); +}; + +receive.browseOrgReference = message => { + location.href = message.link; +}; + +receive.loadInternalResource = async message => { + const url = browser.runtime.getURL(message.path); + return await fetchText(url); +}; + +receive.abortRequested = message => { + abortCrawling(); +}; + +receive.replyFrameSiteCapture = (message, sender) => { + browser.tabs.sendMessage(sender.tab.id, message); +}; + +receive.cancelSiteCapture = (message, sender) => { + browser.tabs.sendMessage(sender.tab.id, message); +}; + +receive.continueSiteCapture = (message, sender) => { + browser.tabs.sendMessage(sender.tab.id, message); +}; + +receive.performSiteCapture = (message, sender) => { + performSiteCapture(message.bookmark); +}; + +receive.performUndo = async message => { + send.startProcessingIndication(); + try { + const result = await undoManager.undo(); + + switch (result.operation) { + case UNDO_DELETE: + send.nodesImported({shelf: result.shelf}); + break; + } + } + finally { + send.stopProcessingIndication(); + } +}; + +receive.saveResource = async message => { + return Archive.saveFile(message.node, message.filename, message.content); +}; diff --git a/addon/core_import.js b/addon/core_import.js new file mode 100644 index 00000000..63602d51 --- /dev/null +++ b/addon/core_import.js @@ -0,0 +1,199 @@ +import {isBuiltInShelf} from "./storage.js"; +import {LineStream, LineReader, readFile} from "./utils_io.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {settings} from "./settings.js"; +import {receive} from "./proxy.js"; +import {Import, Export} from "./import.js"; +import {helperApp} from "./helper_app.js"; +import {sleep} from "./utils.js"; +import UUID from "./uuid.js"; +import {ExportArea} from "./storage_export.js"; + +receive.importFile = async message => { + const shelf = isBuiltInShelf(message.file_name)? message.file_name.toLocaleLowerCase(): message.file_name; + const format = message.file_ext.toLowerCase(); + const importerBuilder = Import.create(format); + + if (importerBuilder) { + importerBuilder.setName(shelf) + importerBuilder.setReportProgress(true); + importerBuilder.setSidebarContext(!_BACKGROUND_PAGE); + + switch (format) { + case "json": + case "jsonl": + case "jsbk": + importerBuilder.setStream(new LineStream(new LineReader(message.file))); + break; + case "org": + case "html": + importerBuilder.setStream(await readFile(message.file)); + break; + case "rdf": + importerBuilder.setStream(message.file); + importerBuilder.setNumberOfThreads(message.threads); + importerBuilder.setQuickImport(message.quick); + importerBuilder.setCreateIndex(message.createIndex); + break; + } + + const importer = importerBuilder.build(); + + let invalidationState = ishellConnector.isInvalidationEnabled(); + ishellConnector.enableInvalidation(false); + return Import.transaction(importer).finally(() => { + ishellConnector.enableInvalidation(invalidationState); + ishellConnector.invalidateCompletion(); + }); + } +}; + +receive.exportFile = async message => { + let shelf = message.shelf; + let shelfName = message.shelf; + + if (typeof shelf === "string") { + shelfName = shelf; + + if (isBuiltInShelf(shelfName)) + shelfName = shelfName.toLocaleLowerCase(); + } + else + shelfName = shelf.name; + + let format = message.format || "json"; + + let shallowExport = false; + if (format === "org_links") { + shallowExport = true; + format = "org"; + } + + let fileExt = ".jsbk"; + if (format === "org") + fileExt = ".org"; + + const fileName = message.fileName.replace(/[\\\/:*?"<>|^#%&!@+={}'~]/g, "_") + fileExt; + + let nodes = await Export.nodes(shelf, format === "org"); + + const exportBuilder = Export.create(format) + .setName(shelfName) + .setUUID(message.uuid) + .setLinksOnly(shallowExport) + .setReportProgress(true) + .setObjects(nodes) + .setSidebarContext(!_BACKGROUND_PAGE); + + + if (settings.storage_mode_internal()) + await exportStandalone(exportBuilder, fileName, format); + else + await exportWithHelperApp(exportBuilder, fileName, format); +}; + +async function exportWithHelperApp(exportBuilder, fileName, format) { + try { + helperApp.fetch("/export/initialize"); + } catch (e) { + console.error(e); + } + + const port = await helperApp.getPort(); + + const file = { + append: async function (text) { + port.postMessage({ + type: "EXPORT_PUSH_TEXT", + text: text + }) + } + }; + + await sleep(50); + + exportBuilder.setStream(file); + const exporter = exportBuilder.build(); + await exporter.export(); + + port.postMessage({ + type: "EXPORT_FINISH" + }); + + let url = helperApp.url("/export/download"); + let download; + + try { + download = await browser.downloads.download({url: url, filename: fileName, saveAs: true}); + } catch (e) { + console.error(e); + helperApp.fetch("/export/finalize"); + } + + if (download) { + let download_listener = delta => { + if (delta.id === download) { + if (delta.state && delta.state.current === "complete" || delta.error) { + browser.downloads.onChanged.removeListener(download_listener); + helperApp.fetch("/export/finalize"); + } + } + }; + browser.downloads.onChanged.addListener(download_listener); + } +} + +async function exportStandalone(exportBuilder, fileName, format) { + const MAX_BLOB_SIZE = 1024 * 1024 * 10; // ~20 mb of UTF-16 + const exportId = UUID.numeric(); + + let file = { + content: [], + size: 0, + append: async function (text) { // store intermediate export results to IDB + this.content.push(text); + this.size += text.length; + + if (this.size >= MAX_BLOB_SIZE) { + await ExportArea.addBlob(exportId, new Blob(this.content, {type: "text/plain"})); + this.content = []; + this.size = 0; + } + }, + flush: async function () { + if (this.size && this.content.length) + await ExportArea.addBlob(exportId, new Blob(this.content, {type: "text/plain"})); + } + }; + + await ExportArea.wipe(); + exportBuilder.setStream(file); + const exporter = exportBuilder.build(); + await exporter.export(); + await file.flush(); + + const mimeType = format === "json"? "application/json": "text/plain"; + let blob = new Blob(await ExportArea.getBlobs(exportId), {type: mimeType}); + let url = URL.createObjectURL(blob); + let download; + + try { + download = await browser.downloads.download({url: url, filename: fileName, saveAs: true}); + } catch (e) { + console.error(e); + ExportArea.removeBlobs(exportId); + } + + if (download) { + let download_listener = delta => { + if (delta.id === download) { + if (delta.state && delta.state.current === "complete" || delta.error) { + browser.downloads.onChanged.removeListener(download_listener); + URL.revokeObjectURL(url); + ExportArea.removeBlobs(exportId); + } + } + }; + browser.downloads.onChanged.addListener(download_listener); + } +} diff --git a/addon/core_ishell.js b/addon/core_ishell.js new file mode 100644 index 00000000..419521ee --- /dev/null +++ b/addon/core_ishell.js @@ -0,0 +1,175 @@ +import {receiveExternal, send, sendLocal} from "./proxy.js"; +import { + BROWSER_EXTERNAL_TYPE, + DEFAULT_SHELF_NAME, DONE_SHELF_NAME, EVERYTHING_SHELF_NAME, FIREFOX_BOOKMARK_MENU, + FIREFOX_BOOKMARK_UNFILED, NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK, NODE_TYPE_NOTES, + NODE_TYPE_FOLDER, NODE_TYPE_SHELF, TODO_SHELF_NAME +} from "./storage.js"; +import {ishellConnector} from "./plugin_ishell.js"; +import {getActiveTabMetadata} from "./bookmarking.js"; +import {Query} from "./storage_query.js"; +import {Path} from "./path.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Icon, Node} from "./storage_entities.js"; +import {Folder} from "./bookmarks_folder.js"; +import {browseNode} from "./browse.js"; +import {showNotification} from "./utils_browser.js"; + +receiveExternal.scrapyardListShelvesIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + let shelves = await Query.allShelves(); + return shelves.map(n => ({name: n.name})); +}; + +receiveExternal.scrapyardListGroupsIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + let shelves = await Query.allShelves(); + shelves = shelves.map(n => ({name: n.name})); + const builtin = [EVERYTHING_SHELF_NAME, TODO_SHELF_NAME, DONE_SHELF_NAME].map(s => ({name: s})); + + shelves = [...builtin, ...shelves]; + + let folders = await Query.allFolders(); + folders.forEach(n => renderPath(n, folders)); + folders = folders.map(n => ({name: n.name, path: n.path})); + + return [...shelves, ...folders]; +}; + +receiveExternal.scrapyardListTagsIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + let tags = []; //await bookmarkManager.queryTags(); + return tags.map(t => ({name: t.name.toLocaleLowerCase()})); +}; + +receiveExternal.scrapyardListNodesIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + delete message.type; + + let no_shelves = message.types && !message.types.some(t => t === NODE_TYPE_SHELF); + + if (message.types) + message.types = message.types.concat([NODE_TYPE_SHELF]); + + message.path = Path.expand(message.path); + + let nodes = await Bookmark.list(message); + + for (let node of nodes) { + if (node.type === NODE_TYPE_FOLDER) { + renderPath(node, nodes); + } + + if (node.stored_icon) + node.icon = await Icon.get(node); + } + if (no_shelves) + return nodes.filter(n => n.type !== NODE_TYPE_SHELF); + else + return nodes; +}; + +receiveExternal.scrapyardBrowseNodeIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + if (message.node.uuid) + Node.getByUUID(message.node.uuid).then(node => browseNode(node)); + else + browseNode(message.node); +}; + +function renderPath(node, nodes) { + let path = []; + let parent = node; + + while (parent) { + path.push(parent); + parent = nodes.find(n => n.id === parent.parent_id); + } + + if (path[path.length - 1].name === DEFAULT_SHELF_NAME) { + path[path.length - 1].name = "~"; + } + + if (path.length >= 2 && path[path.length - 1].external === BROWSER_EXTERNAL_TYPE + && path[path.length - 2].external_id === FIREFOX_BOOKMARK_UNFILED) { + path.pop(); + path[path.length - 1].name = "@@"; + } + + if (path.length >= 2 && path[path.length - 1].external === BROWSER_EXTERNAL_TYPE + && path[path.length - 2].external_id === FIREFOX_BOOKMARK_MENU) { + path.pop(); + path[path.length - 1].name = "@"; + } + + node.path = path.reverse().map(n => n.name).join("/"); +} + +receiveExternal.scrapyardAddBookmarkIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + addBookmarkFromIshell(message, NODE_TYPE_BOOKMARK); +} + +receiveExternal.scrapyardAddArchiveIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + addBookmarkFromIshell(message, NODE_TYPE_ARCHIVE); +} + +receiveExternal.scrapyardAddSiteIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + message.__crawl = true; + addBookmarkFromIshell(message, NODE_TYPE_ARCHIVE); +} + +receiveExternal.scrapyardAddNotesIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + if (!message.name) { + showNotification({title: "Error", message: "Bookmark name is empty!"}); + return; + } + + const bookmark = await addBookmarkFromIshell(message, NODE_TYPE_NOTES); + return browseNode(bookmark, {edit: true}); +} + +async function addBookmarkFromIshell(message, type) { + const node = await getActiveTabMetadata(); + + node.name = message.name || node.name; + node.type = type; + node.tags = message.tags; + node.todo_state = message.todo_state; + node.todo_date = message.todo_date; + node.details = message.details; + + if (message.__crawl) + node.__crawl = true; + + const path = Path.expand(message.path); + const folder = await Folder.getOrCreateByPath(path); + node.parent_id = folder.id; + delete message.path; + + if (type === NODE_TYPE_NOTES) + return sendLocal.createNotes({node}); + else + return sendLocal.captureHighlightedTabs({options: node}); +} diff --git a/addon/core_maintenance.js b/addon/core_maintenance.js new file mode 100644 index 00000000..6e5744ac --- /dev/null +++ b/addon/core_maintenance.js @@ -0,0 +1,183 @@ +import {receive, send, sendLocal} from "./proxy.js"; +import { + isContentNode, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, + NODE_TYPE_NOTES, + DEFAULT_SHELF_UUID +} from "./storage.js"; +import {cloudShelf} from "./plugin_cloud_shelf.js"; +import {filesShelf} from "./plugin_files_shelf.js"; +import {Node} from "./storage_entities.js"; +import {Database} from "./storage_database.js"; +import {settings} from "./settings.js"; +import {helperApp} from "./helper_app.js"; +import {Export} from "./import.js"; +import {FORMAT_DEFAULT_SHELF_UUID, UnmarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {isDeepEqual} from "./utils.js"; +import {browserShelf} from "./plugin_browser_shelf.js"; + +receive.resetCloud = async message => { + if (!cloudShelf.isAuthenticated()) + return false; + + send.startProcessingIndication({noWait: true}); + + await cloudShelf.reset(); + + send.stopProcessingIndication(); + + return true; +} + +receive.resetScrapyard = async message => { + send.startProcessingIndication({noWait: true}); + + await Database.wipeEverything(); + await settings.last_sync_date(null); + + if (settings.enable_files_shelf()) + await filesShelf.createIfMissing(); + + if (settings.cloud_enabled()) + await cloudShelf.createIfMissing(); + + if (settings.show_firefox_bookmarks()) + /*await*/ browserShelf.reconcileBrowserBookmarksDB(); + + send.stopProcessingIndication(); + + if (!settings.storage_mode_internal()) + return sendLocal.performSync(); +} + +receive.computeStatistics = async message => { + let items = 0; + let bookmarks = 0; + let archives = 0; + let notes = 0 + let size = 0; + + send.startProcessingIndication(); + + await Node.iterate(node => { + if (isContentNode(node)) + items += 1; + + if (node.type === NODE_TYPE_BOOKMARK) + bookmarks += 1; + + if (node.type === NODE_TYPE_ARCHIVE) { + archives += 1; + size += node.size || 0; + } + + if (node.type === NODE_TYPE_NOTES) { + notes += 1; + size += node.size || 0; + } + }); + + send.stopProcessingIndication(); + + return {items, bookmarks, archives, notes, size}; +} + +receive.getOrphanedItems = async message => { + const helper = await helperApp.probe(true); + + if (helper) { + await settings.load(); + const params = {data_path: settings.data_folder_path()}; + return await helperApp.fetchJSON_postJSON("/storage/get_orphaned_items", params); + } +}; + +receive.rebuildItemIndex = async message => { + const helper = await helperApp.probe(true); + + if (helper) { + await settings.load(); + const params = {data_path: settings.data_folder_path()}; + return await helperApp.postJSON("/storage/rebuild_item_index", params); + } +}; + +receive.compareDatabaseStorage = async message => { + const helper = await helperApp.probe(true); + + if (helper) { + await settings.load(); + const params = {data_path: settings.data_folder_path()}; + const storedNodes = await helperApp.fetchJSON_postJSON("/storage/debug_get_stored_node_instances", params); + const nodes = await Export.nodes("everything"); + const unmarshaller = new UnmarshallerJSONScrapbook(); + + const idbKeys = new Set(nodes.map(n => n.uuid === DEFAULT_SHELF_UUID? FORMAT_DEFAULT_SHELF_UUID: n.uuid)); + const storageKeys = new Set(Object.keys(storedNodes)); + + let result = idbKeys.size === storageKeys.size && [...idbKeys].every(x => storageKeys.has(x)); + + if (!result) { + for (const node of [...nodes]) { + if (node.uuid === DEFAULT_SHELF_UUID) + node.uuid = FORMAT_DEFAULT_SHELF_UUID; + + if (storedNodes[node.uuid]) { + nodes.splice(nodes.indexOf(node), 1); + delete storedNodes[node.uuid]; + } + } + + console.log("Nodes only in IDB:"); + console.log(nodes); + console.log("Nodes only in storage:") + console.log(storedNodes); + + return result; + } + + for (const node of nodes) { + if (node.uuid === DEFAULT_SHELF_UUID) + continue; + + const storedObjects = storedNodes[node.uuid]; + + if (storedObjects) { + let indexItem = unmarshaller.unconvertNode(storedObjects.db_item); + indexItem = unmarshaller.deserializeNode(indexItem); + await unmarshaller.findParentInIDB(indexItem) + + let objectItem = unmarshaller.unconvertNode(storedObjects.object_item); + objectItem = unmarshaller.deserializeNode(objectItem); + await unmarshaller.findParentInIDB(objectItem) + + delete node.id; + delete node.icon; + delete node.tag_list; + + if (node.tags === "") + delete node.tags; + + if (!isDeepEqual(node, indexItem, true) || !isDeepEqual(node, objectItem, true)) { + console.log("Objects do not match:\nIDB object:"); + console.log(node); + console.log("Index object:"); + console.log(indexItem); + console.log("File object:"); + console.log(objectItem); + + result = false; + } + } + else { + console.log("No corresponding storage item for object:"); + console.log(node); + + result = false; + } + } + + return result; + } +}; diff --git a/addon/core_share.js b/addon/core_share.js new file mode 100644 index 00000000..62334c3d --- /dev/null +++ b/addon/core_share.js @@ -0,0 +1,124 @@ +import {receive} from "./proxy.js"; +import {GetPocket} from "./lib/pocket.js"; +import {settings} from "./settings.js"; +import {showNotification} from "./utils_browser.js"; +import {NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK, NODE_TYPE_FILE, NODE_TYPE_NOTES} from "./storage.js"; +import {notes2html} from "./notes_render.js"; +import {dropboxClient} from "./cloud_client_dropbox.js"; +import {oneDriveClient} from "./cloud_client_onedrive.js"; +import {CONTENT_TYPE_TO_EXT} from "./utils.js"; +import {Archive, Notes} from "./storage_entities.js"; + +receive.shareToPocket = async message => { + const auth_handler = auth_url => new Promise(async (resolve, reject) => { + let pocket_tab = await browser.tabs.create({url: auth_url}); + let listener = async (id, changed, tab) => { + if (id === pocket_tab.id) { + if (changed.url && !changed.url.includes("getpocket.com")) { + await browser.tabs.onUpdated.removeListener(listener); + browser.tabs.remove(pocket_tab.id); + resolve(); + } + } + }; + browser.tabs.onUpdated.addListener(listener); + }); + + let pocket = new GetPocket({ + consumer_key: "87251-b8d5db3009affab6297bc799", + access_token: settings.pocket_access_token(), + redirect_uri: "https://gchristensen.github.io/scrapyard/", + auth_handler: auth_handler, + persist_token: token => settings.pocket_access_token(token) + }); + + let actions = message.nodes.map(n => ({ + action: "add", + title: n.name, + url: n.uri, + tags: n.tags + })); + await pocket.modify(actions).catch(e => console.error(e)); + + showNotification(`Successfully added bookmark${message.nodes.length > 1 + ? "s" + : ""} to Pocket.`) +}; + +receive.shareToDropbox = async message => { + let shared = false; + + for (let node of message.nodes) { + let {filename, content} = await prepareForCloudSharing(node); + + if (filename && content) { + shared = true; + await dropboxClient.share("/", filename, content); + } + } + + if (shared) + showNotification(`Successfully shared bookmark${message.nodes.length > 1? "s": ""} to Dropbox.`) +} + +receive.shareToOneDrive = async message => { + let shared = false; + + for (let node of message.nodes) { + let {filename, content} = await prepareForCloudSharing(node); + + if (filename && content) { + shared = true; + await oneDriveClient.share("/", filename, content); + } + } + + if (shared) + showNotification(`Successfully shared bookmark${message.nodes.length > 1? "s": ""} to OneDrive.`) +} + +async function prepareForCloudSharing(node) { + let filename, content; + + if (node.type === NODE_TYPE_ARCHIVE || node.type === NODE_TYPE_FILE) { + let archive = await Archive.get(node); + if (archive) { + let type = archive.type? archive.type: "text/html"; + + if (Archive.isUnpacked(node)) + type = "application/octet-stream"; + + filename = node.name + + if (!/\.[a-z]{2,8}$/.test(node.name?.toLowerCase())) { + let ext = CONTENT_TYPE_TO_EXT[type] || "bin"; + + if (Archive.isUnpacked(node)) + ext = "zip"; + + filename = node.name + `.${ext}`; + } + + content = await Archive.reify(archive); + + if (!(content instanceof Blob)) + content = new Blob([content], {type}); + } + } + else if (node.type === NODE_TYPE_BOOKMARK) { + filename = node.name + ".url"; + content = "[InternetShortcut]\nURL=" + node.uri; + } + else if (node.type === NODE_TYPE_NOTES) { + let notes = await Notes.get(node); + + if (notes) { + filename = node.name + ".html"; + content = `` + + `${notes2html(notes)}` + + ``; + } + } + + return {filename, content}; +} diff --git a/addon/core_sync.js b/addon/core_sync.js new file mode 100644 index 00000000..ae286974 --- /dev/null +++ b/addon/core_sync.js @@ -0,0 +1,276 @@ +import {receive, send} from "./proxy.js"; +import {SCRAPYARD_SYNC_METADATA, settings} from "./settings.js"; +import {Node} from "./storage_entities.js"; +import {HELPER_APP_v2_IS_REQUIRED, helperApp} from "./helper_app.js"; +import {ACTION_ICONS, showNotification} from "./utils_browser.js"; +import {DEFAULT_SHELF_UUID, NON_SYNCHRONIZED_EXTERNALS, JSON_SCRAPBOOK_VERSION} from "./storage.js"; +import {chunk, ProgressCounter} from "./utils.js"; +import {MarshallerSync, UnmarshallerSync} from "./marshaller_sync.js"; +import {Database} from "./storage_database.js"; +import {undoManager} from "./bookmarks_undo.js"; + +const SYNC_NODE_CHUNK_SIZE = 10; + +let syncing = false; + +receive.checkSyncDirectory = async message => { + try { + send.startProcessingIndication(); + const helper = await helperApp.hasVersion("2.0", HELPER_APP_v2_IS_REQUIRED); + + if (helper) { + const status = await helperApp.fetchJSON_postJSON("/storage/check_directory", { + data_path: message.path + }); + + if (status) + return status.status; + } + } + finally { + send.stopProcessingIndication(); + } +}; + +receive.performSync = async message => { + let synced; + + send.startProcessingIndication(); + + try { + synced = await performSync(); + } + finally { + send.stopProcessingIndication(); + + if (synced) + send.shelvesChanged(); + } +}; + +async function performSync() { + let result; + + await settings.load(); + + const syncDirectory = settings.data_folder_path(); + + if (syncing || !syncDirectory || !await helperApp.probe(true)) + return; + + try { + syncing = true; + + let storageMetadata = await getStorageMetadata(syncDirectory); + + if (storageMetadata) { + const dbMetadata = await settings.get(SCRAPYARD_SYNC_METADATA); + + if (await prepareDatabase(storageMetadata, dbMetadata)) { + + const syncOperations = await computeSync(syncDirectory); + + if (syncOperations) { + result = await syncWithStorage(syncOperations, syncDirectory); + await helperApp.fetch("/storage/sync_close_session"); + await settings.set(SCRAPYARD_SYNC_METADATA, storageMetadata); + } + else + showNotification("Synchronization could not be performed because of an error."); + } + } + } + finally { + syncing = false; + } + + return result; +} + +async function getStorageMetadata(syncDirectory) { + let storageMetadata + try { + storageMetadata = await helperApp.fetchJSON_postJSON("/storage/get_metadata", { + data_path: syncDirectory + }); + } catch (e) { + console.error(e); + } + + if (!storageMetadata || storageMetadata.error === "error") { + showNotification("Synchronization error."); + return; + } + else if (storageMetadata.error === "empty" || !storageMetadata.entities) { + showNotification("The disk storage is missing or empty.\n" + + "If you are just starting to work with Scrapyard, please create a bookmark to mute this message."); + return; + } + else if (storageMetadata.type !== "index") { + showNotification("Unknown storage format type."); + return; + } + else if (typeof storageMetadata.version === "number" && storageMetadata.version > JSON_SCRAPBOOK_VERSION) { + showNotification("Unknown storage format version."); + return; + } + + return storageMetadata; +} + +async function computeSync(syncDirectory) { + const syncNodes = await getNodesForSync(); + + const syncParams = { + data_path: syncDirectory, + nodes: JSON.stringify(syncNodes), + last_sync_date: settings.last_sync_date() || -1 + }; + + let syncOperations; + try { + syncOperations = await helperApp.fetchJSON_postJSON("/storage/sync_compute", syncParams); + } catch (e) { + console.error(e); + } + return syncOperations; +} + +async function getNodesForSync() { + const syncNodes = []; + const marshaller = new MarshallerSync(); + + await Node.iterate(node => { + const nonSyncable = node.external && NON_SYNCHRONIZED_EXTERNALS.some(ex => ex === node.external); + + if (!nonSyncable) { + const syncNode = marshaller.createSyncNode(node); + syncNodes.push(syncNode); + } + }); + + return syncNodes; +} + +async function prepareDatabase(storageMetadata, dbMetadata) { + if (storageMetadata.type !== "index") { + showNotification("Storage format type is not supported."); + return false; + } + + const resetDatabase = storageMetadata.uuid !== dbMetadata?.uuid + || storageMetadata.timestamp < dbMetadata?.timestamp + || storageMetadata.version !== dbMetadata?.version; + + if (resetDatabase) { + await undoManager.commit(); + await Database.wipeImportable(); + await settings.last_sync_date(null); + } + + return true; +} + +async function syncWithStorage(syncOperations, syncDirectory) { + if (areChangesPresent(syncOperations)) { + const action = _MANIFEST_V3? browser.action: browser.browserAction; + + if (settings.platform.firefox) + action.setIcon({path: "/icons/action-sync.svg"}); + else + action.setIcon({path: "/icons/action-sync.png"}); + + try { + await performOperations(syncOperations, syncDirectory); + } finally { + if (settings.platform.firefox) + action.setIcon({path: "/icons/scrapyard.svg"}); + else + action.setIcon({path: ACTION_ICONS}); + } + + return true; + } +} + +function areChangesPresent(syncOperations) { + //console.log(syncOperations); + + const changes = syncOperations.push.length + || syncOperations.pull.length + || syncOperations.delete.length + || syncOperations.delete_in_storage.length; + + return !!changes; +} + +async function performOperations(syncOperations, syncDirectory) { + await helperApp.fetchJSON_postJSON("/storage/sync_open_session", {data_path: syncDirectory}); + + let errors = false; + + // try { + // await deleteStorageNodes(syncOperations.delete_in_storage); + // } + // catch (e) { + // errors = true; + // console.error(e); + // } + + const total = syncOperations.push.length + + Math.floor(syncOperations.pull.length / SYNC_NODE_CHUNK_SIZE) + /*+ syncOperations.delete.length*/; + const progress = new ProgressCounter(total, "syncProgress"); + + // const syncMarshaller = new MarshallerSync(); + // for (const syncNode of syncOperations.push) + // try { + // await syncMarshaller.marshal(syncNode); + // progress.incrementAndNotify(); + // } + // catch (e) { + // errors = true; + // console.error(e); + // } + + const syncUnmarshaller = new UnmarshallerSync(); + for (const syncNodes of chunk(syncOperations.pull, SYNC_NODE_CHUNK_SIZE)) + try { + const success = await syncUnmarshaller.unmarshall(syncNodes) + progress.incrementAndNotify(); + + if (!success) + errors = true; + } catch (e) { + errors = true; + console.error(e); + } + + await deleteNodes(syncOperations.delete); + + progress.finish(); + + await settings.load(); + settings.last_sync_date(Date.now()); + + if (errors) + showNotification("Synchronization finished with errors."); +} + +async function deleteStorageNodes(syncNodes) { + if (syncNodes.length) + await helperApp.post("/storage/sync_delete_nodes", {nodes: JSON.stringify(syncNodes)}); +} + +async function deleteNodes(syncNodes) { + for (const syncNode of syncNodes) + try { + if (syncNode.uuid === DEFAULT_SHELF_UUID) + continue; + const node = await Node.getByUUID(syncNode.uuid) + await Node.idb.delete(node) + } + catch (e) { + console.error(e); + } +} diff --git a/addon/core_transition.js b/addon/core_transition.js new file mode 100644 index 00000000..5f13ab53 --- /dev/null +++ b/addon/core_transition.js @@ -0,0 +1,113 @@ +import {EVERYTHING_SHELF_NAME, NODE_TYPE_ARCHIVE, CLOUD_SHELF_NAME, CLOUD_SHELF_ID} from "./storage.js"; +import {Node, Archive, Comments, Icon, Notes} from "./storage_entities.js"; +import {HELPER_APP_v2_IS_REQUIRED, helperApp} from "./helper_app.js"; +import {showNotification} from "./utils_browser.js"; +import {receive, send} from "./proxy.js"; +import {Export} from "./import.js"; +import {ProgressCounter} from "./utils.js"; +import {settings} from "./settings.js"; +import {Query} from "./storage_query.js"; +import UUID from "./uuid.js"; +import {DiskStorage} from "./storage_external.js"; +import {indexHTML} from "./utils_html.js"; + +receive.transferContentToDisk = async message => { + if (!settings.data_folder_path()) { + showNotification("Data folder path is not set."); + return; + } + + const helper = await helperApp.hasVersion("2.0", HELPER_APP_v2_IS_REQUIRED); + + if (!helper) + return; + + send.startProcessingIndication({noWait: true}); + + const nodes = await collectNodes(); + const progressCounter = new ProgressCounter(nodes.length, "exportProgress"); + + try { + await DiskStorage.openBatchSession(); + + for (const node of nodes) { + await transferNode(node); + progressCounter.incrementAndNotify(); + } + + await settings.transition_to_disk(false); + showNotification("Content transfer finished."); + return true; + } catch (e) { + console.error(e); + showNotification("Content transfer finished with errors."); + return false; + } + finally { + await DiskStorage.closeBatchSession(); + send.stopProcessingIndication(); + await progressCounter.finish(); + } +}; + +async function collectNodes() { + let nodes = await Export.nodes(EVERYTHING_SHELF_NAME); + + if (settings.cloud_enabled()) { + let cloudShelf = await Node.get(CLOUD_SHELF_ID); + + if (cloudShelf) { + const cloudNodes = await Query.fullSubtree(cloudShelf.id); + cloudShelf = cloudNodes.find(n => n.id === CLOUD_SHELF_ID); + + for (const cloudNode of cloudNodes) { + delete cloudNode.external; + delete cloudNode.external_id; + } + + cloudShelf.uuid = UUID.numeric(); + cloudShelf.name = CLOUD_SHELF_NAME + " (transferred)"; + cloudShelf.date_modifed = cloudShelf.date_added; + + nodes = [...nodes, ...cloudNodes]; + } + } + + return nodes; +} + +async function transferNode(node) { + Node.put(node); + + if (node.stored_icon) { + let icon = await Icon.idb.import.get(node); + if (icon) + await Icon.add(node, icon); + } + + if (node.type === NODE_TYPE_ARCHIVE) { + let archive = await Archive.idb.import.get(node); + + if (archive) { + if (!archive.byte_length) { + const content = await Archive.reify(archive); + const index = indexHTML(content); + await Archive.storeIndex(node, index); + } + + await Archive.add(node, archive); + } + } + + if (node.has_notes) { + let notes = await Notes.idb.import.get(node); + if (notes) + await Notes.add(node, notes); + } + + if (node.has_comments) { + let comments = await Comments.idb.import.get(node); + if (comments) + await Comments.add(node, comments); + } +} diff --git a/addon/crawler.js b/addon/crawler.js new file mode 100644 index 00000000..2044e0ad --- /dev/null +++ b/addon/crawler.js @@ -0,0 +1,317 @@ +import {packPage} from "./bookmarking.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {NODE_TYPE_ARCHIVE} from "./storage.js"; +import {fetchWithTimeout} from "./utils_io.js"; +import {send} from "./proxy.js"; +import {sleep} from "./utils.js"; +import {isHTMLLink} from "./utils_html.js"; +import {showNotification} from "./utils_browser.js"; + +class Rules { + #rules; + + constructor(rules) { + this.#rules = this.#constructRules(rules); + } + + match(link) { + let result = false; + if (!this.#rules) + result = true; + else + for (const rule of this.#rules) { + const content = rule.scope === "url"? link.url: link.text; + let matches = false; + + if (rule.mode === "regex") + matches = rule.matcher.test(content); + else + matches = content?.toLowerCase() === rule.matcher.toLowerCase(); + + if (matches) { + result = true; + break; + } + } + + return result; + } + + get empty() { + return !this.#rules; + } + + #constructRules(rules) { + const lines = rules.trim().split("\n"); + + if (lines.length) { + const result = []; + + for (const line of lines) { + const rule = this.#constructRule(line); + if (rule) + result.push(rule); + } + + if (result.length) + return result; + } + } + + #constructRule(line) { + const rule = {scope: "url", mode: "regex"}; + line = line.trim(); + + if (line) { + if (line.startsWith("$text:")) { + rule.scope = "text"; + line = line.replace(/^\$text:/, ""); + } + + if (!line.startsWith("/")) + rule.mode = "string"; + + if (rule.scope !== "text") + line = line.split(" ")[0]; + + if (rule.mode === "regex") { + line = line.replace("\\/", "/"); + const matches = line.match(/^\/(.*)\/([a-zA-Z]*)?$/); + if (matches) + rule.matcher = new RegExp(matches[1], matches[2]); + else { + rule.mode = "string"; + rule.matcher = line; + } + } + else + rule.matcher = line; + + return rule; + } + } +} + +class Queue { + #options; + #rootURL; + #rootHost; + #links = []; + #visitedURLs; + + constructor(rootURL, options) { + this.#rootURL = rootURL; + this.#rootHost = new URL(rootURL).host; + this.#options = options; + + const normalizedURL = this.#normalizeURL(rootURL); + this.#visitedURLs = new Set([normalizedURL]); + } + + #normalizeURL(url) { + if (this.#options.ignoreHashes) + url = url.replace(/#.*$/, ""); + + return url; + } + + push(link) { + const normalizedURL = this.#normalizeURL(link.url); + if (!this.#visitedURLs.has(normalizedURL)) { + this.#visitedURLs.add(normalizedURL); + this.#links.push(link); + } + } + + pop() { + return this.#links.shift(); + } + + get size() { + return this.#links.length; + } +} + +class Crawler { + #queue; + #options; + #includeRules; + #excludeRules; + #siteBookmark; + #threads = 0; + #abort = false; + onFinish = () => null; + + constructor(bookmark) { + this.#siteBookmark = bookmark; + this.#options = {...bookmark.__site_capture}; + this.#includeRules = new Rules(this.#options.includeRules); + this.#excludeRules = new Rules(this.#options.excludeRules); + this.#queue = new Queue(bookmark.uri, this.#options); + } + + enqueue(bookmark) { + const options = bookmark.__site_capture; + + if (options.level < this.#options.depth && options.links) { + for (const link of options.links) { + if (this.#isLinkAllowed(link)) { + link.level = options.level + 1; + this.#queue.push(link); + } + } + } + } + + abort() { + this.#abort = true; + } + + async startThreads() { + this.#threads = Math.min(this.#options.threads, this.#queue.size); + for (let i = 0; i < this.#threads; ++i) { + this.#visitLink(this.#queue.pop()); + + if (this.#options.delay) + await sleep(this.#options.delay * 1000); + } + } + + #isLinkAllowed(link) { + const include = this.#includeRules.match(link); + const exclude = !this.#excludeRules.empty && this.#excludeRules.match(link); + return include && !exclude; + } + + async #visitLink(link) { + const options = {...this.#options}; + options.level = link.level; + + try { + const bookmark = await this.#savePage(link, options); + + if (bookmark) + this.enqueue(bookmark); + } catch (e) { + console.error(e); + } + + this.#crawl(); + } + + async #crawl() { + if (this.#abort) + return; + + if (this.#options.delay) + await sleep(this.#options.delay * 1000); + + const nextLink = this.#queue.pop(); + + if (nextLink) + this.#visitLink(nextLink); + else { + this.#threads -= 1; + if (this.#threads === 0) + this.onFinish(); + } + } + + async #savePage(link, options) { + const bookmark = { + uri: link.url, + name: "Site Page", + __site_capture: options, + _unlisted: true, + parent_id: this.#siteBookmark.parent_id + }; + + const node = await Bookmark.idb.add(bookmark, NODE_TYPE_ARCHIVE); + const isHTML = await isHTMLLink(link.url); + let resource; + + if (isHTML === true) + resource = await this.#captureHTMLPage(node, link); + else if (isHTML === false) + resource = await this.#captureNonHTMLPage(node, link); + + if (resource) + await this.#saveArchive(resource); + + return resource?.bookmark + } + + #captureHTMLPage(node, link) { + node.__url_packing = true; + const resolver = (m, t) => ({bookmark: m.bookmark, content: m.html, title: t.title, icon: t.favIconUrl}); + return packPage(link.url, node, null, resolver); + } + + async #captureNonHTMLPage(node, link) { + let response; + try { + response = await fetchWithTimeout(link.url); + } catch (e) { + console.error(e); + } + const result = {bookmark: node, title: link.text}; + + if (response?.ok) { + result.contentType = response.headers.get("content-type"); + result.content = await response.arrayBuffer(); + } + + return result; + } + + async #saveArchive(result) { + const node = result.bookmark; + const content = result.content || ""; + const contentType = result.contentType || "text/html"; + + node.name = result.title; + node._unlisted = undefined; + + if (result.icon) { + node.icon = result.icon; + await Bookmark.storeIcon(node); + } + + return Bookmark.storeArchive(node, content, contentType, node.__index); + } +} + +let crawler; + +export function initialize(bookmark) { + if (crawler) { + showNotification("Another site capture process is running"); + return false; + } + else { + crawler = new Crawler(bookmark); + crawler.onFinish = finalize; + return true; + } +} + +export function crawl(bookmark) { + if (crawler) { + crawler.enqueue(bookmark); + crawler.startThreads(); + } +} + +export function abort() { + if (crawler) { + crawler.abort(); + setTimeout(finalize, 500); + } +} + +async function finalize() { + crawler = undefined; + await send.stopProcessingIndication(); + await send.toggleAbortMenu({show: false}); + await send.nodesUpdated(); +} diff --git a/addon/favicon.js b/addon/favicon.js new file mode 100644 index 00000000..fb359096 --- /dev/null +++ b/addon/favicon.js @@ -0,0 +1,96 @@ +import {settings} from "./settings.js"; +import {fetchWithTimeout} from "./utils_io.js"; +import {parseHtml} from "./utils_html.js"; +import {injectScriptFile} from "./utils_browser.js"; + +export async function testFavicon(url) { + try { + // get a nice favicon for wikipedia + if (url.origin && url.origin.endsWith("wikipedia.org")) + return "https://en.wikipedia.org/favicon.ico"; + + let response = await fetch(url) + + if (response.ok) { + let type = response.headers.get("content-type") || "image"; + //let length = response.headers.get("content-length") || "0"; + + if (type.startsWith("image") /*&& parseInt(length) > 0*/) + return url.toString(); + } + } catch (e) { + console.error(e); + } +} + +export async function getFaviconFromTab(tab, tabOnly = false) { + let favicon; + let origin = new URL(tab.url).origin; + + if (!origin || origin === "null") + return; + + if (tab.favIconUrl) + return tab.favIconUrl; + else if (tabOnly) + return; + + try { + let icon = await injectScriptFile(tab.id, {file: "/content_favicon.js"}); + + if (icon && icon.length && icon[0]) + favicon = await testFavicon(new URL(icon[0].result || icon[0], origin)); // TODO: leave only .result in MV3 + } catch (e) { + console.error(e); + } + + if (!favicon) + favicon = await testFavicon(new URL("/favicon.ico", origin)); + + return favicon; +} + +export async function getFaviconFromContent(url, doc) { + if (!doc) { + let timeout = settings.link_check_timeout()? settings.link_check_timeout() * 1000: 10000; + try { + const response = await fetchWithTimeout(url, {timeout}); + + if (response.ok) { + if (_BACKGROUND_PAGE) + doc = parseHtml(await response.text()); + else + doc = await response.text(); + } + } + catch (e) {} + } + else if (_BACKGROUND_PAGE && typeof doc === "string") + doc = parseHtml(doc); + + let favIcon; + + try { + if (typeof doc === "string") { + const linkTag = doc.match(/]*rel=['"](?:icon|shortcut)[^>]*>/i)?.[0]; + + if (linkTag) + favIcon = linkTag.match(/href=['"]?([^'" ]+)/i)?.[1]; + } + else if (doc) { + const faviconElt = doc.querySelector("link[rel*='icon'], link[rel*='shortcut']"); + + if (faviconElt) + favIcon = faviconElt.href; + } + } + catch (e) { + console.error(e); + } + + const origin = new URL(url).origin; + + if (origin && origin !== "null") + return favIcon && await testFavicon(new URL(favIcon, origin)) + || await testFavicon(new URL("/favicon.ico", origin)); +} diff --git a/addon/fonts/fontawesome.css b/addon/fonts/fontawesome.css new file mode 100644 index 00000000..1054d193 --- /dev/null +++ b/addon/fonts/fontawesome.css @@ -0,0 +1,6 @@ +@font-face { + font-family:'FontAwesome'; + src:url('fontawesome.woff2') format('woff2'); + font-weight:normal; + font-style:normal +} \ No newline at end of file diff --git a/addon/fonts/fontawesome.woff2 b/addon/fonts/fontawesome.woff2 new file mode 100644 index 00000000..dc52d954 Binary files /dev/null and b/addon/fonts/fontawesome.woff2 differ diff --git a/addon/global.js b/addon/global.js new file mode 100644 index 00000000..cec2a7a7 --- /dev/null +++ b/addon/global.js @@ -0,0 +1,22 @@ +if (typeof browser === "undefined") + globalThis.browser = chrome; + +const _MANIFEST = chrome.runtime.getManifest(); + +globalThis._ADDON_VERSION = _MANIFEST.version; + +globalThis._MANIFEST_VERSION = _MANIFEST.manifest_version; + +globalThis._MANIFEST_V3 = globalThis._MANIFEST_VERSION === 3; + +globalThis._BACKGROUND_PAGE = !!_MANIFEST.background?.page; + +globalThis._SIDEBAR = !!globalThis.browser.sidebarAction; + +globalThis._log = console.log.bind(console); + +globalThis._tm = (name = "timer") => console.time(name); + +globalThis._te = (name = "timer") => console.timeEnd(name); + +globalThis._tr = console.trace.bind(console); diff --git a/addon/helper_app.js b/addon/helper_app.js new file mode 100644 index 00000000..1cbdc01e --- /dev/null +++ b/addon/helper_app.js @@ -0,0 +1,254 @@ +import UUID from "./uuid.js" +import {settings} from "./settings.js" +import {CONTEXT_BACKGROUND, getContextType, hasCSRPermission, showNotification} from "./utils_browser.js"; +import {send} from "./proxy.js" + +export const HELPER_APP_v2_IS_REQUIRED = "Scrapyard backend application v2.0+ is required."; +export const HELPER_APP_v2_1_IS_REQUIRED = "Scrapyard backend application v2.1+ is required."; + +class HelperApp { + #auth; + #externalEventHandlers = {}; + + constructor() { + this.auth = UUID.numeric(); + this.version = undefined; + } + + get auth() { + return this.#auth; + } + + set auth(uuid) { + this.#auth = uuid; + this.authHeader = "Basic " + btoa("default:" + uuid); + } + + async getPort() { + if (this.port) { + return this.port; + } + else { + this.port = new Promise(async (resolve, reject) => { + let port = browser.runtime.connectNative("scrapyard_helper"); + + port.onDisconnect.addListener(error => { + resolve(null); + this.port = null; + }) + + let initListener = async response => { + response = JSON.parse(response); + if (response.type === "INITIALIZED") { + port.onMessage.removeListener(initListener); + + await this._onInitialized(response, port); + + resolve(port); + } + } + + port.onMessage.addListener(initListener); + + await settings.load(); + + try { + port.postMessage({ + type: "INITIALIZE", + port: settings.helper_port_number(), + auth: this.auth, + logging: !!settings.enable_helper_app_logging() + }); + } + catch (e) { + //console.error(e, e.name) + resolve(null); + this.port = null; + } + }); + + return this.port; + } + } + + async _onInitialized(msg, port) { + this.port = port; + this.version = msg.version; + port.onMessage.addListener(HelperApp._incomingMessages.bind(this)); + + if (msg.error === "address_in_use") + showNotification(`The backend application HTTP port ${settings.helper_port_number()} is not available.`); + } + + async probe(verbose) { + if (getContextType() === CONTEXT_BACKGROUND) + return this._probe(verbose); + else + return send.helperAppProbe({verbose}); + } + + async _probe(verbose = false) { + if (!await hasCSRPermission()) + return false; + + const port = await this.getPort(); + + if (!port && verbose) + showNotification({message: "Can not connect to the backend application."}) + + return !!port; + } + + getVersion() { + if (getContextType() !== CONTEXT_BACKGROUND) + throw new Error("Can not call this method in the foreground context."); + + if (this.port) { + if (!this.version) + return "0.1"; + return this.version; + } + } + + async hasVersion(version, msg) { + if (getContextType() === CONTEXT_BACKGROUND) + return this._hasVersion(version, msg); + else + return send.helperAppHasVersion({version, alert: msg}); + } + + async _hasVersion(version, msg) { + if (!(await this.probe())) { + if (msg) + showNotification(msg); + return false; + } + + let installed = this.getVersion(); + + if (installed) { + if (installed.startsWith(version)) + return true; + + version = version.split(".").map(d => parseInt(d)); + installed = installed.split(".").map(d => parseInt(d)); + installed.length = version.length; + + for (let i = 0; i < version.length; ++i) { + if (installed[i] > version[i]) + return true; + } + + if (msg) + showNotification(msg); + return false; + } + } + + static async _incomingMessages(msg) { + const port = await this.getPort(); + msg = JSON.parse(msg); + + const handler = this.#externalEventHandlers[msg.type]; + + if (handler) { + const response = await handler(msg); + + if (response !== undefined) + port.postMessage(response); + } + } + + addMessageHandler(name, handler) { + this.#externalEventHandlers[name] = handler; + } + + url(path) { + return `http://localhost:${settings.helper_port_number()}${path}`; + } + + _injectAuth(init) { + init = init || {}; + init.headers = init.headers || {}; + init.headers["Authorization"] = this.authHeader; + return init; + } + + async _handleHTTPError(response) { + if (response.status === 204 || response.status === 404) + return null; + else { + const errorMessage = `Scrapyard native client error ${response.status} (${response.statusText})\n`; + console.error(errorMessage, await response.text()); + throw {httpError: {status: response.status, statusText: response.statusText}}; + } + } + + fetch(path, init) { + init = this._injectAuth(init); + return globalThis.fetch(this.url(path), init); + } + + async post(path, fields) { + let form = new FormData(); + + for (let [k, v] of Object.entries(fields)) { + if (v instanceof Blob) + form.append(k, v, k); + else { + v = v + ""; + form.append(k, v); + } + } + + const init = this._injectAuth({method: "POST", body: form}); + + return this.fetch(path, init); + } + + async postJSON(path, fields) { + const init = this._injectAuth({ + method: "POST", + body: JSON.stringify(fields), + headers: {"content-type": "application/json"} + }); + + return this.fetch(path, init); + } + + async fetchText(path, init) { + init = this._injectAuth(init); + let response = await globalThis.fetch(this.url(path), init); + + if (response.ok) + return response.text(); + else + return this._handleHTTPError(response); + } + + async fetchJSON(path, init) { + init = this._injectAuth(init); + let response = await globalThis.fetch(this.url(path), init); + + if (response.ok) + return response.json(); + else + return this._handleHTTPError(response); + } + + + async fetchJSON_postJSON(path, fields) { + let response = await this.postJSON(path, fields); + + if (response.ok) + return response.json(); + else + return this._handleHTTPError(response); + } +} + +export const helperApp = new HelperApp(); + +if (getContextType() !== CONTEXT_BACKGROUND) { + send.helperAppGetBackgroundAuth().then(auth => helperApp.auth = auth); +} diff --git a/addon/icons/action-sync.png b/addon/icons/action-sync.png new file mode 100644 index 00000000..4052518b Binary files /dev/null and b/addon/icons/action-sync.png differ diff --git a/addon/icons/action-sync.svg b/addon/icons/action-sync.svg new file mode 100644 index 00000000..c1e2a6f0 --- /dev/null +++ b/addon/icons/action-sync.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/amo.ico b/addon/icons/amo.ico new file mode 100644 index 00000000..9534e263 Binary files /dev/null and b/addon/icons/amo.ico differ diff --git a/addon/icons/batch_warning.svg b/addon/icons/batch_warning.svg new file mode 100644 index 00000000..8ad65ff6 --- /dev/null +++ b/addon/icons/batch_warning.svg @@ -0,0 +1 @@ +multiple-files-processing \ No newline at end of file diff --git a/addon/icons/bell.svg b/addon/icons/bell.svg new file mode 100644 index 00000000..7e5cce1b --- /dev/null +++ b/addon/icons/bell.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/addon/icons/bitcoin.png b/addon/icons/bitcoin.png new file mode 100644 index 00000000..bd666c7f Binary files /dev/null and b/addon/icons/bitcoin.png differ diff --git a/addon/icons/bookmark.svg b/addon/icons/bookmark.svg new file mode 100644 index 00000000..1c6aa2d9 --- /dev/null +++ b/addon/icons/bookmark.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/addon/icons/bookmarks.svg b/addon/icons/bookmarks.svg new file mode 100644 index 00000000..d9290999 --- /dev/null +++ b/addon/icons/bookmarks.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/addon/icons/bookmarksMenu.svg b/addon/icons/bookmarksMenu.svg new file mode 100644 index 00000000..c337b7bd --- /dev/null +++ b/addon/icons/bookmarksMenu.svg @@ -0,0 +1,74 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addon/icons/bookmarksMenu2.svg b/addon/icons/bookmarksMenu2.svg new file mode 100644 index 00000000..4ce73758 --- /dev/null +++ b/addon/icons/bookmarksMenu2.svg @@ -0,0 +1,75 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addon/icons/bookmarksToolbar.svg b/addon/icons/bookmarksToolbar.svg new file mode 100644 index 00000000..2e6f3d8d --- /dev/null +++ b/addon/icons/bookmarksToolbar.svg @@ -0,0 +1,68 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addon/icons/bookmarksToolbar2.svg b/addon/icons/bookmarksToolbar2.svg new file mode 100644 index 00000000..dd2c15ad --- /dev/null +++ b/addon/icons/bookmarksToolbar2.svg @@ -0,0 +1,70 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addon/icons/books.svg b/addon/icons/books.svg new file mode 100644 index 00000000..b684a0cd --- /dev/null +++ b/addon/icons/books.svg @@ -0,0 +1,74 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/addon/icons/calendar.svg b/addon/icons/calendar.svg new file mode 100644 index 00000000..daa536f2 --- /dev/null +++ b/addon/icons/calendar.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/cancelled.svg b/addon/icons/cancelled.svg new file mode 100644 index 00000000..d7ba5fee --- /dev/null +++ b/addon/icons/cancelled.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/catalogue.svg b/addon/icons/catalogue.svg new file mode 100644 index 00000000..6cd9d09d --- /dev/null +++ b/addon/icons/catalogue.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addon/icons/chrome-webstore.png b/addon/icons/chrome-webstore.png new file mode 100644 index 00000000..b66ee34b Binary files /dev/null and b/addon/icons/chrome-webstore.png differ diff --git a/addon/icons/chrome.svg b/addon/icons/chrome.svg new file mode 100644 index 00000000..b102d58b --- /dev/null +++ b/addon/icons/chrome.svg @@ -0,0 +1,44 @@ + +image/svg+xml \ No newline at end of file diff --git a/addon/icons/chrome2.svg b/addon/icons/chrome2.svg new file mode 100644 index 00000000..05db5907 --- /dev/null +++ b/addon/icons/chrome2.svg @@ -0,0 +1,44 @@ + +image/svg+xml \ No newline at end of file diff --git a/addon/icons/cloud.png b/addon/icons/cloud.png new file mode 100644 index 00000000..ca468df8 Binary files /dev/null and b/addon/icons/cloud.png differ diff --git a/addon/icons/cloud.svg b/addon/icons/cloud.svg new file mode 100644 index 00000000..2b60f17d --- /dev/null +++ b/addon/icons/cloud.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/addon/icons/cloud2.png b/addon/icons/cloud2.png new file mode 100644 index 00000000..a2379a06 Binary files /dev/null and b/addon/icons/cloud2.png differ diff --git a/addon/icons/cloud2.svg b/addon/icons/cloud2.svg new file mode 100644 index 00000000..3a2e8395 --- /dev/null +++ b/addon/icons/cloud2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/addon/icons/cloud_sync.svg b/addon/icons/cloud_sync.svg new file mode 100644 index 00000000..78d421e6 --- /dev/null +++ b/addon/icons/cloud_sync.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/addon/icons/cloud_sync2.svg b/addon/icons/cloud_sync2.svg new file mode 100644 index 00000000..451c71e4 --- /dev/null +++ b/addon/icons/cloud_sync2.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/addon/icons/code.svg b/addon/icons/code.svg new file mode 100644 index 00000000..46c8a98f Binary files /dev/null and b/addon/icons/code.svg differ diff --git a/addon/icons/comments-filled.svg b/addon/icons/comments-filled.svg new file mode 100644 index 00000000..30f9e4b7 --- /dev/null +++ b/addon/icons/comments-filled.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/comments-filled2.svg b/addon/icons/comments-filled2.svg new file mode 100644 index 00000000..705ff07a --- /dev/null +++ b/addon/icons/comments-filled2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/comments.svg b/addon/icons/comments.svg new file mode 100644 index 00000000..0cebe7ce --- /dev/null +++ b/addon/icons/comments.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/comments2.svg b/addon/icons/comments2.svg new file mode 100644 index 00000000..bd5b3d75 --- /dev/null +++ b/addon/icons/comments2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/containers.svg b/addon/icons/containers.svg new file mode 100644 index 00000000..e73cdbc1 --- /dev/null +++ b/addon/icons/containers.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/containers2.svg b/addon/icons/containers2.svg new file mode 100644 index 00000000..c26c8455 --- /dev/null +++ b/addon/icons/containers2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/content-comments.svg b/addon/icons/content-comments.svg new file mode 100644 index 00000000..34642bef --- /dev/null +++ b/addon/icons/content-comments.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/content-notes.svg b/addon/icons/content-notes.svg new file mode 100644 index 00000000..782e8624 --- /dev/null +++ b/addon/icons/content-notes.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/content-web.svg b/addon/icons/content-web.svg new file mode 100644 index 00000000..4c84af29 --- /dev/null +++ b/addon/icons/content-web.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/addon/icons/copy.svg b/addon/icons/copy.svg new file mode 100644 index 00000000..584df116 --- /dev/null +++ b/addon/icons/copy.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/dec.svg b/addon/icons/dec.svg new file mode 100644 index 00000000..554d020b Binary files /dev/null and b/addon/icons/dec.svg differ diff --git a/icons/delete.svg b/addon/icons/delete.svg similarity index 100% rename from icons/delete.svg rename to addon/icons/delete.svg diff --git a/addon/icons/disk_warning.svg b/addon/icons/disk_warning.svg new file mode 100644 index 00000000..7cbd6a8b --- /dev/null +++ b/addon/icons/disk_warning.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/addon/icons/done.svg b/addon/icons/done.svg new file mode 100644 index 00000000..2534ee67 --- /dev/null +++ b/addon/icons/done.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/dropbox.png b/addon/icons/dropbox.png new file mode 100644 index 00000000..87bcb985 Binary files /dev/null and b/addon/icons/dropbox.png differ diff --git a/addon/icons/ethereum.svg b/addon/icons/ethereum.svg new file mode 100644 index 00000000..bc0c279c --- /dev/null +++ b/addon/icons/ethereum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/icons/file.svg b/addon/icons/file.svg new file mode 100644 index 00000000..8823d1e6 --- /dev/null +++ b/addon/icons/file.svg @@ -0,0 +1,53 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/file2.svg b/addon/icons/file2.svg new file mode 100644 index 00000000..6d2c5fd5 --- /dev/null +++ b/addon/icons/file2.svg @@ -0,0 +1,53 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/files-box.svg b/addon/icons/files-box.svg new file mode 100644 index 00000000..3c34580e Binary files /dev/null and b/addon/icons/files-box.svg differ diff --git a/addon/icons/files-box2.svg b/addon/icons/files-box2.svg new file mode 100644 index 00000000..55c8bd7e Binary files /dev/null and b/addon/icons/files-box2.svg differ diff --git a/addon/icons/filter-folder.svg b/addon/icons/filter-folder.svg new file mode 100644 index 00000000..43b279d5 --- /dev/null +++ b/addon/icons/filter-folder.svg @@ -0,0 +1,65 @@ + + + +image/svg+xml + + + + + \ No newline at end of file diff --git a/addon/icons/firefox.svg b/addon/icons/firefox.svg new file mode 100644 index 00000000..fe2335a3 --- /dev/null +++ b/addon/icons/firefox.svg @@ -0,0 +1,58 @@ + + + + + + image/svg+xml + + newtab-firefox-gry + + + + + + newtab-firefox-gry + + diff --git a/addon/icons/firefox2.svg b/addon/icons/firefox2.svg new file mode 100644 index 00000000..07892be8 --- /dev/null +++ b/addon/icons/firefox2.svg @@ -0,0 +1,58 @@ + + + + + + image/svg+xml + + newtab-firefox-gry + + + + + + newtab-firefox-gry + + diff --git a/addon/icons/font-default.svg b/addon/icons/font-default.svg new file mode 100644 index 00000000..3e312313 Binary files /dev/null and b/addon/icons/font-default.svg differ diff --git a/addon/icons/font-larger.svg b/addon/icons/font-larger.svg new file mode 100644 index 00000000..555091fa Binary files /dev/null and b/addon/icons/font-larger.svg differ diff --git a/addon/icons/font-smaller.svg b/addon/icons/font-smaller.svg new file mode 100644 index 00000000..5027f83d Binary files /dev/null and b/addon/icons/font-smaller.svg differ diff --git a/addon/icons/format-image.svg b/addon/icons/format-image.svg new file mode 100644 index 00000000..90fa30cb --- /dev/null +++ b/addon/icons/format-image.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/format-image2.svg b/addon/icons/format-image2.svg new file mode 100644 index 00000000..a6e2f1bf --- /dev/null +++ b/addon/icons/format-image2.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/format-pdf.svg b/addon/icons/format-pdf.svg new file mode 100644 index 00000000..cc326106 --- /dev/null +++ b/addon/icons/format-pdf.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/addon/icons/format-pdf2.svg b/addon/icons/format-pdf2.svg new file mode 100644 index 00000000..0f6d9973 --- /dev/null +++ b/addon/icons/format-pdf2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/addon/icons/github.ico b/addon/icons/github.ico new file mode 100644 index 00000000..a59308e2 Binary files /dev/null and b/addon/icons/github.ico differ diff --git a/addon/icons/globe.svg b/addon/icons/globe.svg new file mode 100644 index 00000000..136518f0 --- /dev/null +++ b/addon/icons/globe.svg @@ -0,0 +1,59 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/addon/icons/globe2.svg b/addon/icons/globe2.svg new file mode 100644 index 00000000..eb0e242f --- /dev/null +++ b/addon/icons/globe2.svg @@ -0,0 +1,60 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/grid.svg b/addon/icons/grid.svg new file mode 100644 index 00000000..fdfe926a --- /dev/null +++ b/addon/icons/grid.svg @@ -0,0 +1,179 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/group.svg b/addon/icons/group.svg new file mode 100644 index 00000000..da1f1385 --- /dev/null +++ b/addon/icons/group.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addon/icons/group2.svg b/addon/icons/group2.svg new file mode 100644 index 00000000..4c7388bd --- /dev/null +++ b/addon/icons/group2.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addon/icons/help-mark.svg b/addon/icons/help-mark.svg new file mode 100644 index 00000000..1edc2a8a --- /dev/null +++ b/addon/icons/help-mark.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/addon/icons/help-mark2.svg b/addon/icons/help-mark2.svg new file mode 100644 index 00000000..5faa0380 --- /dev/null +++ b/addon/icons/help-mark2.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/icons/help.svg b/addon/icons/help.svg similarity index 88% rename from icons/help.svg rename to addon/icons/help.svg index 478c0d36..a1054318 100644 --- a/icons/help.svg +++ b/addon/icons/help.svg @@ -19,10 +19,10 @@ xml:space="preserve" id="svg9" sodipodi:docname="help.svg" - inkscape:version="0.92.2 2405546, 2018-03-11">image/svg+xml @@ -44,7 +44,7 @@ showguides="true" inkscape:guide-bbox="true" inkscape:zoom="16.656764" - inkscape:cx="-8.2857918" + inkscape:cx="-21.583693" inkscape:cy="10.310466" inkscape:window-x="1920" inkscape:window-y="0" @@ -68,18 +68,18 @@ inkscape:locked="false" /> diff --git a/addon/icons/inc.svg b/addon/icons/inc.svg new file mode 100644 index 00000000..db92f260 Binary files /dev/null and b/addon/icons/inc.svg differ diff --git a/addon/icons/jar.svg b/addon/icons/jar.svg new file mode 100644 index 00000000..301776f9 --- /dev/null +++ b/addon/icons/jar.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/addon/icons/jar2.svg b/addon/icons/jar2.svg new file mode 100644 index 00000000..8da7beb1 --- /dev/null +++ b/addon/icons/jar2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/addon/icons/line-hover.svg b/addon/icons/line-hover.svg new file mode 100644 index 00000000..13b80fa1 --- /dev/null +++ b/addon/icons/line-hover.svg @@ -0,0 +1,58 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/line.svg b/addon/icons/line.svg new file mode 100644 index 00000000..c302fa74 --- /dev/null +++ b/addon/icons/line.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/icons/link.svg b/addon/icons/link.svg similarity index 100% rename from icons/link.svg rename to addon/icons/link.svg diff --git a/addon/icons/lock.js b/addon/icons/lock.js new file mode 100644 index 00000000..666c9c2d --- /dev/null +++ b/addon/icons/lock.js @@ -0,0 +1,1266 @@ +(function (t, n) { + t.__SVGATOR_PLAYER__ = n() +})(this, (function () { + "use strict"; + + function t(n) { + return (t = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) { + return typeof t + } : function (t) { + return t && "function" == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype ? "symbol" : typeof t + })(n) + } + + function n(t, n) { + if (!(t instanceof n)) throw new TypeError("Cannot call a class as a function") + } + + function r(t, n) { + for (var r = 0; r < n.length; r++) { + var e = n[r]; + e.enumerable = e.enumerable || !1, e.configurable = !0, "value" in e && (e.writable = !0), Object.defineProperty(t, e.key, e) + } + } + + function e(t, n, e) { + return n && r(t.prototype, n), e && r(t, e), t + } + + function i(t) { + return (i = Object.setPrototypeOf ? Object.getPrototypeOf : function (t) { + return t.__proto__ || Object.getPrototypeOf(t) + })(t) + } + + function o(t, n) { + return (o = Object.setPrototypeOf || function (t, n) { + return t.__proto__ = n, t + })(t, n) + } + + function u(t, n) { + return !n || "object" != typeof n && "function" != typeof n ? function (t) { + if (void 0 === t) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + return t + }(t) : n + } + + function a(t) { + var n = function () { + if ("undefined" == typeof Reflect || !Reflect.construct) return !1; + if (Reflect.construct.sham) return !1; + if ("function" == typeof Proxy) return !0; + try { + return Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], (function () { + }))), !0 + } catch (t) { + return !1 + } + }(); + return function () { + var r, e = i(t); + if (n) { + var o = i(this).constructor; + r = Reflect.construct(e, arguments, o) + } + else r = e.apply(this, arguments); + return u(this, r) + } + } + + function l(t, n, r) { + return (l = "undefined" != typeof Reflect && Reflect.get ? Reflect.get : function (t, n, r) { + var e = function (t, n) { + for (; !Object.prototype.hasOwnProperty.call(t, n) && null !== (t = i(t));) ; + return t + }(t, n); + if (e) { + var o = Object.getOwnPropertyDescriptor(e, n); + return o.get ? o.get.call(r) : o.value + } + })(t, n, r || t) + } + + var f = Math.abs; + + function s(t) { + return t + } + + function c(t, n, r) { + var e = 1 - r; + return 3 * r * e * (t * e + n * r) + r * r * r + } + + function h() { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 0, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0, + r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 1, + e = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : 1; + return t < 0 || t > 1 || r < 0 || r > 1 ? null : f(t - n) <= 1e-5 && f(r - e) <= 1e-5 ? s : function (i) { + if (i <= 0) return t > 0 ? i * n / t : 0 === n && r > 0 ? i * e / r : 0; + if (i >= 1) return r < 1 ? 1 + (i - 1) * (e - 1) / (r - 1) : 1 === r && t < 1 ? 1 + (i - 1) * (n - 1) / (t - 1) : 1; + for (var o, u = 0, a = 1; u < a;) { + var l = c(t, r, o = (u + a) / 2); + if (f(i - l) < 1e-5) break; + l < i ? u = o : a = o + } + return c(n, e, o) + } + } + + function v() { + return 1 + } + + function y(t) { + return 1 === t ? 1 : 0 + } + + function d() { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 1, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0; + if (1 === t) { + if (0 === n) return y; + if (1 === n) return v + } + var r = 1 / t; + return function (t) { + return t >= 1 ? 1 : (t += n * r) - t % r + } + } + + function g(t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 2; + if (Number.isInteger(t)) return t; + var r = Math.pow(10, n); + return Math.round(t * r) / r + } + + var p = Math.PI / 180; + + function m(t, n, r) { + return t >= .5 ? r : n + } + + function b(t, n, r) { + return 0 === t || n === r ? n : t * (r - n) + n + } + + function w(t, n, r) { + var e = b(t, n, r); + return e <= 0 ? 0 : e + } + + function x(t, n, r) { + return 0 === t ? n : 1 === t ? r : { + x: b(t, n.x, r.x), + y: b(t, n.y, r.y) + } + } + + function k(t, n, r) { + var e = function (t, n, r) { + return Math.round(b(t, n, r)) + }(t, n, r); + return e <= 0 ? 0 : e >= 255 ? 255 : e + } + + function A(t, n, r) { + return 0 === t ? n : 1 === t ? r : { + r: k(t, n.r, r.r), + g: k(t, n.g, r.g), + b: k(t, n.b, r.b), + a: b(t, null == n.a ? 1 : n.a, null == r.a ? 1 : r.a) + } + } + + function _(t, n, r) { + if (0 === t) return n; + if (1 === t) return r; + var e = n.length; + if (e !== r.length) return m(t, n, r); + for (var i = [], o = 0; o < e; o++) i.push(A(t, n[o], r[o])); + return i + } + + function S(t, n) { + for (var r = [], e = 0; e < t; e++) r.push(n); + return r + } + + function O(t, n) { + if (--n <= 0) return t; + var r = (t = Object.assign([], t)).length; + do { + for (var e = 0; e < r; e++) t.push(t[e]) + } while (--n > 0); + return t + } + + var M, j = function () { + function t(r) { + n(this, t), this.list = r, this.length = r.length + } + + return e(t, [{ + key: "setAttribute", + value: function (t, n) { + for (var r = this.list, e = 0; e < this.length; e++) r[e].setAttribute(t, n) + } + }, { + key: "removeAttribute", + value: function (t) { + for (var n = this.list, r = 0; r < this.length; r++) n[r].removeAttribute(t) + } + }, { + key: "style", + value: function (t, n) { + for (var r = this.list, e = 0; e < this.length; e++) r[e].style[t] = n + } + }]), t + }(), + F = /-./g, + P = function (t, n) { + return n.toUpperCase() + }; + + function B(t) { + return "function" == typeof t ? t : m + } + + function I(t) { + return t ? "function" == typeof t ? t : Array.isArray(t) ? function (t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : s; + if (!Array.isArray(t)) return n; + switch (t.length) { + case 1: + return d(t[0]) || n; + case 2: + return d(t[0], t[1]) || n; + case 4: + return h(t[0], t[1], t[2], t[3]) || n + } + return n + }(t, null) : function (t, n) { + var r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : s; + switch (t) { + case "linear": + return s; + case "steps": + return d(n.steps || 1, n.jump || 0) || r; + case "bezier": + case "cubic-bezier": + return h(n.x1 || 0, n.y1 || 0, n.x2 || 0, n.y2 || 0) || r + } + return r + }(t.type, t.value, null) : null + } + + function R(t, n, r) { + var e = arguments.length > 3 && void 0 !== arguments[3] && arguments[3], + i = n.length - 1; + if (t <= n[0].t) return e ? [0, 0, n[0].v] : n[0].v; + if (t >= n[i].t) return e ? [i, 1, n[i].v] : n[i].v; + var o, u = n[0], + a = null; + for (o = 1; o <= i; o++) { + if (!(t > n[o].t)) { + a = n[o]; + break + } + u = n[o] + } + return null == a ? e ? [i, 1, n[i].v] : n[i].v : u.t === a.t ? e ? [o, 1, a.v] : a.v : (t = (t - u.t) / (a.t - u.t), u.e && (t = u.e(t)), + e ? [o, t, r(t, u.v, a.v)] + : r(t, u.v, a.v)) + } + + function q(t, n) { + var r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : null; + return t && t.length ? "function" != typeof n ? null : ("function" != typeof r && (r = null), function (e) { + var i = R(e, t, n); + return null != i && r && (i = r(i)), i + }) : null + } + + function E(t, n) { + return t.t - n.t + } + + function T(n, r, e, i, o) { + var u, a = "@" === e[0], + l = "#" === e[0], + f = M[e], + s = m; + switch (a ? (u = e.substr(1), e = u.replace(F, P)) : l && (e = e.substr(1)), t(f)) { + case "function": + if (s = f(i, o, R, I, e, a, r, n), l) return s; + break; + case "string": + s = q(i, B(f)); + break; + case "object": + if ((s = q(i, B(f.i), f.f)) && "function" == typeof f.u) return f.u(r, s, e, a, n) + } + return s ? function (t, n, r) { + var e = arguments.length > 3 && void 0 !== arguments[3] && arguments[3]; + if (e) return t instanceof j ? function (e) { + return t.style(n, r(e)) + } : function (e) { + return t.style[n] = r(e) + }; + if (Array.isArray(n)) { + var i = n.length; + return function (e) { + var o = r(e); + if (null == o) + for (var u = 0; u < i; u++) t[u].removeAttribute(n); + else + for (var a = 0; a < i; a++) t[a].setAttribute(n, o) + } + } + return function (e) { + var i = r(e); + null == i ? t.removeAttribute(n) : t.setAttribute(n, i) + } + }(r, e, s, a) : null + } + + function N(n, r, e, i) { + if (!i || "object" !== t(i)) return null; + var o = null, + u = null; + return Array.isArray(i) ? u = function (t) { + if (!t || !t.length) return null; + for (var n = 0; n < t.length; n++) t[n].e && (t[n].e = I(t[n].e)); + return t.sort(E) + }(i) : (u = i.keys, o = i.data || null), u ? T(n, r, e, u, o) : null + } + + function C(t, n, r) { + if (!r) return null; + var e = []; + for (var i in r) + if (r.hasOwnProperty(i)) { + var o = N(t, n, i, r[i]); + o && e.push(o) + } + return e.length ? e : null + } + + function z(t, n) { + if (!n.duration || n.duration < 0) return null; + var r = function (t, n) { + if (!n) return null; + var r = []; + if (Array.isArray(n)) + for (var e = n.length, i = 0; i < e; i++) { + var o = n[i]; + if (2 === o.length) { + var u = null; + if ("string" == typeof o[0]) u = t.getElementById(o[0]); + else if (Array.isArray(o[0])) { + u = []; + for (var a = 0; a < o[0].length; a++) + if ("string" == typeof o[0][a]) { + var l = t.getElementById(o[0][a]); + l && u.push(l) + } + u = u.length ? 1 === u.length ? u[0] : new j(u) : null + } + if (u) { + var f = C(t, u, o[1]); + f && (r = r.concat(f)) + } + } + } else + for (var s in n) + if (n.hasOwnProperty(s)) { + var c = t.getElementById(s); + if (c) { + var h = C(t, c, n[s]); + h && (r = r.concat(h)) + } + } + return r.length ? r : null + }(t, n.elements); + return r ? function (t, n) { + var r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 1 / 0, + e = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : 1, + i = arguments.length > 4 && void 0 !== arguments[4] && arguments[4], + o = arguments.length > 5 && void 0 !== arguments[5] ? arguments[5] : 1, + u = t.length, + a = e > 0 ? n : 0; + i && r % 2 == 0 && (a = n - a); + var l = null; + return function (f, s) { + var c = f % n, + h = 1 + (f - c) / n; + s *= e, i && h % 2 == 0 && (s = -s); + var v = !1; + if (h > r) c = a, v = !0, -1 === o && (c = e > 0 ? 0 : n); + else if (s < 0 && (c = n - c), c === l) return !1; + l = c; + for (var y = 0; y < u; y++) t[y](c); + return v + } + }(r, n.duration, n.iterations || 1 / 0, n.direction || 1, !!n.alternate, n.fill || 1) : null + } + + var L = function () { + function t(r, e) { + var i = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; + n(this, t), this._id = 0, this._running = !1, this._rollingBack = !1, this._animations = r, + this.duration = e.duration, this.alternate = e.alternate, this.fill = e.fill, this.iterations = e.iterations, + this.direction = i.direction || 1, this.speed = i.speed || 1, this.fps = i.fps || 100, this.offset = i.offset || 0, + this.rollbackStartOffset = 0 + } + + return e(t, [{ + key: "_rollback", + value: function () { + var t = this, + n = 1 / 0, + r = null; + this.rollbackStartOffset = this.offset, this._rollingBack || (this._rollingBack = !0, this._running = !0); + this._id = window.requestAnimationFrame((function e(i) { + if (t._rollingBack) { + null == r && (r = i); + var o = i - r, + u = t.rollbackStartOffset - o, + a = Math.round(u * t.speed); + if (a > t.duration && n != 1 / 0) { + var l = !!t.alternate && a / t.duration % 2 > 1, + f = a % t.duration; + a = (f += l ? t.duration : 0) || t.duration + } + var s = t.fps ? 1e3 / t.fps : 0, + c = Math.max(0, a); + if (c < n - s) { + t.offset = c, n = c; + for (var h = t._animations, v = h.length, y = 0; y < v; y++) h[y](c, t.direction) + } + var d = !1; + if (t.iterations > 0 && -1 === t.fill) { + var g = t.iterations * t.duration, + p = g == a; + a = p ? 0 : a, t.offset = p ? 0 : t.offset, d = a > g + } + a > 0 && t.offset >= a && !d ? t._id = window.requestAnimationFrame(e) : t.stop() + } + })) + } + }, { + key: "_start", + value: function () { + var t = this, + n = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 0, + r = -1 / 0, + e = null, + i = {}, + o = function o(u) { + t._running = !0, null == e && (e = u); + var a = Math.round((u - e + n) * t.speed), + l = t.fps ? 1e3 / t.fps : 0; + if (a > r + l && !t._rollingBack) { + t.offset = a, r = a; + for (var f = t._animations, s = f.length, c = 0, h = 0; h < s; h++) i[h] ? c++ : (i[h] = f[h](a, t.direction), i[h] && c++); + if (c === s) return void t._stop() + } + t._id = window.requestAnimationFrame(o) + }; + this._id = window.requestAnimationFrame(o) + } + }, { + key: "_stop", + value: function () { + this._id && window.cancelAnimationFrame(this._id), this._running = !1, this._rollingBack = !1 + } + }, { + key: "play", + value: function () { + !this._rollingBack && this._running || (this._rollingBack = !1, this.rollbackStartOffset > this.duration + && (this.offset = this.rollbackStartOffset - (this.rollbackStartOffset - this.offset) % this.duration, this.rollbackStartOffset = 0), + this._start(this.offset)) + } + }, { + key: "stop", + value: function () { + this._stop(), this.offset = 0, this.rollbackStartOffset = 0; + var t = this.direction, + n = this._animations; + window.requestAnimationFrame((function () { + for (var r = 0; r < n.length; r++) n[r](0, t) + })) + } + }, { + key: "reachedToEnd", + value: function () { + return this.iterations > 0 && this.offset >= this.iterations * this.duration + } + }, { + key: "restart", + value: function () { + this._stop(), this.offset = 0, this._start() + } + }, { + key: "pause", + value: function () { + this._stop() + } + }, { + key: "reverse", + value: function () { + this.direction = -this.direction + } + }], [{ + key: "build", + value: function (n, r) { + return (n = function (t, n) { + if (M = n, !t || !t.root || !Array.isArray(t.animations)) return null; + for (var r = document.getElementsByTagName("svg"), e = !1, i = 0; i < r.length; i++) + if (r[i].id === t.root && !r[i].svgatorAnimation) { + (e = r[i]).svgatorAnimation = !0; + break + } + if (!e) return null; + var o = t.animations.map((function (t) { + return z(e, t) + })).filter((function (t) { + return !!t + })); + return o.length ? { + element: e, + animations: o, + animationSettings: t.animationSettings, + options: t.options || void 0 + } : null + }(n, r)) ? { + el: n.element, + options: n.options || {}, + player: new t(n.animations, n.animationSettings, n.options) + } : null + } + }]), t + }(); + !function () { + for (var t = 0, n = ["ms", "moz", "webkit", "o"], r = 0; r < n.length && !window.requestAnimationFrame; ++r) + window.requestAnimationFrame = window[n[r] + "RequestAnimationFrame"], window.cancelAnimationFrame = window[n[r] + + "CancelAnimationFrame"] || window[n[r] + "CancelRequestAnimationFrame"]; + window.requestAnimationFrame || (window.requestAnimationFrame = function (n) { + var r = Date.now(), + e = Math.max(0, 16 - (r - t)), + i = window.setTimeout((function () { + n(r + e) + }), e); + return t = r + e, i + }, window.cancelAnimationFrame = window.clearTimeout) + }(); + var D = /\.0+$/g; + + function Q(t) { + return Number.isInteger(t) ? t + "" : t.toFixed(6).replace(D, "") + } + + function U(t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : " "; + return t && t.length ? t.map(Q).join(n) : "" + } + + function V(t) { + return t ? null == t.a || t.a >= 1 ? "rgb(" + t.r + "," + t.g + "," + t.b + ")" : "rgba(" + t.r + "," + t.g + "," + t.b + "," + t.a + ")" + : "transparent" + } + + var $ = { + f: null, + i: function (t, n, r) { + return 0 === t ? n : 1 === t ? r : { + x: w(t, n.x, r.x), + y: w(t, n.y, r.y) + } + }, + u: function (t, n) { + return function (r) { + var e = n(r); + t.setAttribute("rx", Q(e.x)), t.setAttribute("ry", Q(e.y)) + } + } + }, + G = { + f: null, + i: function (t, n, r) { + return 0 === t ? n : 1 === t ? r : { + width: w(t, n.width, r.width), + height: w(t, n.height, r.height) + } + }, + u: function (t, n) { + return function (r) { + var e = n(r); + t.setAttribute("width", Q(e.width)), t.setAttribute("height", Q(e.height)) + } + } + }, + H = Math.sin, + Y = Math.cos, + Z = Math.acos, + J = Math.asin, + K = Math.tan, + W = Math.atan2, + X = Math.PI / 180, + tt = 180 / Math.PI, + nt = Math.sqrt, + rt = function () { + function t() { + var r = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 1, + e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0, + i = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 0, + o = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : 1, + u = arguments.length > 4 && void 0 !== arguments[4] ? arguments[4] : 0, + a = arguments.length > 5 && void 0 !== arguments[5] ? arguments[5] : 0; + n(this, t), this.m = [r, e, i, o, u, a], this.i = null, this.w = null, this.s = null + } + + return e(t, [{ + key: "determinant", + get: function () { + var t = this.m; + return t[0] * t[3] - t[1] * t[2] + } + }, { + key: "isIdentity", + get: function () { + if (null === this.i) { + var t = this.m; + this.i = 1 === t[0] && 0 === t[1] && 0 === t[2] && 1 === t[3] && 0 === t[4] && 0 === t[5] + } + return this.i + } + }, { + key: "point", + value: function (t, n) { + var r = this.m; + return { + x: r[0] * t + r[2] * n + r[4], + y: r[1] * t + r[3] * n + r[5] + } + } + }, { + key: "translateSelf", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 0, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0; + if (!t && !n) return this; + var r = this.m; + return r[4] += r[0] * t + r[2] * n, r[5] += r[1] * t + r[3] * n, this.w = this.s = this.i = null, this + } + }, { + key: "rotateSelf", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 0; + if (t %= 360) { + var n = H(t *= X), + r = Y(t), + e = this.m, + i = e[0], + o = e[1]; + e[0] = i * r + e[2] * n, e[1] = o * r + e[3] * n, e[2] = e[2] * r - i * n, e[3] = e[3] * r - o * n, + this.w = this.s = this.i = null + } + return this + } + }, { + key: "scaleSelf", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 1, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 1; + if (1 !== t || 1 !== n) { + var r = this.m; + r[0] *= t, r[1] *= t, r[2] *= n, r[3] *= n, this.w = this.s = this.i = null + } + return this + } + }, { + key: "skewSelf", + value: function (t, n) { + if (n %= 360, (t %= 360) || n) { + var r = this.m, + e = r[0], + i = r[1], + o = r[2], + u = r[3]; + t && (t = K(t * X), r[2] += e * t, r[3] += i * t), n && (n = K(n * X), r[0] += o * n, r[1] += u * n), + this.w = this.s = this.i = null + } + return this + } + }, { + key: "resetSelf", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 1, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0, + r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 0, + e = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : 1, + i = arguments.length > 4 && void 0 !== arguments[4] ? arguments[4] : 0, + o = arguments.length > 5 && void 0 !== arguments[5] ? arguments[5] : 0, + u = this.m; + return u[0] = t, u[1] = n, u[2] = r, u[3] = e, u[4] = i, u[5] = o, this.w = this.s = this.i = null, this + } + }, { + key: "recomposeSelf", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : null, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : null, + r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : null, + e = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : null, + i = arguments.length > 4 && void 0 !== arguments[4] ? arguments[4] : null; + return this.isIdentity || this.resetSelf(), t && (t.x || t.y) && this.translateSelf(t.x, t.y), n + && this.rotateSelf(n), r && (r.x && this.skewSelf(r.x, 0), r.y && this.skewSelf(0, r.y)), !e || 1 === e.x && 1 === e.y + || this.scaleSelf(e.x, e.y), i && (i.x || i.y) && this.translateSelf(i.x, i.y), this + } + }, { + key: "decompose", + value: function () { + var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 0, + n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0, + r = this.m, + e = r[0] * r[0] + r[1] * r[1], + i = [ + [r[0], r[1]], + [r[2], r[3]] + ], + o = nt(e); + if (0 === o) return { + origin: { + x: r[4], + y: r[5] + }, + translate: { + x: t, + y: n + }, + scale: { + x: 0, + y: 0 + }, + skew: { + x: 0, + y: 0 + }, + rotate: 0 + }; + i[0][0] /= o, i[0][1] /= o; + var u = r[0] * r[3] - r[1] * r[2] < 0; + u && (o = -o); + var a = i[0][0] * i[1][0] + i[0][1] * i[1][1]; + i[1][0] -= i[0][0] * a, i[1][1] -= i[0][1] * a; + var l = nt(i[1][0] * i[1][0] + i[1][1] * i[1][1]); + if (0 === l) return { + origin: { + x: r[4], + y: r[5] + }, + translate: { + x: t, + y: n + }, + scale: { + x: o, + y: 0 + }, + skew: { + x: 0, + y: 0 + }, + rotate: 0 + }; + i[1][0] /= l, i[1][1] /= l, a /= l; + var f = 0; + return i[1][1] < 0 ? (f = Z(i[1][1]) * tt, i[0][1] < 0 && (f = 360 - f)) : f = J(i[0][1]) * tt, u && (f = -f), + a = W(a, nt(i[0][0] * i[0][0] + i[0][1] * i[0][1])) * tt, u && (a = -a), { + origin: { + x: r[4], + y: r[5] + }, + translate: { + x: t, + y: n + }, + scale: { + x: o, + y: l + }, + skew: { + x: a, + y: 0 + }, + rotate: f + } + } + }, { + key: "toString", + value: function () { + return null === this.s && (this.s = "matrix(" + this.m.map(it).join(" ") + ")"), this.s + } + }]), t + }(), + et = /\.0+$/; + + function it(t) { + return Number.isInteger(t) ? t : t.toFixed(14).replace(et, "") + } + + function ot(t, n, r) { + return t + (n - t) * r + } + + function ut(t, n, r) { + var e = arguments.length > 3 && void 0 !== arguments[3] && arguments[3], + i = { + x: ot(t.x, n.x, r), + y: ot(t.y, n.y, r) + }; + return e && (i.a = at(t, n)), i + } + + function at(t, n) { + return Math.atan2(n.y - t.y, n.x - t.x) + } + + Object.freeze({ + M: 2, + L: 2, + Z: 0, + H: 1, + V: 1, + C: 6, + Q: 4, + T: 2, + S: 4, + A: 7 + }); + var lt = {}, + ft = null; + + function st(n) { + var r = function () { + if (ft) return ft; + if ("object" !== ("undefined" == typeof document ? "undefined" : t(document))) return {}; + var n = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + return n.style.position = "absolute", n.style.opacity = "0.01", n.style.zIndex = "-9999", n.style.left = "-9999px", + n.style.width = "1px", n.style.height = "1px", ft = { + svg: n + } + }().svg; + if (!r) return function (t) { + return null + }; + var e = document.createElementNS(r.namespaceURI, "path"); + e.setAttributeNS(null, "d", n), e.setAttributeNS(null, "fill", "none"), + e.setAttributeNS(null, "stroke", "none"), r.appendChild(e); + var i = e.getTotalLength(); + return function (t) { + var n = e.getPointAtLength(i * t); + return { + x: n.x, + y: n.y + } + } + } + + function ct(t) { + return lt[t] ? lt[t] : lt[t] = st(t) + } + + function ht(t, n, r, e) { + if (!t || !e) return !1; + var i = ["M", t.x, t.y]; + if (n && r && (i.push("C"), i.push(n.x), i.push(n.y), i.push(r.x), i.push(r.y)), n ? !r : r) { + var o = n || r; + i.push("Q"), i.push(o.x), i.push(o.y) + } + return n || r || i.push("L"), i.push(e.x), i.push(e.y), i.join(" ") + } + + function vt(t, n, r, e) { + var i = arguments.length > 4 && void 0 !== arguments[4] ? arguments[4] : 1, + o = ht(t, n, r, e), + u = ct(o); + try { + return u(i) + } catch (t) { + return null + } + } + + function yt(t, n, r, e) { + var i = 1 - e; + return i * i * t + 2 * i * e * n + e * e * r + } + + function dt(t, n, r, e) { + return 2 * (1 - e) * (n - t) + 2 * e * (r - n) + } + + function gt(t, n, r, e) { + var i = arguments.length > 4 && void 0 !== arguments[4] && arguments[4], + o = vt(t, n, null, r, e); + return o || (o = { + x: yt(t.x, n.x, r.x, e), + y: yt(t.y, n.y, r.y, e) + }), i && (o.a = pt(t, n, r, e)), o + } + + function pt(t, n, r, e) { + return Math.atan2(dt(t.y, n.y, r.y, e), dt(t.x, n.x, r.x, e)) + } + + function mt(t, n, r, e, i) { + var o = i * i; + return i * o * (e - t + 3 * (n - r)) + 3 * o * (t + r - 2 * n) + 3 * i * (n - t) + t + } + + function bt(t, n, r, e, i) { + var o = 1 - i; + return 3 * (o * o * (n - t) + 2 * o * i * (r - n) + i * i * (e - r)) + } + + function wt(t, n, r, e, i) { + var o = arguments.length > 5 && void 0 !== arguments[5] && arguments[5], + u = vt(t, n, r, e, i); + return u || (u = { + x: mt(t.x, n.x, r.x, e.x, i), + y: mt(t.y, n.y, r.y, e.y, i) + }), o && (u.a = xt(t, n, r, e, i)), u + } + + function xt(t, n, r, e, i) { + return Math.atan2(bt(t.y, n.y, r.y, e.y, i), bt(t.x, n.x, r.x, e.x, i)) + } + + function kt(t, n, r) { + var e = arguments.length > 3 && void 0 !== arguments[3] && arguments[3]; + if (_t(n)) { + if (St(r)) return gt(n, r.start, r, t, e) + } + else if (_t(r)) { + if (n.end) return gt(n, n.end, r, t, e) + } + else { + if (n.end) return r.start ? wt(n, n.end, r.start, r, t, e) : gt(n, n.end, r, t, e); + if (r.start) return gt(n, r.start, r, t, e) + } + return ut(n, r, t, e) + } + + function At(t, n, r) { + var e = kt(t, n, r, !0); + return e.a = function (t) { + var n = arguments.length > 1 && void 0 !== arguments[1] && arguments[1]; + return n ? t + Math.PI : t + }(e.a) / p, e + } + + function _t(t) { + return !t.type || "corner" === t.type + } + + function St(t) { + return null != t.start && !_t(t) + } + + var Ot = new rt; + var Mt = { + f: Q, + i: b + }, + jt = { + f: Q, + i: function (t, n, r) { + var e = b(t, n, r); + return e <= 0 ? 0 : e >= 1 ? 1 : e + } + }; + + function Ft(t, n, r, e, i, o, u, a) { + return n = function (t, n, r) { + for (var e, i, o, u = t.length - 1, a = {}, l = 0; l <= u; l++) (e = t[l]).e && (e.e = n(e.e)), e.v && "g" === (i = e.v).t && i.r + && (o = r.getElementById(i.r)) && (a[i.r] = o.querySelectorAll("stop")); + return a + }(t, e, a), + function (e) { + var i, o = r(e, t, Pt); + return o ? "c" === o.t ? V(o.v) : "g" === o.t ? (n[o.r] && function (t, n) { + for (var r = 0, e = t.length; r < e; r++) t[r].setAttribute("stop-color", V(n[r])) + }(n[o.r], o.v), (i = o.r) ? "url(#" + i + ")" : "none") : "none" : "none" + } + } + + function Pt(t, n, r) { + if (0 === t) return n; + if (1 === t) return r; + if (n && r) { + var e = n.t; + if (e === r.t) switch (n.t) { + case "c": + return { + t: e, + v: A(t, n.v, r.v) + }; + case "g": + if (n.r === r.r) return { + t: e, + v: _(t, n.v, r.v), + r: n.r + } + } + } + return m(t, n, r) + } + + var Bt = { + fill: Ft, + "fill-opacity": jt, + stroke: Ft, + "stroke-opacity": jt, + "stroke-width": Mt, + "stroke-dashoffset": { + f: Q, + i: b + }, + "stroke-dasharray": { + f: function (t) { + var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : " "; + return t && t.length > 0 && (t = t.map((function (t) { + return Math.floor(1e4 * t) / 1e4 + }))), U(t, n) + }, + i: function (t, n, r) { + var e, i, o, u = n.length, + a = r.length; + if (u !== a) + if (0 === u) n = S(u = a, 0); + else if (0 === a) a = u, r = S(u, 0); + else { + var l = (o = (e = u) * (i = a) / function (t, n) { + for (var r; n;) r = n, n = t % n, t = r; + return t || 1 + }(e, i)) < 0 ? -o : o; + n = O(n, Math.floor(l / u)), r = O(r, Math.floor(l / a)), u = a = l + } + for (var f = [], s = 0; s < u; s++) f.push(g(w(t, n[s], r[s]), 6)); + return f + } + }, + opacity: jt, + transform: function (n, r, e, i) { + if (!(n = function (n, r) { + if (!n || "object" !== t(n)) return null; + var e = !1; + for (var i in n) n.hasOwnProperty(i) && (n[i] && n[i].length ? (n[i].forEach((function (t) { + t.e && (t.e = r(t.e)) + })), e = !0) : delete n[i]); + return e ? n : null + }(n, i))) return null; + var o = function (t, i, o) { + var u = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : null; + return n[t] ? e(i, n[t], o) : r && r[t] ? r[t] : u + }; + return r && r.a && n.o ? function (t) { + var r = e(t, n.o, At); + return Ot.recomposeSelf(r, o("r", t, b, 0) + r.a, o("k", t, x), o("s", t, x), o("t", t, x)).toString() + } : function (t) { + return Ot.recomposeSelf(o("o", t, kt, null), o("r", t, b, 0), o("k", t, x), o("s", t, x), o("t", t, x)).toString() + } + }, + r: Mt, + "#size": G, + "#radius": $, + _: function (t, n) { + if (Array.isArray(t)) + for (var r = 0; r < t.length; r++) this[t[r]] = n; + else this[t] = n + } + }; + return function (t) { + !function (t, n) { + if ("function" != typeof n && null !== n) throw new TypeError("Super expression must either be null or a function"); + t.prototype = Object.create(n && n.prototype, { + constructor: { + value: t, + writable: !0, + configurable: !0 + } + }), n && o(t, n) + }(u, t); + var r = a(u); + + function u() { + return n(this, u), r.apply(this, arguments) + } + + return e(u, null, [{ + key: "build", + value: function (t) { + var n = l(i(u), "build", this).call(this, t, Bt); + n.el, n.options; + var r = n.player; + return function (t, n, r) { + t.play() + }(r), r + } + }]), u + }(L) +})); + +var animation = { + "root": "eT9VLHb5slv1", + "animations": [{ + "duration": 5000, + "direction": 1, + "iterations": 0, + "fill": 1, + "alternate": false, + "speed": 1, + "elements": { + "eT9VLHb5slv8": { + "opacity": [{ + "t": 0, + "v": 1 + }, { + "t": 1000, + "v": 0.3 + }, { + "t": 2000, + "v": 1 + }, { + "t": 3000, + "v": 0.3 + }, { + "t": 4000, + "v": 1 + }] + }, + "eT9VLHb5slv9": { + "opacity": [{ + "t": 500, + "v": 1 + }, { + "t": 1500, + "v": 0.3 + }, { + "t": 2500, + "v": 1 + }, { + "t": 3500, + "v": 0.3 + }, { + "t": 4500, + "v": 1 + }] + }, + "eT9VLHb5slv10": { + "opacity": [{ + "t": 1100, + "v": 1 + }, { + "t": 2100, + "v": 0.3 + }, { + "t": 3100, + "v": 1 + }, { + "t": 4100, + "v": 0.3 + }, { + "t": 5000, + "v": 1 + }] + }, + "eT9VLHb5slv14": { + "transform": { + "data": { + "t": { + "x": -1178.445898290934, + "y": -962.2699999951578 + } + }, + "keys": { + "o": [{ + "t": 1500, + "v": { + "x": 990.6801366500003, + "y": 1335.8325074900001, + "type": "cusp", + "start": { + "x": 1018.2508121561755, + "y": 1334.957403885074 + }, + "end": { + "x": 1088.1251614916594, + "y": 1053.313533141961 + } + } + }, { + "t": 5000, + "v": { + "x": 169.744174, + "y": 133.288452, + "type": "cusp", + "start": { + "x": 990.6590500556304, + "y": 195.34145411460256 + } + } + }], + "s": [{ + "t": 1500, + "v": { + "x": 0.5, + "y": 0.5 + } + }, { + "t": 5000, + "v": { + "x": 1, + "y": 1 + } + }] + } + } + } + } + }], + "options": { + "start": "load", + "hover": "freeze", + "click": "freeze", + "scroll": 25, + "font": "embed", + "exportedIds": "unique", + "svgFormat": "animated", + "title": "lock" + }, + "animationSettings": { + "duration": 5000, + "direction": 1, + "iterations": 0, + "fill": 1, + "alternate": false, + "speed": 1 + } +}; + +var clip = document.querySelector('#eT9VLHb5slv4'); +clip.addEventListener('mouseenter', startAnimation); +clip.addEventListener('mousemove', startAnimation); + +var animated = false; +function startAnimation() { + if (animated) + return; + animated = true; + __SVGATOR_PLAYER__.build(animation) +} diff --git a/addon/icons/lock.svg b/addon/icons/lock.svg new file mode 100644 index 00000000..34eddb5e --- /dev/null +++ b/addon/icons/lock.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addon/icons/logo128.png b/addon/icons/logo128.png new file mode 100644 index 00000000..d2c96627 Binary files /dev/null and b/addon/icons/logo128.png differ diff --git a/addon/icons/logo16.png b/addon/icons/logo16.png new file mode 100644 index 00000000..7077aca8 Binary files /dev/null and b/addon/icons/logo16.png differ diff --git a/addon/icons/logo24.png b/addon/icons/logo24.png new file mode 100644 index 00000000..22685743 Binary files /dev/null and b/addon/icons/logo24.png differ diff --git a/addon/icons/logo32.png b/addon/icons/logo32.png new file mode 100644 index 00000000..b67fa1f9 Binary files /dev/null and b/addon/icons/logo32.png differ diff --git a/addon/icons/logo64.png b/addon/icons/logo64.png new file mode 100644 index 00000000..02c5b154 Binary files /dev/null and b/addon/icons/logo64.png differ diff --git a/addon/icons/logo96.png b/addon/icons/logo96.png new file mode 100644 index 00000000..59680a89 Binary files /dev/null and b/addon/icons/logo96.png differ diff --git a/addon/icons/menu.svg b/addon/icons/menu.svg new file mode 100644 index 00000000..feb517ed --- /dev/null +++ b/addon/icons/menu.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addon/icons/mode-page.svg b/addon/icons/mode-page.svg new file mode 100644 index 00000000..0da0a3ae --- /dev/null +++ b/addon/icons/mode-page.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/addon/icons/mode-site.svg b/addon/icons/mode-site.svg new file mode 100644 index 00000000..da176e86 --- /dev/null +++ b/addon/icons/mode-site.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/addon/icons/new-group.svg b/addon/icons/new-group.svg new file mode 100644 index 00000000..9c319453 --- /dev/null +++ b/addon/icons/new-group.svg @@ -0,0 +1,98 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/addon/icons/new-group2.svg b/addon/icons/new-group2.svg new file mode 100644 index 00000000..8d6f11a9 --- /dev/null +++ b/addon/icons/new-group2.svg @@ -0,0 +1,100 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/addon/icons/new-shelf.svg b/addon/icons/new-shelf.svg new file mode 100644 index 00000000..7c3828da --- /dev/null +++ b/addon/icons/new-shelf.svg @@ -0,0 +1,118 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/new-shelf2.svg b/addon/icons/new-shelf2.svg new file mode 100644 index 00000000..98808000 --- /dev/null +++ b/addon/icons/new-shelf2.svg @@ -0,0 +1,118 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/notes.svg b/addon/icons/notes.svg new file mode 100644 index 00000000..e7d88390 --- /dev/null +++ b/addon/icons/notes.svg @@ -0,0 +1,65 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addon/icons/notes2.svg b/addon/icons/notes2.svg new file mode 100644 index 00000000..4e9a37a1 --- /dev/null +++ b/addon/icons/notes2.svg @@ -0,0 +1,65 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addon/icons/onedrive.png b/addon/icons/onedrive.png new file mode 100644 index 00000000..857442ea Binary files /dev/null and b/addon/icons/onedrive.png differ diff --git a/addon/icons/open-link-this-tab.svg b/addon/icons/open-link-this-tab.svg new file mode 100644 index 00000000..0c13801f --- /dev/null +++ b/addon/icons/open-link-this-tab.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/addon/icons/open-link.svg b/addon/icons/open-link.svg new file mode 100644 index 00000000..8c30c490 --- /dev/null +++ b/addon/icons/open-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/icons/page-blank.svg b/addon/icons/page-blank.svg new file mode 100644 index 00000000..0e0b35b6 --- /dev/null +++ b/addon/icons/page-blank.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addon/icons/page-blank2.svg b/addon/icons/page-blank2.svg new file mode 100644 index 00000000..bcca2f0b --- /dev/null +++ b/addon/icons/page-blank2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/page-info.svg b/addon/icons/page-info.svg new file mode 100644 index 00000000..30b646bb --- /dev/null +++ b/addon/icons/page-info.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/addon/icons/page.svg b/addon/icons/page.svg new file mode 100644 index 00000000..d5afffbd Binary files /dev/null and b/addon/icons/page.svg differ diff --git a/addon/icons/page2.svg b/addon/icons/page2.svg new file mode 100644 index 00000000..b8cb70fb Binary files /dev/null and b/addon/icons/page2.svg differ diff --git a/addon/icons/patreon.png b/addon/icons/patreon.png new file mode 100644 index 00000000..5c2af68c Binary files /dev/null and b/addon/icons/patreon.png differ diff --git a/addon/icons/pocket.svg b/addon/icons/pocket.svg new file mode 100644 index 00000000..60425e8b --- /dev/null +++ b/addon/icons/pocket.svg @@ -0,0 +1,72 @@ + +image/svg+xml \ No newline at end of file diff --git a/addon/icons/postponed.svg b/addon/icons/postponed.svg new file mode 100644 index 00000000..dcbdaac3 --- /dev/null +++ b/addon/icons/postponed.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/properties.svg b/addon/icons/properties.svg new file mode 100644 index 00000000..42ec9067 --- /dev/null +++ b/addon/icons/properties.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/properties2.svg b/addon/icons/properties2.svg new file mode 100644 index 00000000..78f1131e --- /dev/null +++ b/addon/icons/properties2.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/reload.svg b/addon/icons/reload.svg similarity index 94% rename from icons/reload.svg rename to addon/icons/reload.svg index f218227f..13bb16e4 100644 --- a/icons/reload.svg +++ b/addon/icons/reload.svg @@ -13,7 +13,7 @@ version="1.1" id="svg6" sodipodi:docname="reload.svg" - inkscape:version="0.92.2 2405546, 2018-03-11"> + inkscape:version="0.92.4 5da689c313, 2019-01-14"> @@ -44,7 +44,7 @@ id="namedview8" showgrid="false" inkscape:zoom="22.627417" - inkscape:cx="-7.7743024" + inkscape:cx="-17.563312" inkscape:cy="13.364672" inkscape:window-x="1920" inkscape:window-y="0" @@ -85,6 +85,6 @@ diff --git a/addon/icons/scrapyard-star.png b/addon/icons/scrapyard-star.png new file mode 100644 index 00000000..cc987522 Binary files /dev/null and b/addon/icons/scrapyard-star.png differ diff --git a/addon/icons/scrapyard-star.svg b/addon/icons/scrapyard-star.svg new file mode 100644 index 00000000..ffe00b47 --- /dev/null +++ b/addon/icons/scrapyard-star.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/scrapyard.svg b/addon/icons/scrapyard.svg new file mode 100644 index 00000000..cb1cdc4d --- /dev/null +++ b/addon/icons/scrapyard.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/addon/icons/scrapyard_mg.svg b/addon/icons/scrapyard_mg.svg new file mode 100644 index 00000000..386da0cd --- /dev/null +++ b/addon/icons/scrapyard_mg.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/addon/icons/search.svg b/addon/icons/search.svg new file mode 100644 index 00000000..578bd2b1 --- /dev/null +++ b/addon/icons/search.svg @@ -0,0 +1,72 @@ + + + +image/svg+xml + + + \ No newline at end of file diff --git a/icons/selection.svg b/addon/icons/selection.svg similarity index 100% rename from icons/selection.svg rename to addon/icons/selection.svg diff --git a/addon/icons/separator.svg b/addon/icons/separator.svg new file mode 100644 index 00000000..0d646d2c --- /dev/null +++ b/addon/icons/separator.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/separator2.svg b/addon/icons/separator2.svg new file mode 100644 index 00000000..ba20164a --- /dev/null +++ b/addon/icons/separator2.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/icons/settings.svg b/addon/icons/settings.svg similarity index 91% rename from icons/settings.svg rename to addon/icons/settings.svg index 321c3d74..fee2ff35 100644 --- a/icons/settings.svg +++ b/addon/icons/settings.svg @@ -19,7 +19,7 @@ xml:space="preserve" id="svg6" sodipodi:docname="settings.svg" - inkscape:version="0.92.2 2405546, 2018-03-11">image/svg+xml + style="opacity:1;fill:#000000;fill-opacity:1"> + style="fill:#000000;fill-opacity:1"> diff --git a/addon/icons/shelf.svg b/addon/icons/shelf.svg new file mode 100644 index 00000000..0322d89a --- /dev/null +++ b/addon/icons/shelf.svg @@ -0,0 +1,95 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/shelf2.svg b/addon/icons/shelf2.svg new file mode 100644 index 00000000..9efbd012 --- /dev/null +++ b/addon/icons/shelf2.svg @@ -0,0 +1,95 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/sidebar.svg b/addon/icons/sidebar.svg new file mode 100644 index 00000000..57e773cd --- /dev/null +++ b/addon/icons/sidebar.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/addon/icons/star.svg b/addon/icons/star.svg new file mode 100644 index 00000000..756594a6 --- /dev/null +++ b/addon/icons/star.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/sync.svg b/addon/icons/sync.svg new file mode 100644 index 00000000..f17cd394 --- /dev/null +++ b/addon/icons/sync.svg @@ -0,0 +1,97 @@ + + + + + + image/svg+xml + + + reload + + + + + + + + + + + + + + reload + + + + diff --git a/addon/icons/tags.svg b/addon/icons/tags.svg new file mode 100644 index 00000000..20c9f6f2 --- /dev/null +++ b/addon/icons/tags.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/addon/icons/tape.svg b/addon/icons/tape.svg new file mode 100644 index 00000000..210dc31c --- /dev/null +++ b/addon/icons/tape.svg @@ -0,0 +1,96 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addon/icons/tape2.svg b/addon/icons/tape2.svg new file mode 100644 index 00000000..9de122c4 --- /dev/null +++ b/addon/icons/tape2.svg @@ -0,0 +1,140 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addon/icons/text.svg b/addon/icons/text.svg new file mode 100644 index 00000000..64ba9537 --- /dev/null +++ b/addon/icons/text.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/addon/icons/toc.svg b/addon/icons/toc.svg new file mode 100644 index 00000000..d9246f43 --- /dev/null +++ b/addon/icons/toc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/icons/todo.svg b/addon/icons/todo.svg new file mode 100644 index 00000000..eecbe400 --- /dev/null +++ b/addon/icons/todo.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/tree-select.svg b/addon/icons/tree-select.svg new file mode 100644 index 00000000..a4758a41 --- /dev/null +++ b/addon/icons/tree-select.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/unfiledBookmarks.svg b/addon/icons/unfiledBookmarks.svg new file mode 100644 index 00000000..c26fc6d8 --- /dev/null +++ b/addon/icons/unfiledBookmarks.svg @@ -0,0 +1,74 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addon/icons/unfiledBookmarks2.svg b/addon/icons/unfiledBookmarks2.svg new file mode 100644 index 00000000..a61c400e --- /dev/null +++ b/addon/icons/unfiledBookmarks2.svg @@ -0,0 +1,75 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addon/icons/url.svg b/addon/icons/url.svg new file mode 100644 index 00000000..f2c1a140 --- /dev/null +++ b/addon/icons/url.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/waiting.svg b/addon/icons/waiting.svg new file mode 100644 index 00000000..ec64401a --- /dev/null +++ b/addon/icons/waiting.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/addon/icons/web-archive.svg b/addon/icons/web-archive.svg new file mode 100644 index 00000000..5f8c4063 --- /dev/null +++ b/addon/icons/web-archive.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/icons/web.svg b/addon/icons/web.svg new file mode 100644 index 00000000..f768870f --- /dev/null +++ b/addon/icons/web.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/web2.svg b/addon/icons/web2.svg new file mode 100644 index 00000000..82c1f4b0 --- /dev/null +++ b/addon/icons/web2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/addon/icons/zip.svg b/addon/icons/zip.svg new file mode 100644 index 00000000..e63cb41f --- /dev/null +++ b/addon/icons/zip.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/addon/images/android_qrcode.png b/addon/images/android_qrcode.png new file mode 100644 index 00000000..2a911105 Binary files /dev/null and b/addon/images/android_qrcode.png differ diff --git a/addon/images/checkbox.png b/addon/images/checkbox.png new file mode 100644 index 00000000..e0faf6a4 Binary files /dev/null and b/addon/images/checkbox.png differ diff --git a/addon/images/circuit.png b/addon/images/circuit.png new file mode 100644 index 00000000..52da8aec Binary files /dev/null and b/addon/images/circuit.png differ diff --git a/addon/images/donation_kitty.png b/addon/images/donation_kitty.png new file mode 100644 index 00000000..0889f535 Binary files /dev/null and b/addon/images/donation_kitty.png differ diff --git a/addon/images/dropdown.svg b/addon/images/dropdown.svg new file mode 100644 index 00000000..dbab466f --- /dev/null +++ b/addon/images/dropdown.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/addon/images/radiobutton.png b/addon/images/radiobutton.png new file mode 100644 index 00000000..3e38e000 Binary files /dev/null and b/addon/images/radiobutton.png differ diff --git a/addon/images/ufo.svg b/addon/images/ufo.svg new file mode 100644 index 00000000..292dd616 --- /dev/null +++ b/addon/images/ufo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/addon/import.js b/addon/import.js new file mode 100644 index 00000000..f4c5c578 --- /dev/null +++ b/addon/import.js @@ -0,0 +1,141 @@ +import { + BROWSER_SHELF_ID, + CLOUD_SHELF_ID, + DEFAULT_SHELF_NAME, + DONE_SHELF_NAME, + EVERYTHING_SHELF_NAME, FILES_SHELF_ID, + RDF_EXTERNAL_TYPE, + TODO_SHELF_NAME +} from "./storage.js"; +import {Query} from "./storage_query.js"; +import {TODO} from "./bookmarks_todo.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {MarshallerORG, UnmarshallerORG} from "./marshaller_org.js"; +import {NetscapeImporterBuilder} from "./import_html.js"; +import {RDFImporterBuilder} from "./import_rdf.js"; +import {StreamExporterBuilder, StreamImporterBuilder, StructuredStreamImporterBuilder} from "./import_drivers.js"; +import {undoManager} from "./bookmarks_undo.js"; +import {Database} from "./storage_database.js"; +import {DiskStorage} from "./storage_external.js"; +import {MarshallerJSONScrapbook, UnmarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; + +export class Export { + static create(format) { + switch (format) { + case "json": + return new StreamExporterBuilder(new MarshallerJSONScrapbook()); + case "org": + return new StreamExporterBuilder(new MarshallerORG()); + } + } + + // get nodes of the specified shelf for export + static async nodes(shelf, computeLevel) { + const isShelfName = typeof shelf === "string"; + let nodes; + + if (isShelfName && shelf.toUpperCase() === TODO_SHELF_NAME) { + nodes = await TODO.listTODO(); + + if (computeLevel) + nodes.forEach(n => n.__level = 1) + + return nodes; + } + else if (isShelfName && shelf.toUpperCase() === DONE_SHELF_NAME) { + nodes = await TODO.listDONE(); + + if (computeLevel) + nodes.forEach(n => n.__level = 1) + + return nodes; + } + + const everything = isShelfName && shelf === EVERYTHING_SHELF_NAME; + + if (!everything && isShelfName) + shelf = await Query.shelf(shelf); + + let level = computeLevel? (everything? 1: 0): undefined; + + if (everything) { + const shelves = await Query.allShelves(); + const cloud = shelves.find(s => s.id === CLOUD_SHELF_ID); + + if (cloud) + shelves.splice(shelves.indexOf(cloud), 1); + + const browser = shelves.find(s => s.id === BROWSER_SHELF_ID); + if (browser) + shelves.splice(shelves.indexOf(browser), 1); + + const files = shelves.find(s => s.id === FILES_SHELF_ID); + if (files) + shelves.splice(shelves.indexOf(files), 1); + + const openRDFShelves = shelves.filter(n => n.external === RDF_EXTERNAL_TYPE); + for (const node of openRDFShelves) + shelves.splice(shelves.indexOf(node), 1); + + nodes = await Query.fullSubtree(shelves.map(s => s.id), true, level); + } + else { + nodes = await Query.fullSubtree(shelf.id,true, level); + nodes.shift(); + } + + return nodes; + } +} + +export class Import { + static create(format) { + switch (format) { + case "json": + case "jsonl": + case "jsbk": + return new StructuredStreamImporterBuilder(new UnmarshallerJSONScrapbook()); + case "org": + return new StreamImporterBuilder(new UnmarshallerORG()); + case "html": + return new NetscapeImporterBuilder(null); + case "rdf": + return new RDFImporterBuilder(null); + } + } + + static async prepare(shelf) { + try { + await undoManager.commit(); + } catch (e) { + console.error(e); + } + + if (shelf === EVERYTHING_SHELF_NAME) { + await Database.wipeImportable(); + await DiskStorage.wipeStorage(); + } + else { + shelf = await Query.shelf(shelf); + + if (shelf && shelf.name === DEFAULT_SHELF_NAME) { + return Bookmark.deleteChildren(shelf.id); + } else if (shelf) { + return Bookmark.delete(shelf.id); + } + } + } + + static async transaction(importer) { + try { + await DiskStorage.openBatchSession(); + return importer.import(); + } + finally { + await DiskStorage.closeBatchSession(); + } + } +} + + + diff --git a/addon/import_drivers.js b/addon/import_drivers.js new file mode 100644 index 00000000..f8726a80 --- /dev/null +++ b/addon/import_drivers.js @@ -0,0 +1,274 @@ +import {ProgressCounter} from "./utils.js"; +import { + DEFAULT_SHELF_ID, + DEFAULT_SHELF_UUID, + DEFAULT_SHELF_NAME, + BROWSER_SHELF_ID, + isContainerNode, + NODE_TYPE_FOLDER, + NODE_TYPE_SHELF, EVERYTHING_SHELF_NAME +} from "./storage.js"; +import {Folder} from "./bookmarks_folder.js"; +import UUID from "./uuid.js"; +import {formatShelfName} from "./bookmarking.js"; +import {Import} from "./import.js"; +import {SCRAPYARD_STORAGE_FORMAT, UnmarshallerJSON} from "./marshaller_json.js"; + +export class StreamExporterBuilder { + constructor(marshaller) { + this._marshaller = marshaller; + this._exportOptions = {}; + } + + setName(name) { + this._exportOptions.name = name; + return this; + } + + setComment(comment) { + this._exportOptions.comment = comment; + return this; + } + + setUUID(uuid) { + this._exportOptions.uuid = uuid; + return this; + } + + setLinksOnly(linksOnly) { + this._exportOptions.linksOnly = linksOnly; + return this; + } + + setStream(stream) { + this._exportOptions.stream = stream; + return this; + } + + setObjects(objects) { + this._exportOptions.objects = objects; + return this; + } + + setReportProgress(report) { + this._exportOptions.progress = report; + return this; + } + + setMuteSidebar(mute) { + this._exportOptions.muteSidebar = mute; + return this; + } + + setSidebarContext(val) { + this._exportOptions.sidebarContext = val; + return this; + } + + _createExporter(options) { + const exporter = new StreamExporter(this._marshaller); + exporter._exportOptions = options; + return exporter; + } + + build() { + this._marshaller?.configure(this._exportOptions); + return this._createExporter(this._exportOptions); + } +} + +export class StreamExporter { + constructor(marshaller) { + this._marshaller = marshaller; + } + + async export() { + const {progress, muteSidebar, objects} = this._exportOptions; + const marshaller = this._marshaller; + + await marshaller.marshalMeta(this._exportOptions); + + if (objects.length) { + const local = !_BACKGROUND_PAGE && !muteSidebar; + const progressCounter = progress + ? new ProgressCounter(objects.length, "exportProgress", {muteSidebar}, local) + : null; + + for (let object of objects) { + await marshaller.marshal(object); + progressCounter?.incrementAndNotify() + } + + progressCounter?.finish(); + } + } +} + +export class StreamImporterBuilder { + constructor(unmarshaller) { + this._unmarshaller = unmarshaller; + this._importOptions = {}; + } + + setName(name) { + this._importOptions.name = name; + return this; + } + + setStream(stream) { + this._importOptions.stream = stream; + return this; + } + + setReportProgress(report) { + this._importOptions.progress = report; + return this; + } + + setMuteSidebar(mute) { + this._importOptions.muteSidebar = mute; + return this; + } + + setSidebarContext(val) { + this._importOptions.sidebarContext = val; + return this; + } + + _createImporter(options) { + const importer = new StreamImporter(this._unmarshaller); + importer._importOptions = options; + return importer; + } + + build() { + this._unmarshaller?.configure(this._importOptions); + return this._createImporter(this._importOptions); + } +} + +export class StreamImporter { + constructor(unmarshaller) { + this._unmarshaller = unmarshaller; + } + + async import() { + let {name: shelfName, progress, muteSidebar} = this._importOptions; + const unmarshaller = this._unmarshaller; + + const meta = await unmarshaller.unmarshalMeta(); + + await Import.prepare(shelfName); + + const local = !_BACKGROUND_PAGE && !muteSidebar; + const progressCounter = progress && !!meta?.entities + ? new ProgressCounter(meta.entities, "importProgress", {muteSidebar}, local) + : null; + + while (await unmarshaller.unmarshal()) + progressCounter?.incrementAndNotify(); + + progressCounter?.finish(); + } +} + +export class StructuredStreamImporterBuilder extends StreamImporterBuilder { + _createImporter(options) { + const importer = new StructuredStreamImporter(this._unmarshaller); + importer._importOptions = options; + return importer; + } +} + +// Imports JSON Lines explicitly structured as a tree through id references +export class StructuredStreamImporter { + constructor(unmarshaller) { + this._unmarshaller = unmarshaller; + } + + async import() { + let unmarshaller = this._unmarshaller; + const meta = await unmarshaller.unmarshalMeta(); + + if (!meta) + throw new Error("Invalid file format."); + + if (meta.export === SCRAPYARD_STORAGE_FORMAT) { + unmarshaller = new UnmarshallerJSON(meta); + unmarshaller.configure(this._importOptions); + } + + let firstObject = await unmarshaller.unmarshal(); + + if (!firstObject) + throw new Error("Invalid file format."); + + let {name: shelfName, progress, muteSidebar} = this._importOptions; + + await Import.prepare(shelfName); + + progress = progress && !!meta.entities; + const localSender = !_BACKGROUND_PAGE && !muteSidebar; + this._progressCounter = progress + ? new ProgressCounter(meta.entities, "importProgress", {muteSidebar}, localSender) + : null; + + this._importParentId2DBParentId = new Map(); + this._importParentId2DBParentId.set(DEFAULT_SHELF_ID, DEFAULT_SHELF_ID); + this._everythingAsShelf = !firstObject.node.parent_id && shelfName !== EVERYTHING_SHELF_NAME; + this._shelfNode = shelfName !== EVERYTHING_SHELF_NAME? await Folder.getOrCreateByPath(shelfName): null; + + if (this._shelfNode) // first object contains id of its parent shelf if a shelf (not everything) is imported + this._importParentId2DBParentId.set(firstObject.node.parent_id, this._shelfNode.id); + + await this._importObject(firstObject); + + let object; + while (object = await unmarshaller.unmarshal()) { + await this._importObject(object); + } + + if (progress && !this._progressCounter.isFinished()) + throw new Error("some records are missing"); + else + this._progressCounter?.finish(); + + return this._shelfNode; + } + + async _importObject(object) { + const {_importParentId2DBParentId, _shelfNode, _everythingAsShelf, _progressCounter} = this; + this._renameBuiltinShelves(object.node); + + // importing the default shelf + if (object.node.type === NODE_TYPE_SHELF && object.node.name?.toLowerCase() === DEFAULT_SHELF_NAME) { + if (_everythingAsShelf) // import default shelf as a folder + object.node.uuid = UUID.numeric(); + else { // force import to the external storage + object.node.id = DEFAULT_SHELF_ID; + object.node.uuid = DEFAULT_SHELF_UUID; + object.node.date_modified = 0; + } + } + + if (object.node.parent_id) + object.node.parent_id = _importParentId2DBParentId.get(object.node.parent_id); + else if (_everythingAsShelf && object.node.type === NODE_TYPE_SHELF) { + object.node.type = NODE_TYPE_FOLDER; + object.node.parent_id = _shelfNode.id; + } + + let objectImportId = object.node.id; + const node = await object.persist(); + + if (objectImportId && isContainerNode(node)) + _importParentId2DBParentId.set(objectImportId, node.id); + + _progressCounter?.incrementAndNotify(); + } + + _renameBuiltinShelves(node) { + if (node && node.id === BROWSER_SHELF_ID) + node.name = `${formatShelfName(node.name)} (imported)`; + } +} diff --git a/addon/import_html.js b/addon/import_html.js new file mode 100644 index 00000000..54d0322b --- /dev/null +++ b/addon/import_html.js @@ -0,0 +1,60 @@ +import {NODE_TYPE_BOOKMARK} from "./storage.js"; +import {Import} from "./import.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {StreamImporterBuilder} from "./import_drivers.js"; + +async function importHtml(shelf, html) { + await Import.prepare(shelf); + + let doc = jQuery.parseHTML(html); + let root = doc.find(e => e.localName === "dl"); + let path = [shelf]; + + function peekType(node) { + for (let child of node.childNodes) { + if (child.localName === "a") + return ["link", child]; + else if (child.localName === "h3") + return ["folder", child]; + } + return ["unknown", null]; + } + + async function traverseHtml(root, path) { + for (let child of root.childNodes) { + if (child.localName === "dt") { + let [type, node] = peekType(child); + if (type === "folder") { + path.push(node.textContent); + await traverseHtml(child, path); + path.pop(); + } + else if (type === "link") { + node = await Bookmark.import({ + uri: node.href, + name: node.textContent, + type: NODE_TYPE_BOOKMARK, + path: path.join("/") + }); + + await Bookmark.storeIconFromURI(node) + } + } + else if (child.localName === "dl") { + await traverseHtml(child, path); + } + } + } + + await traverseHtml(root, path); +} + +export class NetscapeImporterBuilder extends StreamImporterBuilder { + _createImporter(options) { + return { + import() { + return importHtml(options.name, options.stream); + } + }; + } +} diff --git a/addon/import_rdf.js b/addon/import_rdf.js new file mode 100644 index 00000000..7666f320 --- /dev/null +++ b/addon/import_rdf.js @@ -0,0 +1,319 @@ +import {helperApp} from "./helper_app.js"; +import { + ARCHIVE_TYPE_FILES, + NODE_TYPE_ARCHIVE, + NODE_TYPE_FOLDER, + NODE_TYPE_SEPARATOR, + RDF_EXTERNAL_TYPE +} from "./storage.js"; +import {ProgressCounter} from "./utils.js"; +import {send, sendLocal} from "./proxy.js"; +import {Folder} from "./bookmarks_folder.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {Archive, Comments, Node} from "./storage_entities.js"; +import {StreamImporterBuilder} from "./import_drivers.js"; +import {RDFNamespaces} from "./utils_html.js"; +import {settings} from "./settings.js"; + +class RDFImporter { + #options; + #nodeID_SB2SY = new Map(); + #nodeID_SY2SB = new Map(); + #shelf; + #bookmarks = []; + #cancelled = false; + #progressCounter; + #threadCount; + #sidebarSender; + #importType = "full"; + + #Bookmark = Bookmark; + #Folder = Folder; + + constructor(importOptions) { + this.#options = importOptions; + this.#sidebarSender = importOptions.sidebarContext? sendLocal: send; + + if (importOptions.quick) { + this.#Bookmark = Bookmark.idb; + this.#Folder = Folder.idb; + this.#importType = "index"; + } + } + + async import() { + const helper = await helperApp.probe(true); + + if (helper) { + const path = this.#options.stream.replace(/\\/g, "/"); + const rdfFile = path.split("/").at(-1); + const rdfDirectory = path.substring(0, path.lastIndexOf("/")); + const xml = await this.#getRDFXML(rdfFile, rdfDirectory); + + if (!xml) + return Promise.reject(new Error("RDF file not found.")); + + await this.#buildBookmarkTree(path, xml); + await this.#importArchives(rdfDirectory); + } + } + + #traverseRDFTree(doc, visitor, data) { + const namespaces = new RDFNamespaces(doc); + const seqs = this.#mapURNToNodes("//RDF:Seq", doc, namespaces, false); + const separators = this.#mapURNToNodes("//NC:BookmarkSeparator", doc, namespaces) + const descriptions = this.#mapURNToNodes("//RDF:Description", doc, namespaces); + const leaves = new Map([...separators, ...descriptions]); + + async function doTraverse(parent, current, visitor) { + let seq = seqs.get(current? current.__sb_about: "urn:scrapbook:root"); + let children = seq.children; + + if (children && children.length) { + for (let i = 0; i < children.length; ++i) { + if (children[i].localName === "li") { + let resource = children[i].getAttributeNS(namespaces.NS_RDF, "resource"); + let node = leaves.get(resource); + + if (node) { + await visitor(current, node, data); + if (node.__sb_type === "folder") + await doTraverse(current, node, visitor); + } + } + } + } + } + + return doTraverse(null, null, visitor); + } + + #mapURNToNodes(xpath, doc, namespaces, collectAttributes = true) { + const result = new Map(); + const nodes = doc.evaluate(xpath, doc, namespaces.resolver, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null); + let node; + + while (node = nodes.iterateNext()) { + if (collectAttributes) { + node.__sb_about = node.getAttributeNS(namespaces.NS_RDF, "about"); + node.__sb_id = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "id"); + node.__sb_type = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "type"); + node.__sb_title = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "title"); + node.__sb_source = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "source"); + node.__sb_icon = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "icon"); + node.__sb_comment = node.getAttributeNS(namespaces.NS_SCRAPBOOK, "comment"); + + if (node.__sb_comment) + node.__sb_comment = node.__sb_comment.replace(/ __BR__ /g, "\n"); + } + + result.set(node.getAttributeNS(namespaces.NS_RDF, "about"), node); + } + + return result; + } + + async #getRDFXML(rdfFile, rdfDirectory) { + let xml = null; + + try { + let form = new FormData(); + form.append("rdf_file", rdfFile); + form.append("rdf_directory", rdfDirectory); + + xml = await helperApp.fetchText(`/rdf/import/${rdfFile}`, {method: "POST", body: form}); + } catch (e) { + console.error(e); + } + + return xml; + } + + async #buildBookmarkTree(path, xml) { + this.#shelf = await this.#createShelf(path); + const rdfDoc = new DOMParser().parseFromString(xml, 'application/xml'); + + await this.#traverseRDFTree(rdfDoc, this.#createBookmark.bind(this), {pos: 0}); + } + + async #importArchives(path) { + let cancelListener = (message, sender, sendResponse) => { + if (message.type === "cancelRdfImport") + this.#cancelled = true; + }; + browser.runtime.onMessage.addListener(cancelListener); + + try { + if (this.#options.createIndex) { // createIndex is always on when performing a full import + this.#progressCounter = + new ProgressCounter(this.#bookmarks.length, "rdfImportProgress", {muteSidebar: true}); + await this.#startThreads(this.#importThread.bind(this, path), this.#options.threads); + } + else + await this.#onFinish(); + } finally { + browser.runtime.onMessage.removeListener(cancelListener); + } + } + + async #startThreads(threadf, maxThreads) { + const bookmarks = [...this.#bookmarks]; + this.#threadCount = Math.min(maxThreads, this.#bookmarks.length); + + const promises = []; + for (let i = 0; i < maxThreads; ++i) + promises.push(threadf(bookmarks)); + + return Promise.all(promises); + } + + async #importThread(path, bookmarks) { + if (bookmarks.length && !this.#cancelled) { + let bookmark = bookmarks.shift(); + + try { + let scrapbookId = this.#nodeID_SY2SB.get(bookmark.id); + await this.#importRDFArchive(path, bookmark, scrapbookId); + } catch (e) { + send.rdfImportError({bookmark: bookmark, error: e.message}); + } + + this.#progressCounter.incrementAndNotify(); + + return this.#importThread(path, bookmarks); + } + else { + this.#threadCount -= 1; + if (this.#threadCount === 0) + return this.#onFinish(); + } + } + + async #importRDFArchive(path, node, scrapbookId) { + const params = { + data_path: settings.data_folder_path(), + rdf_archive_path: path, + uuid: node.uuid, + scrapbook_id: scrapbookId + }; + + try { + const url = `/rdf/import/archive?type=${this.#importType}`; + const response = await helperApp.fetchJSON_postJSON(url, params); + + if (response?.size) { + node.size = response.size; + await Node.update(node); + } + + if (response?.archive_index) + Archive.idb.import.storeIndex(node, response.archive_index); + } catch (e) { + console.error(e); + } + } + + async #onFinish() { + this.#sidebarSender.nodesImported({shelf: this.#shelf}); + + if (this.#options.createIndex) + this.#progressCounter.finish(); + + send.obtainingIcons({shelf: this.#shelf}); + await this.#startThreads(this.#iconImportThread.bind(this), this.#options.threads); + } + + async #iconImportThread(bookmarks) { + if (bookmarks.length && !this.#cancelled) { + let bookmark = bookmarks.shift(); + + if (bookmark.icon && bookmark.icon.startsWith("resource://scrapbook/")) { + bookmark.icon = bookmark.icon.replace("resource://scrapbook/", ""); + bookmark.icon = helperApp.url(`/rdf/import/files/${bookmark.icon}`); + await this.#Bookmark.storeIcon(bookmark); + } + + return this.#iconImportThread(bookmarks); + } + else { + this.#threadCount -= 1; + if (this.#threadCount === 0) + this.#sidebarSender.nodesReady({shelf: this.#shelf}); + } + } + + async #createShelf(path) { + const shelfNode = await this.#Folder.getOrCreateByPath(this.#options.name); + + if (shelfNode) { + if (this.#options.quick) { + shelfNode.external = RDF_EXTERNAL_TYPE; + shelfNode.uri = path.substring(0, path.lastIndexOf("/")); + await Node.idb.update(shelfNode); + } + this.#nodeID_SB2SY.set(null, shelfNode.id); + } + + return shelfNode; + } + + async #createBookmark(parent, node, vars) { + const now = new Date(); + + let data = { + pos: vars.pos++, + uri: node.__sb_source, + name: node.__sb_title, + type: node.__sb_type === "folder" + ? NODE_TYPE_FOLDER + : (node.__sb_type === "separator" + ? NODE_TYPE_SEPARATOR + : NODE_TYPE_ARCHIVE), + parent_id: parent ? this.#nodeID_SB2SY.get(parent.__sb_id) : this.#shelf.id, + todo_state: node.__sb_type === "marked" ? 1 : undefined, + contains: ARCHIVE_TYPE_FILES, + content_type: "text/html", + has_comments: node.__sb_comment? true: undefined, + icon: node.__sb_icon, + date_added: now, + date_modified: now + }; + + if (this.#options.quick) { + data.external = RDF_EXTERNAL_TYPE; + data.external_id = node.__sb_id; + } + + let bookmark = await this.#Bookmark.import(data); + + if (node.__sb_comment) // commends json file on disk is also created by helper + await Comments.idb.import.add(bookmark, node.__sb_comment); + + this.#nodeID_SB2SY.set(node.__sb_id, bookmark.id); + + if (data.type === NODE_TYPE_FOLDER) + this.#nodeID_SB2SY.set(node.__sb_id, bookmark.id); + else if (data.type === NODE_TYPE_ARCHIVE) { + this.#nodeID_SY2SB.set(bookmark.id, node.__sb_id); + this.#bookmarks.push(bookmark); + } + } +} + +export class RDFImporterBuilder extends StreamImporterBuilder { + setNumberOfThreads(threads) { + this._importOptions.threads = threads; + } + + setQuickImport(quick) { + this._importOptions.quick = quick; + } + + setCreateIndex(quick) { + this._importOptions.createIndex = quick; + } + + _createImporter(options) { + return new RDFImporter(options); + } +} diff --git a/addon/import_versions.js b/addon/import_versions.js new file mode 100644 index 00000000..5282e3a0 --- /dev/null +++ b/addon/import_versions.js @@ -0,0 +1,96 @@ +import {NODE_TYPE_ARCHIVE} from "./storage.js"; + +export function parseJSONObject_v1(line) { + let object; + line = line.trim(); + try { + if (line.endsWith(",")) // support for old JSON format files + object = JSON.parse(line.slice(0, line.length - 1)); + else + object = JSON.parse(line); + } catch (e) { + console.error(e) + } + + //object = transformFromV1ToV3(object); + return transformFromV1ToV3(object); +} + +// This function is also used to import ORG v2 objects which are essentially +// v1 objects with base64 binary blob representation +export function transformFromV1ToV3(object, blobVersion = 1) { + let notes = object.notes; + delete object.notes; + + let notes_html = object.notes_html; + delete object.notes_html; + + let notes_format = object.notes_format; + delete object.notes_format; + + let notes_align = object.notes_align; + delete object.notes_align; + + let notes_width = object.notes_width; + delete object.notes_width; + + let comments = object.comments; + delete object.comments; + + let icon_data = object.icon_data; + delete object.icon_data; + + const result = { + node: object + }; + + if (object.type === NODE_TYPE_ARCHIVE) { + let data = object.data; + let byte_length = object.byte_length; + + delete object.data; + delete object.byte_length; + + if (blobVersion === 1 && byte_length) + data = btoa(data); + + result.archive = {object: data, type: object.mime_type, byte_length}; + } + + if (notes) + result.notes = {content: notes, html: notes_html, format: notes_format, align: notes_align, width: notes_width}; + + if (icon_data) + result.icon = {data_url: icon_data}; + + if (comments) + result.comments = {text: comments}; + + return result; +} + +export function parseJSONObject_v2(line) { + let object = JSON.parse(line); + return transformFromV2ToV3(object); +} + +export function transformFromV2ToV3(object) { + const result = { + node: object, + notes: object.notes, + archive: object.blob + }; + + if (object.icon_data) + result.icon = {data_url: object.icon_data}; + + if (object.comments) + result.comments = {text: object.comments}; + + delete object.blob; + delete object.notes; + delete object.comments; + delete object.icon_data; + + return result; +} diff --git a/addon/lib/PKCE.js b/addon/lib/PKCE.js new file mode 100644 index 00000000..66e7fe0a --- /dev/null +++ b/addon/lib/PKCE.js @@ -0,0 +1,178 @@ +// derived from: https://github.com/bpedroza/js-pkce and https://github.com/aaronpk/pkce-vanilla-js + +/** + * Initialize the instance with configuration + * @param {IConfig} config + */ +export function PKCE(config) { + this.state = ''; + this.codeVerifier = ''; + this.config = config; +} +/** + * Generate the authorize url + * @param {object} additionalParams include additional parameters in the query + * @return Promise + */ +PKCE.prototype.getAuthorizationUrl = async function (additionalParams) { + if (additionalParams === void 0) { additionalParams = {}; } + var codeChallenge = await this.pkceChallengeFromVerifier(); + var queryString = new URLSearchParams(Object.assign({ + response_type: 'code', + response_mode: 'query', + client_id: this.config.client_id, + state: this.getState(additionalParams.state || null), + scope: this.config.requested_scopes, + redirect_uri: this.config.redirect_uri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }, additionalParams)).toString(); + return this.config.authorization_endpoint + "?" + queryString; +}; +/** + * Given the return url, get a token from the oauth server + * @param url current urlwith params from server + * @param {object} additionalParams include additional parameters in the request body + * @return {Promise} + */ +PKCE.prototype.exchangeForAccessToken = function (url, additionalParams) { + var _this = this; + if (additionalParams === void 0) { additionalParams = {}; } + return this.parseAuthResponseUrl(url).then(function (q) { + return fetch(_this.config.token_endpoint, { + method: 'POST', + body: new URLSearchParams(Object.assign({ + grant_type: 'authorization_code', + code: q.code, + client_id: _this.config.client_id, + redirect_uri: _this.config.redirect_uri, + code_verifier: _this.getCodeVerifier(), + }, additionalParams)), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + }).then(function (response) { return response.json(); }); + }); +}; + +PKCE.prototype.refreshAccessToken = function (refreshToken, additionalParams) { + var _this = this; + if (additionalParams === void 0) { additionalParams = {}; } + return fetch(_this.config.token_endpoint, { + method: 'POST', + body: new URLSearchParams(Object.assign({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: _this.config.client_id, + redirect_uri: _this.config.redirect_uri, + scope: this.config.requested_scopes + }, additionalParams)), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + }).then(function (response) { return response.json(); }); +}; + +/** + * Get the current codeVerifier or generate a new one + * @return {string} + */ +PKCE.prototype.getCodeVerifier = function () { + if (this.codeVerifier === '') { + this.codeVerifier = this.randomStringFromStorage('pkce_code_verifier'); + } + return this.codeVerifier; +}; +/** + * Get the current state or generate a new one + * @return {string} + */ +PKCE.prototype.getState = function (explicit) { + if (explicit === void 0) { explicit = null; } + var stateKey = 'pkce_state'; + if (explicit !== null) { + sessionStorage.setItem(stateKey, explicit); + } + if (this.state === '') { + this.state = this.randomStringFromStorage(stateKey); + } + return this.state; +}; +/** + * Get the query params as json from a auth response url + * @param {string} url a url expected to have AuthResponse params + * @return {Promise} + */ +PKCE.prototype.parseAuthResponseUrl = function (url) { + var params = new URL(url).searchParams; + return this.validateAuthResponse({ + error: params.get('error'), + query: params.get('query'), + state: params.get('state'), + code: params.get('code'), + }); +}; +/** + * Generate a code challenge + * @return {Promise} + */ +PKCE.prototype.pkceChallengeFromVerifier = async function () { + var hashed = await sha256(this.getCodeVerifier()); + return base64urlencode(hashed); +}; +/** + * Get a random string from storage or store a new one and return it's value + * @param {string} key + * @return {string} + */ +PKCE.prototype.randomStringFromStorage = function (key) { + var fromStorage = sessionStorage.getItem(key); + if (fromStorage === null) { + sessionStorage.setItem(key, generateRandomString()); + } + return sessionStorage.getItem(key) || ''; +}; +/** + * Validates params from auth response + * @param {AuthResponse} queryParams + * @return {Promise} + */ +PKCE.prototype.validateAuthResponse = function (queryParams) { + var _this = this; + return new Promise(function (resolve, reject) { + if (queryParams.error) { + return reject({ error: queryParams.error }); + } + if (queryParams.state !== _this.getState()) { + return reject({ error: 'Invalid State' }); + } + return resolve(queryParams); + }); +}; + +// Generate a secure random string using the browser crypto functions +function generateRandomString() { + var array = new Uint32Array(28); + window.crypto.getRandomValues(array); + return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join(''); +} + +// Calculate the SHA256 hash of the input text. +// Returns a promise that resolves to an ArrayBuffer +function sha256(plain) { + const encoder = new TextEncoder(); + const data = encoder.encode(plain); + return window.crypto.subtle.digest('SHA-256', data); +} + +// Base64-urlencodes the input string +function base64urlencode(str) { + // Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts. + // btoa accepts chars only within ascii 0-255 and base64 encodes them. + // Then convert the base64 encoded to base64url encoded + // (replace + with -, replace / with _, trim trailing =) + return btoa(String.fromCharCode.apply(null, new Uint8Array(str))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/addon/lib/browser-polyfill.js b/addon/lib/browser-polyfill.js new file mode 100644 index 00000000..f78670de --- /dev/null +++ b/addon/lib/browser-polyfill.js @@ -0,0 +1,2 @@ +if (!globalThis.browser) + globalThis.browser = chrome; diff --git a/addon/lib/dexie.js b/addon/lib/dexie.js new file mode 100644 index 00000000..cdcc7b7d --- /dev/null +++ b/addon/lib/dexie.js @@ -0,0 +1,5179 @@ +/* + * Dexie.js - a minimalistic wrapper for IndexedDB + * =============================================== + * + * By David Fahlander, david.fahlander@gmail.com + * + * Version 3.2.2, Wed Apr 27 2022 + * + * https://dexie.org + * + * Apache License Version 2.0, January 2004, http://www.apache.org/licenses/ + */ + +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +var __assign = function() { + __assign = Object.assign || function __assign(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +function __spreadArray(to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +} + +var _global = typeof globalThis !== 'undefined' ? globalThis : + typeof self !== 'undefined' ? self : + typeof window !== 'undefined' ? window : + global; + +var keys = Object.keys; +var isArray = Array.isArray; +if (typeof Promise !== 'undefined' && !_global.Promise) { + _global.Promise = Promise; +} +function extend(obj, extension) { + if (typeof extension !== 'object') + return obj; + keys(extension).forEach(function (key) { + obj[key] = extension[key]; + }); + return obj; +} +var getProto = Object.getPrototypeOf; +var _hasOwn = {}.hasOwnProperty; +function hasOwn(obj, prop) { + return _hasOwn.call(obj, prop); +} +function props(proto, extension) { + if (typeof extension === 'function') + extension = extension(getProto(proto)); + (typeof Reflect === "undefined" ? keys : Reflect.ownKeys)(extension).forEach(function (key) { + setProp(proto, key, extension[key]); + }); +} +var defineProperty = Object.defineProperty; +function setProp(obj, prop, functionOrGetSet, options) { + defineProperty(obj, prop, extend(functionOrGetSet && hasOwn(functionOrGetSet, "get") && typeof functionOrGetSet.get === 'function' ? + { get: functionOrGetSet.get, set: functionOrGetSet.set, configurable: true } : + { value: functionOrGetSet, configurable: true, writable: true }, options)); +} +function derive(Child) { + return { + from: function (Parent) { + Child.prototype = Object.create(Parent.prototype); + setProp(Child.prototype, "constructor", Child); + return { + extend: props.bind(null, Child.prototype) + }; + } + }; +} +var getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +function getPropertyDescriptor(obj, prop) { + var pd = getOwnPropertyDescriptor(obj, prop); + var proto; + return pd || (proto = getProto(obj)) && getPropertyDescriptor(proto, prop); +} +var _slice = [].slice; +function slice(args, start, end) { + return _slice.call(args, start, end); +} +function override(origFunc, overridedFactory) { + return overridedFactory(origFunc); +} +function assert(b) { + if (!b) + throw new Error("Assertion Failed"); +} +function asap$1(fn) { + if (_global.setImmediate) + setImmediate(fn); + else + setTimeout(fn, 0); +} +function arrayToObject(array, extractor) { + return array.reduce(function (result, item, i) { + var nameAndValue = extractor(item, i); + if (nameAndValue) + result[nameAndValue[0]] = nameAndValue[1]; + return result; + }, {}); +} +function tryCatch(fn, onerror, args) { + try { + fn.apply(null, args); + } + catch (ex) { + onerror && onerror(ex); + } +} +function getByKeyPath(obj, keyPath) { + if (hasOwn(obj, keyPath)) + return obj[keyPath]; + if (!keyPath) + return obj; + if (typeof keyPath !== 'string') { + var rv = []; + for (var i = 0, l = keyPath.length; i < l; ++i) { + var val = getByKeyPath(obj, keyPath[i]); + rv.push(val); + } + return rv; + } + var period = keyPath.indexOf('.'); + if (period !== -1) { + var innerObj = obj[keyPath.substr(0, period)]; + return innerObj === undefined ? undefined : getByKeyPath(innerObj, keyPath.substr(period + 1)); + } + return undefined; +} +function setByKeyPath(obj, keyPath, value) { + if (!obj || keyPath === undefined) + return; + if ('isFrozen' in Object && Object.isFrozen(obj)) + return; + if (typeof keyPath !== 'string' && 'length' in keyPath) { + assert(typeof value !== 'string' && 'length' in value); + for (var i = 0, l = keyPath.length; i < l; ++i) { + setByKeyPath(obj, keyPath[i], value[i]); + } + } + else { + var period = keyPath.indexOf('.'); + if (period !== -1) { + var currentKeyPath = keyPath.substr(0, period); + var remainingKeyPath = keyPath.substr(period + 1); + if (remainingKeyPath === "") + if (value === undefined) { + if (isArray(obj) && !isNaN(parseInt(currentKeyPath))) + obj.splice(currentKeyPath, 1); + else + delete obj[currentKeyPath]; + } + else + obj[currentKeyPath] = value; + else { + var innerObj = obj[currentKeyPath]; + if (!innerObj || !hasOwn(obj, currentKeyPath)) + innerObj = (obj[currentKeyPath] = {}); + setByKeyPath(innerObj, remainingKeyPath, value); + } + } + else { + if (value === undefined) { + if (isArray(obj) && !isNaN(parseInt(keyPath))) + obj.splice(keyPath, 1); + else + delete obj[keyPath]; + } + else + obj[keyPath] = value; + } + } +} +function delByKeyPath(obj, keyPath) { + if (typeof keyPath === 'string') + setByKeyPath(obj, keyPath, undefined); + else if ('length' in keyPath) + [].map.call(keyPath, function (kp) { + setByKeyPath(obj, kp, undefined); + }); +} +function shallowClone(obj) { + var rv = {}; + for (var m in obj) { + if (hasOwn(obj, m)) + rv[m] = obj[m]; + } + return rv; +} +var concat = [].concat; +function flatten(a) { + return concat.apply([], a); +} +var intrinsicTypeNames = "Boolean,String,Date,RegExp,Blob,File,FileList,FileSystemFileHandle,ArrayBuffer,DataView,Uint8ClampedArray,ImageBitmap,ImageData,Map,Set,CryptoKey" + .split(',').concat(flatten([8, 16, 32, 64].map(function (num) { return ["Int", "Uint", "Float"].map(function (t) { return t + num + "Array"; }); }))).filter(function (t) { return _global[t]; }); +var intrinsicTypes = intrinsicTypeNames.map(function (t) { return _global[t]; }); +arrayToObject(intrinsicTypeNames, function (x) { return [x, true]; }); +var circularRefs = null; +function deepClone(any) { + circularRefs = typeof WeakMap !== 'undefined' && new WeakMap(); + var rv = innerDeepClone(any); + circularRefs = null; + return rv; +} +function innerDeepClone(any) { + if (!any || typeof any !== 'object') + return any; + var rv = circularRefs && circularRefs.get(any); + if (rv) + return rv; + if (isArray(any)) { + rv = []; + circularRefs && circularRefs.set(any, rv); + for (var i = 0, l = any.length; i < l; ++i) { + rv.push(innerDeepClone(any[i])); + } + } + else if (intrinsicTypes.indexOf(any.constructor) >= 0) { + rv = any; + } + else { + var proto = getProto(any); + rv = proto === Object.prototype ? {} : Object.create(proto); + circularRefs && circularRefs.set(any, rv); + for (var prop in any) { + if (hasOwn(any, prop)) { + rv[prop] = innerDeepClone(any[prop]); + } + } + } + return rv; +} +var toString = {}.toString; +function toStringTag(o) { + return toString.call(o).slice(8, -1); +} +var iteratorSymbol = typeof Symbol !== 'undefined' ? + Symbol.iterator : + '@@iterator'; +var getIteratorOf = typeof iteratorSymbol === "symbol" ? function (x) { + var i; + return x != null && (i = x[iteratorSymbol]) && i.apply(x); +} : function () { return null; }; +var NO_CHAR_ARRAY = {}; +function getArrayOf(arrayLike) { + var i, a, x, it; + if (arguments.length === 1) { + if (isArray(arrayLike)) + return arrayLike.slice(); + if (this === NO_CHAR_ARRAY && typeof arrayLike === 'string') + return [arrayLike]; + if ((it = getIteratorOf(arrayLike))) { + a = []; + while ((x = it.next()), !x.done) + a.push(x.value); + return a; + } + if (arrayLike == null) + return [arrayLike]; + i = arrayLike.length; + if (typeof i === 'number') { + a = new Array(i); + while (i--) + a[i] = arrayLike[i]; + return a; + } + return [arrayLike]; + } + i = arguments.length; + a = new Array(i); + while (i--) + a[i] = arguments[i]; + return a; +} +var isAsyncFunction = typeof Symbol !== 'undefined' + ? function (fn) { return fn[Symbol.toStringTag] === 'AsyncFunction'; } + : function () { return false; }; + +var debug = typeof location !== 'undefined' && + /^(http|https):\/\/(localhost|127\.0\.0\.1)/.test(location.href); +function setDebug(value, filter) { + debug = value; + libraryFilter = filter; +} +var libraryFilter = function () { return true; }; +var NEEDS_THROW_FOR_STACK = !new Error("").stack; +function getErrorWithStack() { + if (NEEDS_THROW_FOR_STACK) + try { + //getErrorWithStack.arguments; + throw new Error(); + } + catch (e) { + return e; + } + return new Error(); +} +function prettyStack(exception, numIgnoredFrames) { + var stack = exception.stack; + if (!stack) + return ""; + numIgnoredFrames = (numIgnoredFrames || 0); + if (stack.indexOf(exception.name) === 0) + numIgnoredFrames += (exception.name + exception.message).split('\n').length; + return stack.split('\n') + .slice(numIgnoredFrames) + .filter(libraryFilter) + .map(function (frame) { return "\n" + frame; }) + .join(''); +} + +var dexieErrorNames = [ + 'Modify', + 'Bulk', + 'OpenFailed', + 'VersionChange', + 'Schema', + 'Upgrade', + 'InvalidTable', + 'MissingAPI', + 'NoSuchDatabase', + 'InvalidArgument', + 'SubTransaction', + 'Unsupported', + 'Internal', + 'DatabaseClosed', + 'PrematureCommit', + 'ForeignAwait' +]; +var idbDomErrorNames = [ + 'Unknown', + 'Constraint', + 'Data', + 'TransactionInactive', + 'ReadOnly', + 'Version', + 'NotFound', + 'InvalidState', + 'InvalidAccess', + 'Abort', + 'Timeout', + 'QuotaExceeded', + 'Syntax', + 'DataClone' +]; +var errorList = dexieErrorNames.concat(idbDomErrorNames); +var defaultTexts = { + VersionChanged: "Database version changed by other database connection", + DatabaseClosed: "Database has been closed", + Abort: "Transaction aborted", + TransactionInactive: "Transaction has already completed or failed", + MissingAPI: "IndexedDB API missing. Please visit https://tinyurl.com/y2uuvskb" +}; +function DexieError(name, msg) { + this._e = getErrorWithStack(); + this.name = name; + this.message = msg; +} +derive(DexieError).from(Error).extend({ + stack: { + get: function () { + return this._stack || + (this._stack = this.name + ": " + this.message + prettyStack(this._e, 2)); + } + }, + toString: function () { return this.name + ": " + this.message; } +}); +function getMultiErrorMessage(msg, failures) { + return msg + ". Errors: " + Object.keys(failures) + .map(function (key) { return failures[key].toString(); }) + .filter(function (v, i, s) { return s.indexOf(v) === i; }) + .join('\n'); +} +function ModifyError(msg, failures, successCount, failedKeys) { + this._e = getErrorWithStack(); + this.failures = failures; + this.failedKeys = failedKeys; + this.successCount = successCount; + this.message = getMultiErrorMessage(msg, failures); +} +derive(ModifyError).from(DexieError); +function BulkError(msg, failures) { + this._e = getErrorWithStack(); + this.name = "BulkError"; + this.failures = Object.keys(failures).map(function (pos) { return failures[pos]; }); + this.failuresByPos = failures; + this.message = getMultiErrorMessage(msg, failures); +} +derive(BulkError).from(DexieError); +var errnames = errorList.reduce(function (obj, name) { return (obj[name] = name + "Error", obj); }, {}); +var BaseException = DexieError; +var exceptions = errorList.reduce(function (obj, name) { + var fullName = name + "Error"; + function DexieError(msgOrInner, inner) { + this._e = getErrorWithStack(); + this.name = fullName; + if (!msgOrInner) { + this.message = defaultTexts[name] || fullName; + this.inner = null; + } + else if (typeof msgOrInner === 'string') { + this.message = "" + msgOrInner + (!inner ? '' : '\n ' + inner); + this.inner = inner || null; + } + else if (typeof msgOrInner === 'object') { + this.message = msgOrInner.name + " " + msgOrInner.message; + this.inner = msgOrInner; + } + } + derive(DexieError).from(BaseException); + obj[name] = DexieError; + return obj; +}, {}); +exceptions.Syntax = SyntaxError; +exceptions.Type = TypeError; +exceptions.Range = RangeError; +var exceptionMap = idbDomErrorNames.reduce(function (obj, name) { + obj[name + "Error"] = exceptions[name]; + return obj; +}, {}); +function mapError(domError, message) { + if (!domError || domError instanceof DexieError || domError instanceof TypeError || domError instanceof SyntaxError || !domError.name || !exceptionMap[domError.name]) + return domError; + var rv = new exceptionMap[domError.name](message || domError.message, domError); + if ("stack" in domError) { + setProp(rv, "stack", { get: function () { + return this.inner.stack; + } }); + } + return rv; +} +var fullNameExceptions = errorList.reduce(function (obj, name) { + if (["Syntax", "Type", "Range"].indexOf(name) === -1) + obj[name + "Error"] = exceptions[name]; + return obj; +}, {}); +fullNameExceptions.ModifyError = ModifyError; +fullNameExceptions.DexieError = DexieError; +fullNameExceptions.BulkError = BulkError; + +function nop() { } +function mirror(val) { return val; } +function pureFunctionChain(f1, f2) { + if (f1 == null || f1 === mirror) + return f2; + return function (val) { + return f2(f1(val)); + }; +} +function callBoth(on1, on2) { + return function () { + on1.apply(this, arguments); + on2.apply(this, arguments); + }; +} +function hookCreatingChain(f1, f2) { + if (f1 === nop) + return f2; + return function () { + var res = f1.apply(this, arguments); + if (res !== undefined) + arguments[0] = res; + var onsuccess = this.onsuccess, + onerror = this.onerror; + this.onsuccess = null; + this.onerror = null; + var res2 = f2.apply(this, arguments); + if (onsuccess) + this.onsuccess = this.onsuccess ? callBoth(onsuccess, this.onsuccess) : onsuccess; + if (onerror) + this.onerror = this.onerror ? callBoth(onerror, this.onerror) : onerror; + return res2 !== undefined ? res2 : res; + }; +} +function hookDeletingChain(f1, f2) { + if (f1 === nop) + return f2; + return function () { + f1.apply(this, arguments); + var onsuccess = this.onsuccess, + onerror = this.onerror; + this.onsuccess = this.onerror = null; + f2.apply(this, arguments); + if (onsuccess) + this.onsuccess = this.onsuccess ? callBoth(onsuccess, this.onsuccess) : onsuccess; + if (onerror) + this.onerror = this.onerror ? callBoth(onerror, this.onerror) : onerror; + }; +} +function hookUpdatingChain(f1, f2) { + if (f1 === nop) + return f2; + return function (modifications) { + var res = f1.apply(this, arguments); + extend(modifications, res); + var onsuccess = this.onsuccess, + onerror = this.onerror; + this.onsuccess = null; + this.onerror = null; + var res2 = f2.apply(this, arguments); + if (onsuccess) + this.onsuccess = this.onsuccess ? callBoth(onsuccess, this.onsuccess) : onsuccess; + if (onerror) + this.onerror = this.onerror ? callBoth(onerror, this.onerror) : onerror; + return res === undefined ? + (res2 === undefined ? undefined : res2) : + (extend(res, res2)); + }; +} +function reverseStoppableEventChain(f1, f2) { + if (f1 === nop) + return f2; + return function () { + if (f2.apply(this, arguments) === false) + return false; + return f1.apply(this, arguments); + }; +} +function promisableChain(f1, f2) { + if (f1 === nop) + return f2; + return function () { + var res = f1.apply(this, arguments); + if (res && typeof res.then === 'function') { + var thiz = this, i = arguments.length, args = new Array(i); + while (i--) + args[i] = arguments[i]; + return res.then(function () { + return f2.apply(thiz, args); + }); + } + return f2.apply(this, arguments); + }; +} + +var INTERNAL = {}; +var LONG_STACKS_CLIP_LIMIT = 100, +MAX_LONG_STACKS = 20, ZONE_ECHO_LIMIT = 100, _a$1 = typeof Promise === 'undefined' ? + [] : + (function () { + var globalP = Promise.resolve(); + if (typeof crypto === 'undefined' || !crypto.subtle) + return [globalP, getProto(globalP), globalP]; + var nativeP = crypto.subtle.digest("SHA-512", new Uint8Array([0])); + return [ + nativeP, + getProto(nativeP), + globalP + ]; + })(), resolvedNativePromise = _a$1[0], nativePromiseProto = _a$1[1], resolvedGlobalPromise = _a$1[2], nativePromiseThen = nativePromiseProto && nativePromiseProto.then; +var NativePromise = resolvedNativePromise && resolvedNativePromise.constructor; +var patchGlobalPromise = !!resolvedGlobalPromise; +var stack_being_generated = false; +var schedulePhysicalTick = resolvedGlobalPromise ? + function () { resolvedGlobalPromise.then(physicalTick); } + : + _global.setImmediate ? + setImmediate.bind(null, physicalTick) : + _global.MutationObserver ? + function () { + var hiddenDiv = document.createElement("div"); + (new MutationObserver(function () { + physicalTick(); + hiddenDiv = null; + })).observe(hiddenDiv, { attributes: true }); + hiddenDiv.setAttribute('i', '1'); + } : + function () { setTimeout(physicalTick, 0); }; +var asap = function (callback, args) { + microtickQueue.push([callback, args]); + if (needsNewPhysicalTick) { + schedulePhysicalTick(); + needsNewPhysicalTick = false; + } +}; +var isOutsideMicroTick = true, +needsNewPhysicalTick = true, +unhandledErrors = [], +rejectingErrors = [], +currentFulfiller = null, rejectionMapper = mirror; +var globalPSD = { + id: 'global', + global: true, + ref: 0, + unhandleds: [], + onunhandled: globalError, + pgp: false, + env: {}, + finalize: function () { + this.unhandleds.forEach(function (uh) { + try { + globalError(uh[0], uh[1]); + } + catch (e) { } + }); + } +}; +var PSD = globalPSD; +var microtickQueue = []; +var numScheduledCalls = 0; +var tickFinalizers = []; +function DexiePromise(fn) { + if (typeof this !== 'object') + throw new TypeError('Promises must be constructed via new'); + this._listeners = []; + this.onuncatched = nop; + this._lib = false; + var psd = (this._PSD = PSD); + if (debug) { + this._stackHolder = getErrorWithStack(); + this._prev = null; + this._numPrev = 0; + } + if (typeof fn !== 'function') { + if (fn !== INTERNAL) + throw new TypeError('Not a function'); + this._state = arguments[1]; + this._value = arguments[2]; + if (this._state === false) + handleRejection(this, this._value); + return; + } + this._state = null; + this._value = null; + ++psd.ref; + executePromiseTask(this, fn); +} +var thenProp = { + get: function () { + var psd = PSD, microTaskId = totalEchoes; + function then(onFulfilled, onRejected) { + var _this = this; + var possibleAwait = !psd.global && (psd !== PSD || microTaskId !== totalEchoes); + var cleanup = possibleAwait && !decrementExpectedAwaits(); + var rv = new DexiePromise(function (resolve, reject) { + propagateToListener(_this, new Listener(nativeAwaitCompatibleWrap(onFulfilled, psd, possibleAwait, cleanup), nativeAwaitCompatibleWrap(onRejected, psd, possibleAwait, cleanup), resolve, reject, psd)); + }); + debug && linkToPreviousPromise(rv, this); + return rv; + } + then.prototype = INTERNAL; + return then; + }, + set: function (value) { + setProp(this, 'then', value && value.prototype === INTERNAL ? + thenProp : + { + get: function () { + return value; + }, + set: thenProp.set + }); + } +}; +props(DexiePromise.prototype, { + then: thenProp, + _then: function (onFulfilled, onRejected) { + propagateToListener(this, new Listener(null, null, onFulfilled, onRejected, PSD)); + }, + catch: function (onRejected) { + if (arguments.length === 1) + return this.then(null, onRejected); + var type = arguments[0], handler = arguments[1]; + return typeof type === 'function' ? this.then(null, function (err) { + return err instanceof type ? handler(err) : PromiseReject(err); + }) + : this.then(null, function (err) { + return err && err.name === type ? handler(err) : PromiseReject(err); + }); + }, + finally: function (onFinally) { + return this.then(function (value) { + onFinally(); + return value; + }, function (err) { + onFinally(); + return PromiseReject(err); + }); + }, + stack: { + get: function () { + if (this._stack) + return this._stack; + try { + stack_being_generated = true; + var stacks = getStack(this, [], MAX_LONG_STACKS); + var stack = stacks.join("\nFrom previous: "); + if (this._state !== null) + this._stack = stack; + return stack; + } + finally { + stack_being_generated = false; + } + } + }, + timeout: function (ms, msg) { + var _this = this; + return ms < Infinity ? + new DexiePromise(function (resolve, reject) { + var handle = setTimeout(function () { return reject(new exceptions.Timeout(msg)); }, ms); + _this.then(resolve, reject).finally(clearTimeout.bind(null, handle)); + }) : this; + } +}); +if (typeof Symbol !== 'undefined' && Symbol.toStringTag) + setProp(DexiePromise.prototype, Symbol.toStringTag, 'Dexie.Promise'); +globalPSD.env = snapShot(); +function Listener(onFulfilled, onRejected, resolve, reject, zone) { + this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null; + this.onRejected = typeof onRejected === 'function' ? onRejected : null; + this.resolve = resolve; + this.reject = reject; + this.psd = zone; +} +props(DexiePromise, { + all: function () { + var values = getArrayOf.apply(null, arguments) + .map(onPossibleParallellAsync); + return new DexiePromise(function (resolve, reject) { + if (values.length === 0) + resolve([]); + var remaining = values.length; + values.forEach(function (a, i) { return DexiePromise.resolve(a).then(function (x) { + values[i] = x; + if (!--remaining) + resolve(values); + }, reject); }); + }); + }, + resolve: function (value) { + if (value instanceof DexiePromise) + return value; + if (value && typeof value.then === 'function') + return new DexiePromise(function (resolve, reject) { + value.then(resolve, reject); + }); + var rv = new DexiePromise(INTERNAL, true, value); + linkToPreviousPromise(rv, currentFulfiller); + return rv; + }, + reject: PromiseReject, + race: function () { + var values = getArrayOf.apply(null, arguments).map(onPossibleParallellAsync); + return new DexiePromise(function (resolve, reject) { + values.map(function (value) { return DexiePromise.resolve(value).then(resolve, reject); }); + }); + }, + PSD: { + get: function () { return PSD; }, + set: function (value) { return PSD = value; } + }, + totalEchoes: { get: function () { return totalEchoes; } }, + newPSD: newScope, + usePSD: usePSD, + scheduler: { + get: function () { return asap; }, + set: function (value) { asap = value; } + }, + rejectionMapper: { + get: function () { return rejectionMapper; }, + set: function (value) { rejectionMapper = value; } + }, + follow: function (fn, zoneProps) { + return new DexiePromise(function (resolve, reject) { + return newScope(function (resolve, reject) { + var psd = PSD; + psd.unhandleds = []; + psd.onunhandled = reject; + psd.finalize = callBoth(function () { + var _this = this; + run_at_end_of_this_or_next_physical_tick(function () { + _this.unhandleds.length === 0 ? resolve() : reject(_this.unhandleds[0]); + }); + }, psd.finalize); + fn(); + }, zoneProps, resolve, reject); + }); + } +}); +if (NativePromise) { + if (NativePromise.allSettled) + setProp(DexiePromise, "allSettled", function () { + var possiblePromises = getArrayOf.apply(null, arguments).map(onPossibleParallellAsync); + return new DexiePromise(function (resolve) { + if (possiblePromises.length === 0) + resolve([]); + var remaining = possiblePromises.length; + var results = new Array(remaining); + possiblePromises.forEach(function (p, i) { return DexiePromise.resolve(p).then(function (value) { return results[i] = { status: "fulfilled", value: value }; }, function (reason) { return results[i] = { status: "rejected", reason: reason }; }) + .then(function () { return --remaining || resolve(results); }); }); + }); + }); + if (NativePromise.any && typeof AggregateError !== 'undefined') + setProp(DexiePromise, "any", function () { + var possiblePromises = getArrayOf.apply(null, arguments).map(onPossibleParallellAsync); + return new DexiePromise(function (resolve, reject) { + if (possiblePromises.length === 0) + reject(new AggregateError([])); + var remaining = possiblePromises.length; + var failures = new Array(remaining); + possiblePromises.forEach(function (p, i) { return DexiePromise.resolve(p).then(function (value) { return resolve(value); }, function (failure) { + failures[i] = failure; + if (!--remaining) + reject(new AggregateError(failures)); + }); }); + }); + }); +} +function executePromiseTask(promise, fn) { + try { + fn(function (value) { + if (promise._state !== null) + return; + if (value === promise) + throw new TypeError('A promise cannot be resolved with itself.'); + var shouldExecuteTick = promise._lib && beginMicroTickScope(); + if (value && typeof value.then === 'function') { + executePromiseTask(promise, function (resolve, reject) { + value instanceof DexiePromise ? + value._then(resolve, reject) : + value.then(resolve, reject); + }); + } + else { + promise._state = true; + promise._value = value; + propagateAllListeners(promise); + } + if (shouldExecuteTick) + endMicroTickScope(); + }, handleRejection.bind(null, promise)); + } + catch (ex) { + handleRejection(promise, ex); + } +} +function handleRejection(promise, reason) { + rejectingErrors.push(reason); + if (promise._state !== null) + return; + var shouldExecuteTick = promise._lib && beginMicroTickScope(); + reason = rejectionMapper(reason); + promise._state = false; + promise._value = reason; + debug && reason !== null && typeof reason === 'object' && !reason._promise && tryCatch(function () { + var origProp = getPropertyDescriptor(reason, "stack"); + reason._promise = promise; + setProp(reason, "stack", { + get: function () { + return stack_being_generated ? + origProp && (origProp.get ? + origProp.get.apply(reason) : + origProp.value) : + promise.stack; + } + }); + }); + addPossiblyUnhandledError(promise); + propagateAllListeners(promise); + if (shouldExecuteTick) + endMicroTickScope(); +} +function propagateAllListeners(promise) { + var listeners = promise._listeners; + promise._listeners = []; + for (var i = 0, len = listeners.length; i < len; ++i) { + propagateToListener(promise, listeners[i]); + } + var psd = promise._PSD; + --psd.ref || psd.finalize(); + if (numScheduledCalls === 0) { + ++numScheduledCalls; + asap(function () { + if (--numScheduledCalls === 0) + finalizePhysicalTick(); + }, []); + } +} +function propagateToListener(promise, listener) { + if (promise._state === null) { + promise._listeners.push(listener); + return; + } + var cb = promise._state ? listener.onFulfilled : listener.onRejected; + if (cb === null) { + return (promise._state ? listener.resolve : listener.reject)(promise._value); + } + ++listener.psd.ref; + ++numScheduledCalls; + asap(callListener, [cb, promise, listener]); +} +function callListener(cb, promise, listener) { + try { + currentFulfiller = promise; + var ret, value = promise._value; + if (promise._state) { + ret = cb(value); + } + else { + if (rejectingErrors.length) + rejectingErrors = []; + ret = cb(value); + if (rejectingErrors.indexOf(value) === -1) + markErrorAsHandled(promise); + } + listener.resolve(ret); + } + catch (e) { + listener.reject(e); + } + finally { + currentFulfiller = null; + if (--numScheduledCalls === 0) + finalizePhysicalTick(); + --listener.psd.ref || listener.psd.finalize(); + } +} +function getStack(promise, stacks, limit) { + if (stacks.length === limit) + return stacks; + var stack = ""; + if (promise._state === false) { + var failure = promise._value, errorName, message; + if (failure != null) { + errorName = failure.name || "Error"; + message = failure.message || failure; + stack = prettyStack(failure, 0); + } + else { + errorName = failure; + message = ""; + } + stacks.push(errorName + (message ? ": " + message : "") + stack); + } + if (debug) { + stack = prettyStack(promise._stackHolder, 2); + if (stack && stacks.indexOf(stack) === -1) + stacks.push(stack); + if (promise._prev) + getStack(promise._prev, stacks, limit); + } + return stacks; +} +function linkToPreviousPromise(promise, prev) { + var numPrev = prev ? prev._numPrev + 1 : 0; + if (numPrev < LONG_STACKS_CLIP_LIMIT) { + promise._prev = prev; + promise._numPrev = numPrev; + } +} +function physicalTick() { + beginMicroTickScope() && endMicroTickScope(); +} +function beginMicroTickScope() { + var wasRootExec = isOutsideMicroTick; + isOutsideMicroTick = false; + needsNewPhysicalTick = false; + return wasRootExec; +} +function endMicroTickScope() { + var callbacks, i, l; + do { + while (microtickQueue.length > 0) { + callbacks = microtickQueue; + microtickQueue = []; + l = callbacks.length; + for (i = 0; i < l; ++i) { + var item = callbacks[i]; + item[0].apply(null, item[1]); + } + } + } while (microtickQueue.length > 0); + isOutsideMicroTick = true; + needsNewPhysicalTick = true; +} +function finalizePhysicalTick() { + var unhandledErrs = unhandledErrors; + unhandledErrors = []; + unhandledErrs.forEach(function (p) { + p._PSD.onunhandled.call(null, p._value, p); + }); + var finalizers = tickFinalizers.slice(0); + var i = finalizers.length; + while (i) + finalizers[--i](); +} +function run_at_end_of_this_or_next_physical_tick(fn) { + function finalizer() { + fn(); + tickFinalizers.splice(tickFinalizers.indexOf(finalizer), 1); + } + tickFinalizers.push(finalizer); + ++numScheduledCalls; + asap(function () { + if (--numScheduledCalls === 0) + finalizePhysicalTick(); + }, []); +} +function addPossiblyUnhandledError(promise) { + if (!unhandledErrors.some(function (p) { return p._value === promise._value; })) + unhandledErrors.push(promise); +} +function markErrorAsHandled(promise) { + var i = unhandledErrors.length; + while (i) + if (unhandledErrors[--i]._value === promise._value) { + unhandledErrors.splice(i, 1); + return; + } +} +function PromiseReject(reason) { + return new DexiePromise(INTERNAL, false, reason); +} +function wrap(fn, errorCatcher) { + var psd = PSD; + return function () { + var wasRootExec = beginMicroTickScope(), outerScope = PSD; + try { + switchToZone(psd, true); + return fn.apply(this, arguments); + } + catch (e) { + errorCatcher && errorCatcher(e); + } + finally { + switchToZone(outerScope, false); + if (wasRootExec) + endMicroTickScope(); + } + }; +} +var task = { awaits: 0, echoes: 0, id: 0 }; +var taskCounter = 0; +var zoneStack = []; +var zoneEchoes = 0; +var totalEchoes = 0; +var zone_id_counter = 0; +function newScope(fn, props, a1, a2) { + var parent = PSD, psd = Object.create(parent); + psd.parent = parent; + psd.ref = 0; + psd.global = false; + psd.id = ++zone_id_counter; + var globalEnv = globalPSD.env; + psd.env = patchGlobalPromise ? { + Promise: DexiePromise, + PromiseProp: { value: DexiePromise, configurable: true, writable: true }, + all: DexiePromise.all, + race: DexiePromise.race, + allSettled: DexiePromise.allSettled, + any: DexiePromise.any, + resolve: DexiePromise.resolve, + reject: DexiePromise.reject, + nthen: getPatchedPromiseThen(globalEnv.nthen, psd), + gthen: getPatchedPromiseThen(globalEnv.gthen, psd) + } : {}; + if (props) + extend(psd, props); + ++parent.ref; + psd.finalize = function () { + --this.parent.ref || this.parent.finalize(); + }; + var rv = usePSD(psd, fn, a1, a2); + if (psd.ref === 0) + psd.finalize(); + return rv; +} +function incrementExpectedAwaits() { + if (!task.id) + task.id = ++taskCounter; + ++task.awaits; + task.echoes += ZONE_ECHO_LIMIT; + return task.id; +} +function decrementExpectedAwaits() { + if (!task.awaits) + return false; + if (--task.awaits === 0) + task.id = 0; + task.echoes = task.awaits * ZONE_ECHO_LIMIT; + return true; +} +if (('' + nativePromiseThen).indexOf('[native code]') === -1) { + incrementExpectedAwaits = decrementExpectedAwaits = nop; +} +function onPossibleParallellAsync(possiblePromise) { + if (task.echoes && possiblePromise && possiblePromise.constructor === NativePromise) { + incrementExpectedAwaits(); + return possiblePromise.then(function (x) { + decrementExpectedAwaits(); + return x; + }, function (e) { + decrementExpectedAwaits(); + return rejection(e); + }); + } + return possiblePromise; +} +function zoneEnterEcho(targetZone) { + ++totalEchoes; + if (!task.echoes || --task.echoes === 0) { + task.echoes = task.id = 0; + } + zoneStack.push(PSD); + switchToZone(targetZone, true); +} +function zoneLeaveEcho() { + var zone = zoneStack[zoneStack.length - 1]; + zoneStack.pop(); + switchToZone(zone, false); +} +function switchToZone(targetZone, bEnteringZone) { + var currentZone = PSD; + if (bEnteringZone ? task.echoes && (!zoneEchoes++ || targetZone !== PSD) : zoneEchoes && (!--zoneEchoes || targetZone !== PSD)) { + enqueueNativeMicroTask(bEnteringZone ? zoneEnterEcho.bind(null, targetZone) : zoneLeaveEcho); + } + if (targetZone === PSD) + return; + PSD = targetZone; + if (currentZone === globalPSD) + globalPSD.env = snapShot(); + if (patchGlobalPromise) { + var GlobalPromise_1 = globalPSD.env.Promise; + var targetEnv = targetZone.env; + nativePromiseProto.then = targetEnv.nthen; + GlobalPromise_1.prototype.then = targetEnv.gthen; + if (currentZone.global || targetZone.global) { + Object.defineProperty(_global, 'Promise', targetEnv.PromiseProp); + GlobalPromise_1.all = targetEnv.all; + GlobalPromise_1.race = targetEnv.race; + GlobalPromise_1.resolve = targetEnv.resolve; + GlobalPromise_1.reject = targetEnv.reject; + if (targetEnv.allSettled) + GlobalPromise_1.allSettled = targetEnv.allSettled; + if (targetEnv.any) + GlobalPromise_1.any = targetEnv.any; + } + } +} +function snapShot() { + var GlobalPromise = _global.Promise; + return patchGlobalPromise ? { + Promise: GlobalPromise, + PromiseProp: Object.getOwnPropertyDescriptor(_global, "Promise"), + all: GlobalPromise.all, + race: GlobalPromise.race, + allSettled: GlobalPromise.allSettled, + any: GlobalPromise.any, + resolve: GlobalPromise.resolve, + reject: GlobalPromise.reject, + nthen: nativePromiseProto.then, + gthen: GlobalPromise.prototype.then + } : {}; +} +function usePSD(psd, fn, a1, a2, a3) { + var outerScope = PSD; + try { + switchToZone(psd, true); + return fn(a1, a2, a3); + } + finally { + switchToZone(outerScope, false); + } +} +function enqueueNativeMicroTask(job) { + nativePromiseThen.call(resolvedNativePromise, job); +} +function nativeAwaitCompatibleWrap(fn, zone, possibleAwait, cleanup) { + return typeof fn !== 'function' ? fn : function () { + var outerZone = PSD; + if (possibleAwait) + incrementExpectedAwaits(); + switchToZone(zone, true); + try { + return fn.apply(this, arguments); + } + finally { + switchToZone(outerZone, false); + if (cleanup) + enqueueNativeMicroTask(decrementExpectedAwaits); + } + }; +} +function getPatchedPromiseThen(origThen, zone) { + return function (onResolved, onRejected) { + return origThen.call(this, nativeAwaitCompatibleWrap(onResolved, zone), nativeAwaitCompatibleWrap(onRejected, zone)); + }; +} +var UNHANDLEDREJECTION = "unhandledrejection"; +function globalError(err, promise) { + var rv; + try { + rv = promise.onuncatched(err); + } + catch (e) { } + if (rv !== false) + try { + var event, eventData = { promise: promise, reason: err }; + if (_global.document && document.createEvent) { + event = document.createEvent('Event'); + event.initEvent(UNHANDLEDREJECTION, true, true); + extend(event, eventData); + } + else if (_global.CustomEvent) { + event = new CustomEvent(UNHANDLEDREJECTION, { detail: eventData }); + extend(event, eventData); + } + if (event && _global.dispatchEvent) { + dispatchEvent(event); + if (!_global.PromiseRejectionEvent && _global.onunhandledrejection) + try { + _global.onunhandledrejection(event); + } + catch (_) { } + } + if (debug && event && !event.defaultPrevented) { + console.warn("Unhandled rejection: " + (err.stack || err)); + } + } + catch (e) { } +} +var rejection = DexiePromise.reject; + +function tempTransaction(db, mode, storeNames, fn) { + if (!db.idbdb || (!db._state.openComplete && (!PSD.letThrough && !db._vip))) { + if (db._state.openComplete) { + return rejection(new exceptions.DatabaseClosed(db._state.dbOpenError)); + } + if (!db._state.isBeingOpened) { + if (!db._options.autoOpen) + return rejection(new exceptions.DatabaseClosed()); + db.open().catch(nop); + } + return db._state.dbReadyPromise.then(function () { return tempTransaction(db, mode, storeNames, fn); }); + } + else { + var trans = db._createTransaction(mode, storeNames, db._dbSchema); + try { + trans.create(); + db._state.PR1398_maxLoop = 3; + } + catch (ex) { + if (ex.name === errnames.InvalidState && db.isOpen() && --db._state.PR1398_maxLoop > 0) { + console.warn('Dexie: Need to reopen db'); + db._close(); + return db.open().then(function () { return tempTransaction(db, mode, storeNames, fn); }); + } + return rejection(ex); + } + return trans._promise(mode, function (resolve, reject) { + return newScope(function () { + PSD.trans = trans; + return fn(resolve, reject, trans); + }); + }).then(function (result) { + return trans._completion.then(function () { return result; }); + }); + } +} + +var DEXIE_VERSION = '3.2.2'; +var maxString = String.fromCharCode(65535); +var minKey = -Infinity; +var INVALID_KEY_ARGUMENT = "Invalid key provided. Keys must be of type string, number, Date or Array."; +var STRING_EXPECTED = "String expected."; +var connections = []; +var isIEOrEdge = typeof navigator !== 'undefined' && /(MSIE|Trident|Edge)/.test(navigator.userAgent); +var hasIEDeleteObjectStoreBug = isIEOrEdge; +var hangsOnDeleteLargeKeyRange = isIEOrEdge; +var dexieStackFrameFilter = function (frame) { return !/(dexie\.js|dexie\.min\.js)/.test(frame); }; +var DBNAMES_DB = '__dbnames'; +var READONLY = 'readonly'; +var READWRITE = 'readwrite'; + +function combine(filter1, filter2) { + return filter1 ? + filter2 ? + function () { return filter1.apply(this, arguments) && filter2.apply(this, arguments); } : + filter1 : + filter2; +} + +var AnyRange = { + type: 3 , + lower: -Infinity, + lowerOpen: false, + upper: [[]], + upperOpen: false +}; + +function workaroundForUndefinedPrimKey(keyPath) { + return typeof keyPath === "string" && !/\./.test(keyPath) + ? function (obj) { + if (obj[keyPath] === undefined && (keyPath in obj)) { + obj = deepClone(obj); + delete obj[keyPath]; + } + return obj; + } + : function (obj) { return obj; }; +} + +var Table = (function () { + function Table() { + } + Table.prototype._trans = function (mode, fn, writeLocked) { + var trans = this._tx || PSD.trans; + var tableName = this.name; + function checkTableInTransaction(resolve, reject, trans) { + if (!trans.schema[tableName]) + throw new exceptions.NotFound("Table " + tableName + " not part of transaction"); + return fn(trans.idbtrans, trans); + } + var wasRootExec = beginMicroTickScope(); + try { + return trans && trans.db === this.db ? + trans === PSD.trans ? + trans._promise(mode, checkTableInTransaction, writeLocked) : + newScope(function () { return trans._promise(mode, checkTableInTransaction, writeLocked); }, { trans: trans, transless: PSD.transless || PSD }) : + tempTransaction(this.db, mode, [this.name], checkTableInTransaction); + } + finally { + if (wasRootExec) + endMicroTickScope(); + } + }; + Table.prototype.get = function (keyOrCrit, cb) { + var _this = this; + if (keyOrCrit && keyOrCrit.constructor === Object) + return this.where(keyOrCrit).first(cb); + return this._trans('readonly', function (trans) { + return _this.core.get({ trans: trans, key: keyOrCrit }) + .then(function (res) { return _this.hook.reading.fire(res); }); + }).then(cb); + }; + Table.prototype.where = function (indexOrCrit) { + if (typeof indexOrCrit === 'string') + return new this.db.WhereClause(this, indexOrCrit); + if (isArray(indexOrCrit)) + return new this.db.WhereClause(this, "[" + indexOrCrit.join('+') + "]"); + var keyPaths = keys(indexOrCrit); + if (keyPaths.length === 1) + return this + .where(keyPaths[0]) + .equals(indexOrCrit[keyPaths[0]]); + var compoundIndex = this.schema.indexes.concat(this.schema.primKey).filter(function (ix) { + return ix.compound && + keyPaths.every(function (keyPath) { return ix.keyPath.indexOf(keyPath) >= 0; }) && + ix.keyPath.every(function (keyPath) { return keyPaths.indexOf(keyPath) >= 0; }); + })[0]; + if (compoundIndex && this.db._maxKey !== maxString) + return this + .where(compoundIndex.name) + .equals(compoundIndex.keyPath.map(function (kp) { return indexOrCrit[kp]; })); + if (!compoundIndex && debug) + console.warn("The query " + JSON.stringify(indexOrCrit) + " on " + this.name + " would benefit of a " + + ("compound index [" + keyPaths.join('+') + "]")); + var idxByName = this.schema.idxByName; + var idb = this.db._deps.indexedDB; + function equals(a, b) { + try { + return idb.cmp(a, b) === 0; + } + catch (e) { + return false; + } + } + var _a = keyPaths.reduce(function (_a, keyPath) { + var prevIndex = _a[0], prevFilterFn = _a[1]; + var index = idxByName[keyPath]; + var value = indexOrCrit[keyPath]; + return [ + prevIndex || index, + prevIndex || !index ? + combine(prevFilterFn, index && index.multi ? + function (x) { + var prop = getByKeyPath(x, keyPath); + return isArray(prop) && prop.some(function (item) { return equals(value, item); }); + } : function (x) { return equals(value, getByKeyPath(x, keyPath)); }) + : prevFilterFn + ]; + }, [null, null]), idx = _a[0], filterFunction = _a[1]; + return idx ? + this.where(idx.name).equals(indexOrCrit[idx.keyPath]) + .filter(filterFunction) : + compoundIndex ? + this.filter(filterFunction) : + this.where(keyPaths).equals(''); + }; + Table.prototype.filter = function (filterFunction) { + return this.toCollection().and(filterFunction); + }; + Table.prototype.count = function (thenShortcut) { + return this.toCollection().count(thenShortcut); + }; + Table.prototype.offset = function (offset) { + return this.toCollection().offset(offset); + }; + Table.prototype.limit = function (numRows) { + return this.toCollection().limit(numRows); + }; + Table.prototype.each = function (callback) { + return this.toCollection().each(callback); + }; + Table.prototype.toArray = function (thenShortcut) { + return this.toCollection().toArray(thenShortcut); + }; + Table.prototype.toCollection = function () { + return new this.db.Collection(new this.db.WhereClause(this)); + }; + Table.prototype.orderBy = function (index) { + return new this.db.Collection(new this.db.WhereClause(this, isArray(index) ? + "[" + index.join('+') + "]" : + index)); + }; + Table.prototype.reverse = function () { + return this.toCollection().reverse(); + }; + Table.prototype.mapToClass = function (constructor) { + this.schema.mappedClass = constructor; + var readHook = function (obj) { + if (!obj) + return obj; + var res = Object.create(constructor.prototype); + for (var m in obj) + if (hasOwn(obj, m)) + try { + res[m] = obj[m]; + } + catch (_) { } + return res; + }; + if (this.schema.readHook) { + this.hook.reading.unsubscribe(this.schema.readHook); + } + this.schema.readHook = readHook; + this.hook("reading", readHook); + return constructor; + }; + Table.prototype.defineClass = function () { + function Class(content) { + extend(this, content); + } + return this.mapToClass(Class); + }; + Table.prototype.add = function (obj, key) { + var _this = this; + var _a = this.schema.primKey, auto = _a.auto, keyPath = _a.keyPath; + var objToAdd = obj; + if (keyPath && auto) { + objToAdd = workaroundForUndefinedPrimKey(keyPath)(obj); + } + return this._trans('readwrite', function (trans) { + return _this.core.mutate({ trans: trans, type: 'add', keys: key != null ? [key] : null, values: [objToAdd] }); + }).then(function (res) { return res.numFailures ? DexiePromise.reject(res.failures[0]) : res.lastResult; }) + .then(function (lastResult) { + if (keyPath) { + try { + setByKeyPath(obj, keyPath, lastResult); + } + catch (_) { } + } + return lastResult; + }); + }; + Table.prototype.update = function (keyOrObject, modifications) { + if (typeof keyOrObject === 'object' && !isArray(keyOrObject)) { + var key = getByKeyPath(keyOrObject, this.schema.primKey.keyPath); + if (key === undefined) + return rejection(new exceptions.InvalidArgument("Given object does not contain its primary key")); + try { + if (typeof modifications !== "function") { + keys(modifications).forEach(function (keyPath) { + setByKeyPath(keyOrObject, keyPath, modifications[keyPath]); + }); + } + else { + modifications(keyOrObject, { value: keyOrObject, primKey: key }); + } + } + catch (_a) { + } + return this.where(":id").equals(key).modify(modifications); + } + else { + return this.where(":id").equals(keyOrObject).modify(modifications); + } + }; + Table.prototype.put = function (obj, key) { + var _this = this; + var _a = this.schema.primKey, auto = _a.auto, keyPath = _a.keyPath; + var objToAdd = obj; + if (keyPath && auto) { + objToAdd = workaroundForUndefinedPrimKey(keyPath)(obj); + } + return this._trans('readwrite', function (trans) { return _this.core.mutate({ trans: trans, type: 'put', values: [objToAdd], keys: key != null ? [key] : null }); }) + .then(function (res) { return res.numFailures ? DexiePromise.reject(res.failures[0]) : res.lastResult; }) + .then(function (lastResult) { + if (keyPath) { + try { + setByKeyPath(obj, keyPath, lastResult); + } + catch (_) { } + } + return lastResult; + }); + }; + Table.prototype.delete = function (key) { + var _this = this; + return this._trans('readwrite', function (trans) { return _this.core.mutate({ trans: trans, type: 'delete', keys: [key] }); }) + .then(function (res) { return res.numFailures ? DexiePromise.reject(res.failures[0]) : undefined; }); + }; + Table.prototype.clear = function () { + var _this = this; + return this._trans('readwrite', function (trans) { return _this.core.mutate({ trans: trans, type: 'deleteRange', range: AnyRange }); }) + .then(function (res) { return res.numFailures ? DexiePromise.reject(res.failures[0]) : undefined; }); + }; + Table.prototype.bulkGet = function (keys) { + var _this = this; + return this._trans('readonly', function (trans) { + return _this.core.getMany({ + keys: keys, + trans: trans + }).then(function (result) { return result.map(function (res) { return _this.hook.reading.fire(res); }); }); + }); + }; + Table.prototype.bulkAdd = function (objects, keysOrOptions, options) { + var _this = this; + var keys = Array.isArray(keysOrOptions) ? keysOrOptions : undefined; + options = options || (keys ? undefined : keysOrOptions); + var wantResults = options ? options.allKeys : undefined; + return this._trans('readwrite', function (trans) { + var _a = _this.schema.primKey, auto = _a.auto, keyPath = _a.keyPath; + if (keyPath && keys) + throw new exceptions.InvalidArgument("bulkAdd(): keys argument invalid on tables with inbound keys"); + if (keys && keys.length !== objects.length) + throw new exceptions.InvalidArgument("Arguments objects and keys must have the same length"); + var numObjects = objects.length; + var objectsToAdd = keyPath && auto ? + objects.map(workaroundForUndefinedPrimKey(keyPath)) : + objects; + return _this.core.mutate({ trans: trans, type: 'add', keys: keys, values: objectsToAdd, wantResults: wantResults }) + .then(function (_a) { + var numFailures = _a.numFailures, results = _a.results, lastResult = _a.lastResult, failures = _a.failures; + var result = wantResults ? results : lastResult; + if (numFailures === 0) + return result; + throw new BulkError(_this.name + ".bulkAdd(): " + numFailures + " of " + numObjects + " operations failed", failures); + }); + }); + }; + Table.prototype.bulkPut = function (objects, keysOrOptions, options) { + var _this = this; + var keys = Array.isArray(keysOrOptions) ? keysOrOptions : undefined; + options = options || (keys ? undefined : keysOrOptions); + var wantResults = options ? options.allKeys : undefined; + return this._trans('readwrite', function (trans) { + var _a = _this.schema.primKey, auto = _a.auto, keyPath = _a.keyPath; + if (keyPath && keys) + throw new exceptions.InvalidArgument("bulkPut(): keys argument invalid on tables with inbound keys"); + if (keys && keys.length !== objects.length) + throw new exceptions.InvalidArgument("Arguments objects and keys must have the same length"); + var numObjects = objects.length; + var objectsToPut = keyPath && auto ? + objects.map(workaroundForUndefinedPrimKey(keyPath)) : + objects; + return _this.core.mutate({ trans: trans, type: 'put', keys: keys, values: objectsToPut, wantResults: wantResults }) + .then(function (_a) { + var numFailures = _a.numFailures, results = _a.results, lastResult = _a.lastResult, failures = _a.failures; + var result = wantResults ? results : lastResult; + if (numFailures === 0) + return result; + throw new BulkError(_this.name + ".bulkPut(): " + numFailures + " of " + numObjects + " operations failed", failures); + }); + }); + }; + Table.prototype.bulkDelete = function (keys) { + var _this = this; + var numKeys = keys.length; + return this._trans('readwrite', function (trans) { + return _this.core.mutate({ trans: trans, type: 'delete', keys: keys }); + }).then(function (_a) { + var numFailures = _a.numFailures, lastResult = _a.lastResult, failures = _a.failures; + if (numFailures === 0) + return lastResult; + throw new BulkError(_this.name + ".bulkDelete(): " + numFailures + " of " + numKeys + " operations failed", failures); + }); + }; + return Table; +}()); + +function Events(ctx) { + var evs = {}; + var rv = function (eventName, subscriber) { + if (subscriber) { + var i = arguments.length, args = new Array(i - 1); + while (--i) + args[i - 1] = arguments[i]; + evs[eventName].subscribe.apply(null, args); + return ctx; + } + else if (typeof (eventName) === 'string') { + return evs[eventName]; + } + }; + rv.addEventType = add; + for (var i = 1, l = arguments.length; i < l; ++i) { + add(arguments[i]); + } + return rv; + function add(eventName, chainFunction, defaultFunction) { + if (typeof eventName === 'object') + return addConfiguredEvents(eventName); + if (!chainFunction) + chainFunction = reverseStoppableEventChain; + if (!defaultFunction) + defaultFunction = nop; + var context = { + subscribers: [], + fire: defaultFunction, + subscribe: function (cb) { + if (context.subscribers.indexOf(cb) === -1) { + context.subscribers.push(cb); + context.fire = chainFunction(context.fire, cb); + } + }, + unsubscribe: function (cb) { + context.subscribers = context.subscribers.filter(function (fn) { return fn !== cb; }); + context.fire = context.subscribers.reduce(chainFunction, defaultFunction); + } + }; + evs[eventName] = rv[eventName] = context; + return context; + } + function addConfiguredEvents(cfg) { + keys(cfg).forEach(function (eventName) { + var args = cfg[eventName]; + if (isArray(args)) { + add(eventName, cfg[eventName][0], cfg[eventName][1]); + } + else if (args === 'asap') { + var context = add(eventName, mirror, function fire() { + var i = arguments.length, args = new Array(i); + while (i--) + args[i] = arguments[i]; + context.subscribers.forEach(function (fn) { + asap$1(function fireEvent() { + fn.apply(null, args); + }); + }); + }); + } + else + throw new exceptions.InvalidArgument("Invalid event config"); + }); + } +} + +function makeClassConstructor(prototype, constructor) { + derive(constructor).from({ prototype: prototype }); + return constructor; +} + +function createTableConstructor(db) { + return makeClassConstructor(Table.prototype, function Table(name, tableSchema, trans) { + this.db = db; + this._tx = trans; + this.name = name; + this.schema = tableSchema; + this.hook = db._allTables[name] ? db._allTables[name].hook : Events(null, { + "creating": [hookCreatingChain, nop], + "reading": [pureFunctionChain, mirror], + "updating": [hookUpdatingChain, nop], + "deleting": [hookDeletingChain, nop] + }); + }); +} + +function isPlainKeyRange(ctx, ignoreLimitFilter) { + return !(ctx.filter || ctx.algorithm || ctx.or) && + (ignoreLimitFilter ? ctx.justLimit : !ctx.replayFilter); +} +function addFilter(ctx, fn) { + ctx.filter = combine(ctx.filter, fn); +} +function addReplayFilter(ctx, factory, isLimitFilter) { + var curr = ctx.replayFilter; + ctx.replayFilter = curr ? function () { return combine(curr(), factory()); } : factory; + ctx.justLimit = isLimitFilter && !curr; +} +function addMatchFilter(ctx, fn) { + ctx.isMatch = combine(ctx.isMatch, fn); +} +function getIndexOrStore(ctx, coreSchema) { + if (ctx.isPrimKey) + return coreSchema.primaryKey; + var index = coreSchema.getIndexByKeyPath(ctx.index); + if (!index) + throw new exceptions.Schema("KeyPath " + ctx.index + " on object store " + coreSchema.name + " is not indexed"); + return index; +} +function openCursor(ctx, coreTable, trans) { + var index = getIndexOrStore(ctx, coreTable.schema); + return coreTable.openCursor({ + trans: trans, + values: !ctx.keysOnly, + reverse: ctx.dir === 'prev', + unique: !!ctx.unique, + query: { + index: index, + range: ctx.range + } + }); +} +function iter(ctx, fn, coreTrans, coreTable) { + var filter = ctx.replayFilter ? combine(ctx.filter, ctx.replayFilter()) : ctx.filter; + if (!ctx.or) { + return iterate(openCursor(ctx, coreTable, coreTrans), combine(ctx.algorithm, filter), fn, !ctx.keysOnly && ctx.valueMapper); + } + else { + var set_1 = {}; + var union = function (item, cursor, advance) { + if (!filter || filter(cursor, advance, function (result) { return cursor.stop(result); }, function (err) { return cursor.fail(err); })) { + var primaryKey = cursor.primaryKey; + var key = '' + primaryKey; + if (key === '[object ArrayBuffer]') + key = '' + new Uint8Array(primaryKey); + if (!hasOwn(set_1, key)) { + set_1[key] = true; + fn(item, cursor, advance); + } + } + }; + return Promise.all([ + ctx.or._iterate(union, coreTrans), + iterate(openCursor(ctx, coreTable, coreTrans), ctx.algorithm, union, !ctx.keysOnly && ctx.valueMapper) + ]); + } +} +function iterate(cursorPromise, filter, fn, valueMapper) { + var mappedFn = valueMapper ? function (x, c, a) { return fn(valueMapper(x), c, a); } : fn; + var wrappedFn = wrap(mappedFn); + return cursorPromise.then(function (cursor) { + if (cursor) { + return cursor.start(function () { + var c = function () { return cursor.continue(); }; + if (!filter || filter(cursor, function (advancer) { return c = advancer; }, function (val) { cursor.stop(val); c = nop; }, function (e) { cursor.fail(e); c = nop; })) + wrappedFn(cursor.value, cursor, function (advancer) { return c = advancer; }); + c(); + }); + } + }); +} + +function cmp(a, b) { + try { + var ta = type(a); + var tb = type(b); + if (ta !== tb) { + if (ta === 'Array') + return 1; + if (tb === 'Array') + return -1; + if (ta === 'binary') + return 1; + if (tb === 'binary') + return -1; + if (ta === 'string') + return 1; + if (tb === 'string') + return -1; + if (ta === 'Date') + return 1; + if (tb !== 'Date') + return NaN; + return -1; + } + switch (ta) { + case 'number': + case 'Date': + case 'string': + return a > b ? 1 : a < b ? -1 : 0; + case 'binary': { + return compareUint8Arrays(getUint8Array(a), getUint8Array(b)); + } + case 'Array': + return compareArrays(a, b); + } + } + catch (_a) { } + return NaN; +} +function compareArrays(a, b) { + var al = a.length; + var bl = b.length; + var l = al < bl ? al : bl; + for (var i = 0; i < l; ++i) { + var res = cmp(a[i], b[i]); + if (res !== 0) + return res; + } + return al === bl ? 0 : al < bl ? -1 : 1; +} +function compareUint8Arrays(a, b) { + var al = a.length; + var bl = b.length; + var l = al < bl ? al : bl; + for (var i = 0; i < l; ++i) { + if (a[i] !== b[i]) + return a[i] < b[i] ? -1 : 1; + } + return al === bl ? 0 : al < bl ? -1 : 1; +} +function type(x) { + var t = typeof x; + if (t !== 'object') + return t; + if (ArrayBuffer.isView(x)) + return 'binary'; + var tsTag = toStringTag(x); + return tsTag === 'ArrayBuffer' ? 'binary' : tsTag; +} +function getUint8Array(a) { + if (a instanceof Uint8Array) + return a; + if (ArrayBuffer.isView(a)) + return new Uint8Array(a.buffer, a.byteOffset, a.byteLength); + return new Uint8Array(a); +} + +var Collection = (function () { + function Collection() { + } + Collection.prototype._read = function (fn, cb) { + var ctx = this._ctx; + return ctx.error ? + ctx.table._trans(null, rejection.bind(null, ctx.error)) : + ctx.table._trans('readonly', fn).then(cb); + }; + Collection.prototype._write = function (fn) { + var ctx = this._ctx; + return ctx.error ? + ctx.table._trans(null, rejection.bind(null, ctx.error)) : + ctx.table._trans('readwrite', fn, "locked"); + }; + Collection.prototype._addAlgorithm = function (fn) { + var ctx = this._ctx; + ctx.algorithm = combine(ctx.algorithm, fn); + }; + Collection.prototype._iterate = function (fn, coreTrans) { + return iter(this._ctx, fn, coreTrans, this._ctx.table.core); + }; + Collection.prototype.clone = function (props) { + var rv = Object.create(this.constructor.prototype), ctx = Object.create(this._ctx); + if (props) + extend(ctx, props); + rv._ctx = ctx; + return rv; + }; + Collection.prototype.raw = function () { + this._ctx.valueMapper = null; + return this; + }; + Collection.prototype.each = function (fn) { + var ctx = this._ctx; + return this._read(function (trans) { return iter(ctx, fn, trans, ctx.table.core); }); + }; + Collection.prototype.count = function (cb) { + var _this = this; + return this._read(function (trans) { + var ctx = _this._ctx; + var coreTable = ctx.table.core; + if (isPlainKeyRange(ctx, true)) { + return coreTable.count({ + trans: trans, + query: { + index: getIndexOrStore(ctx, coreTable.schema), + range: ctx.range + } + }).then(function (count) { return Math.min(count, ctx.limit); }); + } + else { + var count = 0; + return iter(ctx, function () { ++count; return false; }, trans, coreTable) + .then(function () { return count; }); + } + }).then(cb); + }; + Collection.prototype.sortBy = function (keyPath, cb) { + var parts = keyPath.split('.').reverse(), lastPart = parts[0], lastIndex = parts.length - 1; + function getval(obj, i) { + if (i) + return getval(obj[parts[i]], i - 1); + return obj[lastPart]; + } + var order = this._ctx.dir === "next" ? 1 : -1; + function sorter(a, b) { + var aVal = getval(a, lastIndex), bVal = getval(b, lastIndex); + return aVal < bVal ? -order : aVal > bVal ? order : 0; + } + return this.toArray(function (a) { + return a.sort(sorter); + }).then(cb); + }; + Collection.prototype.toArray = function (cb) { + var _this = this; + return this._read(function (trans) { + var ctx = _this._ctx; + if (ctx.dir === 'next' && isPlainKeyRange(ctx, true) && ctx.limit > 0) { + var valueMapper_1 = ctx.valueMapper; + var index = getIndexOrStore(ctx, ctx.table.core.schema); + return ctx.table.core.query({ + trans: trans, + limit: ctx.limit, + values: true, + query: { + index: index, + range: ctx.range + } + }).then(function (_a) { + var result = _a.result; + return valueMapper_1 ? result.map(valueMapper_1) : result; + }); + } + else { + var a_1 = []; + return iter(ctx, function (item) { return a_1.push(item); }, trans, ctx.table.core).then(function () { return a_1; }); + } + }, cb); + }; + Collection.prototype.offset = function (offset) { + var ctx = this._ctx; + if (offset <= 0) + return this; + ctx.offset += offset; + if (isPlainKeyRange(ctx)) { + addReplayFilter(ctx, function () { + var offsetLeft = offset; + return function (cursor, advance) { + if (offsetLeft === 0) + return true; + if (offsetLeft === 1) { + --offsetLeft; + return false; + } + advance(function () { + cursor.advance(offsetLeft); + offsetLeft = 0; + }); + return false; + }; + }); + } + else { + addReplayFilter(ctx, function () { + var offsetLeft = offset; + return function () { return (--offsetLeft < 0); }; + }); + } + return this; + }; + Collection.prototype.limit = function (numRows) { + this._ctx.limit = Math.min(this._ctx.limit, numRows); + addReplayFilter(this._ctx, function () { + var rowsLeft = numRows; + return function (cursor, advance, resolve) { + if (--rowsLeft <= 0) + advance(resolve); + return rowsLeft >= 0; + }; + }, true); + return this; + }; + Collection.prototype.until = function (filterFunction, bIncludeStopEntry) { + addFilter(this._ctx, function (cursor, advance, resolve) { + if (filterFunction(cursor.value)) { + advance(resolve); + return bIncludeStopEntry; + } + else { + return true; + } + }); + return this; + }; + Collection.prototype.first = function (cb) { + return this.limit(1).toArray(function (a) { return a[0]; }).then(cb); + }; + Collection.prototype.last = function (cb) { + return this.reverse().first(cb); + }; + Collection.prototype.filter = function (filterFunction) { + addFilter(this._ctx, function (cursor) { + return filterFunction(cursor.value); + }); + addMatchFilter(this._ctx, filterFunction); + return this; + }; + Collection.prototype.and = function (filter) { + return this.filter(filter); + }; + Collection.prototype.or = function (indexName) { + return new this.db.WhereClause(this._ctx.table, indexName, this); + }; + Collection.prototype.reverse = function () { + this._ctx.dir = (this._ctx.dir === "prev" ? "next" : "prev"); + if (this._ondirectionchange) + this._ondirectionchange(this._ctx.dir); + return this; + }; + Collection.prototype.desc = function () { + return this.reverse(); + }; + Collection.prototype.eachKey = function (cb) { + var ctx = this._ctx; + ctx.keysOnly = !ctx.isMatch; + return this.each(function (val, cursor) { cb(cursor.key, cursor); }); + }; + Collection.prototype.eachUniqueKey = function (cb) { + this._ctx.unique = "unique"; + return this.eachKey(cb); + }; + Collection.prototype.eachPrimaryKey = function (cb) { + var ctx = this._ctx; + ctx.keysOnly = !ctx.isMatch; + return this.each(function (val, cursor) { cb(cursor.primaryKey, cursor); }); + }; + Collection.prototype.keys = function (cb) { + var ctx = this._ctx; + ctx.keysOnly = !ctx.isMatch; + var a = []; + return this.each(function (item, cursor) { + a.push(cursor.key); + }).then(function () { + return a; + }).then(cb); + }; + Collection.prototype.primaryKeys = function (cb) { + var ctx = this._ctx; + if (ctx.dir === 'next' && isPlainKeyRange(ctx, true) && ctx.limit > 0) { + return this._read(function (trans) { + var index = getIndexOrStore(ctx, ctx.table.core.schema); + return ctx.table.core.query({ + trans: trans, + values: false, + limit: ctx.limit, + query: { + index: index, + range: ctx.range + } + }); + }).then(function (_a) { + var result = _a.result; + return result; + }).then(cb); + } + ctx.keysOnly = !ctx.isMatch; + var a = []; + return this.each(function (item, cursor) { + a.push(cursor.primaryKey); + }).then(function () { + return a; + }).then(cb); + }; + Collection.prototype.uniqueKeys = function (cb) { + this._ctx.unique = "unique"; + return this.keys(cb); + }; + Collection.prototype.firstKey = function (cb) { + return this.limit(1).keys(function (a) { return a[0]; }).then(cb); + }; + Collection.prototype.lastKey = function (cb) { + return this.reverse().firstKey(cb); + }; + Collection.prototype.distinct = function () { + var ctx = this._ctx, idx = ctx.index && ctx.table.schema.idxByName[ctx.index]; + if (!idx || !idx.multi) + return this; + var set = {}; + addFilter(this._ctx, function (cursor) { + var strKey = cursor.primaryKey.toString(); + var found = hasOwn(set, strKey); + set[strKey] = true; + return !found; + }); + return this; + }; + Collection.prototype.modify = function (changes) { + var _this = this; + var ctx = this._ctx; + return this._write(function (trans) { + var modifyer; + if (typeof changes === 'function') { + modifyer = changes; + } + else { + var keyPaths = keys(changes); + var numKeys = keyPaths.length; + modifyer = function (item) { + var anythingModified = false; + for (var i = 0; i < numKeys; ++i) { + var keyPath = keyPaths[i], val = changes[keyPath]; + if (getByKeyPath(item, keyPath) !== val) { + setByKeyPath(item, keyPath, val); + anythingModified = true; + } + } + return anythingModified; + }; + } + var coreTable = ctx.table.core; + var _a = coreTable.schema.primaryKey, outbound = _a.outbound, extractKey = _a.extractKey; + var limit = _this.db._options.modifyChunkSize || 200; + var totalFailures = []; + var successCount = 0; + var failedKeys = []; + var applyMutateResult = function (expectedCount, res) { + var failures = res.failures, numFailures = res.numFailures; + successCount += expectedCount - numFailures; + for (var _i = 0, _a = keys(failures); _i < _a.length; _i++) { + var pos = _a[_i]; + totalFailures.push(failures[pos]); + } + }; + return _this.clone().primaryKeys().then(function (keys) { + var nextChunk = function (offset) { + var count = Math.min(limit, keys.length - offset); + return coreTable.getMany({ + trans: trans, + keys: keys.slice(offset, offset + count), + cache: "immutable" + }).then(function (values) { + var addValues = []; + var putValues = []; + var putKeys = outbound ? [] : null; + var deleteKeys = []; + for (var i = 0; i < count; ++i) { + var origValue = values[i]; + var ctx_1 = { + value: deepClone(origValue), + primKey: keys[offset + i] + }; + if (modifyer.call(ctx_1, ctx_1.value, ctx_1) !== false) { + if (ctx_1.value == null) { + deleteKeys.push(keys[offset + i]); + } + else if (!outbound && cmp(extractKey(origValue), extractKey(ctx_1.value)) !== 0) { + deleteKeys.push(keys[offset + i]); + addValues.push(ctx_1.value); + } + else { + putValues.push(ctx_1.value); + if (outbound) + putKeys.push(keys[offset + i]); + } + } + } + var criteria = isPlainKeyRange(ctx) && + ctx.limit === Infinity && + (typeof changes !== 'function' || changes === deleteCallback) && { + index: ctx.index, + range: ctx.range + }; + return Promise.resolve(addValues.length > 0 && + coreTable.mutate({ trans: trans, type: 'add', values: addValues }) + .then(function (res) { + for (var pos in res.failures) { + deleteKeys.splice(parseInt(pos), 1); + } + applyMutateResult(addValues.length, res); + })).then(function () { return (putValues.length > 0 || (criteria && typeof changes === 'object')) && + coreTable.mutate({ + trans: trans, + type: 'put', + keys: putKeys, + values: putValues, + criteria: criteria, + changeSpec: typeof changes !== 'function' + && changes + }).then(function (res) { return applyMutateResult(putValues.length, res); }); }).then(function () { return (deleteKeys.length > 0 || (criteria && changes === deleteCallback)) && + coreTable.mutate({ + trans: trans, + type: 'delete', + keys: deleteKeys, + criteria: criteria + }).then(function (res) { return applyMutateResult(deleteKeys.length, res); }); }).then(function () { + return keys.length > offset + count && nextChunk(offset + limit); + }); + }); + }; + return nextChunk(0).then(function () { + if (totalFailures.length > 0) + throw new ModifyError("Error modifying one or more objects", totalFailures, successCount, failedKeys); + return keys.length; + }); + }); + }); + }; + Collection.prototype.delete = function () { + var ctx = this._ctx, range = ctx.range; + if (isPlainKeyRange(ctx) && + ((ctx.isPrimKey && !hangsOnDeleteLargeKeyRange) || range.type === 3 )) + { + return this._write(function (trans) { + var primaryKey = ctx.table.core.schema.primaryKey; + var coreRange = range; + return ctx.table.core.count({ trans: trans, query: { index: primaryKey, range: coreRange } }).then(function (count) { + return ctx.table.core.mutate({ trans: trans, type: 'deleteRange', range: coreRange }) + .then(function (_a) { + var failures = _a.failures; _a.lastResult; _a.results; var numFailures = _a.numFailures; + if (numFailures) + throw new ModifyError("Could not delete some values", Object.keys(failures).map(function (pos) { return failures[pos]; }), count - numFailures); + return count - numFailures; + }); + }); + }); + } + return this.modify(deleteCallback); + }; + return Collection; +}()); +var deleteCallback = function (value, ctx) { return ctx.value = null; }; + +function createCollectionConstructor(db) { + return makeClassConstructor(Collection.prototype, function Collection(whereClause, keyRangeGenerator) { + this.db = db; + var keyRange = AnyRange, error = null; + if (keyRangeGenerator) + try { + keyRange = keyRangeGenerator(); + } + catch (ex) { + error = ex; + } + var whereCtx = whereClause._ctx; + var table = whereCtx.table; + var readingHook = table.hook.reading.fire; + this._ctx = { + table: table, + index: whereCtx.index, + isPrimKey: (!whereCtx.index || (table.schema.primKey.keyPath && whereCtx.index === table.schema.primKey.name)), + range: keyRange, + keysOnly: false, + dir: "next", + unique: "", + algorithm: null, + filter: null, + replayFilter: null, + justLimit: true, + isMatch: null, + offset: 0, + limit: Infinity, + error: error, + or: whereCtx.or, + valueMapper: readingHook !== mirror ? readingHook : null + }; + }); +} + +function simpleCompare(a, b) { + return a < b ? -1 : a === b ? 0 : 1; +} +function simpleCompareReverse(a, b) { + return a > b ? -1 : a === b ? 0 : 1; +} + +function fail(collectionOrWhereClause, err, T) { + var collection = collectionOrWhereClause instanceof WhereClause ? + new collectionOrWhereClause.Collection(collectionOrWhereClause) : + collectionOrWhereClause; + collection._ctx.error = T ? new T(err) : new TypeError(err); + return collection; +} +function emptyCollection(whereClause) { + return new whereClause.Collection(whereClause, function () { return rangeEqual(""); }).limit(0); +} +function upperFactory(dir) { + return dir === "next" ? + function (s) { return s.toUpperCase(); } : + function (s) { return s.toLowerCase(); }; +} +function lowerFactory(dir) { + return dir === "next" ? + function (s) { return s.toLowerCase(); } : + function (s) { return s.toUpperCase(); }; +} +function nextCasing(key, lowerKey, upperNeedle, lowerNeedle, cmp, dir) { + var length = Math.min(key.length, lowerNeedle.length); + var llp = -1; + for (var i = 0; i < length; ++i) { + var lwrKeyChar = lowerKey[i]; + if (lwrKeyChar !== lowerNeedle[i]) { + if (cmp(key[i], upperNeedle[i]) < 0) + return key.substr(0, i) + upperNeedle[i] + upperNeedle.substr(i + 1); + if (cmp(key[i], lowerNeedle[i]) < 0) + return key.substr(0, i) + lowerNeedle[i] + upperNeedle.substr(i + 1); + if (llp >= 0) + return key.substr(0, llp) + lowerKey[llp] + upperNeedle.substr(llp + 1); + return null; + } + if (cmp(key[i], lwrKeyChar) < 0) + llp = i; + } + if (length < lowerNeedle.length && dir === "next") + return key + upperNeedle.substr(key.length); + if (length < key.length && dir === "prev") + return key.substr(0, upperNeedle.length); + return (llp < 0 ? null : key.substr(0, llp) + lowerNeedle[llp] + upperNeedle.substr(llp + 1)); +} +function addIgnoreCaseAlgorithm(whereClause, match, needles, suffix) { + var upper, lower, compare, upperNeedles, lowerNeedles, direction, nextKeySuffix, needlesLen = needles.length; + if (!needles.every(function (s) { return typeof s === 'string'; })) { + return fail(whereClause, STRING_EXPECTED); + } + function initDirection(dir) { + upper = upperFactory(dir); + lower = lowerFactory(dir); + compare = (dir === "next" ? simpleCompare : simpleCompareReverse); + var needleBounds = needles.map(function (needle) { + return { lower: lower(needle), upper: upper(needle) }; + }).sort(function (a, b) { + return compare(a.lower, b.lower); + }); + upperNeedles = needleBounds.map(function (nb) { return nb.upper; }); + lowerNeedles = needleBounds.map(function (nb) { return nb.lower; }); + direction = dir; + nextKeySuffix = (dir === "next" ? "" : suffix); + } + initDirection("next"); + var c = new whereClause.Collection(whereClause, function () { return createRange(upperNeedles[0], lowerNeedles[needlesLen - 1] + suffix); }); + c._ondirectionchange = function (direction) { + initDirection(direction); + }; + var firstPossibleNeedle = 0; + c._addAlgorithm(function (cursor, advance, resolve) { + var key = cursor.key; + if (typeof key !== 'string') + return false; + var lowerKey = lower(key); + if (match(lowerKey, lowerNeedles, firstPossibleNeedle)) { + return true; + } + else { + var lowestPossibleCasing = null; + for (var i = firstPossibleNeedle; i < needlesLen; ++i) { + var casing = nextCasing(key, lowerKey, upperNeedles[i], lowerNeedles[i], compare, direction); + if (casing === null && lowestPossibleCasing === null) + firstPossibleNeedle = i + 1; + else if (lowestPossibleCasing === null || compare(lowestPossibleCasing, casing) > 0) { + lowestPossibleCasing = casing; + } + } + if (lowestPossibleCasing !== null) { + advance(function () { cursor.continue(lowestPossibleCasing + nextKeySuffix); }); + } + else { + advance(resolve); + } + return false; + } + }); + return c; +} +function createRange(lower, upper, lowerOpen, upperOpen) { + return { + type: 2 , + lower: lower, + upper: upper, + lowerOpen: lowerOpen, + upperOpen: upperOpen + }; +} +function rangeEqual(value) { + return { + type: 1 , + lower: value, + upper: value + }; +} + +var WhereClause = (function () { + function WhereClause() { + } + Object.defineProperty(WhereClause.prototype, "Collection", { + get: function () { + return this._ctx.table.db.Collection; + }, + enumerable: false, + configurable: true + }); + WhereClause.prototype.between = function (lower, upper, includeLower, includeUpper) { + includeLower = includeLower !== false; + includeUpper = includeUpper === true; + try { + if ((this._cmp(lower, upper) > 0) || + (this._cmp(lower, upper) === 0 && (includeLower || includeUpper) && !(includeLower && includeUpper))) + return emptyCollection(this); + return new this.Collection(this, function () { return createRange(lower, upper, !includeLower, !includeUpper); }); + } + catch (e) { + return fail(this, INVALID_KEY_ARGUMENT); + } + }; + WhereClause.prototype.equals = function (value) { + if (value == null) + return fail(this, INVALID_KEY_ARGUMENT); + return new this.Collection(this, function () { return rangeEqual(value); }); + }; + WhereClause.prototype.above = function (value) { + if (value == null) + return fail(this, INVALID_KEY_ARGUMENT); + return new this.Collection(this, function () { return createRange(value, undefined, true); }); + }; + WhereClause.prototype.aboveOrEqual = function (value) { + if (value == null) + return fail(this, INVALID_KEY_ARGUMENT); + return new this.Collection(this, function () { return createRange(value, undefined, false); }); + }; + WhereClause.prototype.below = function (value) { + if (value == null) + return fail(this, INVALID_KEY_ARGUMENT); + return new this.Collection(this, function () { return createRange(undefined, value, false, true); }); + }; + WhereClause.prototype.belowOrEqual = function (value) { + if (value == null) + return fail(this, INVALID_KEY_ARGUMENT); + return new this.Collection(this, function () { return createRange(undefined, value); }); + }; + WhereClause.prototype.startsWith = function (str) { + if (typeof str !== 'string') + return fail(this, STRING_EXPECTED); + return this.between(str, str + maxString, true, true); + }; + WhereClause.prototype.startsWithIgnoreCase = function (str) { + if (str === "") + return this.startsWith(str); + return addIgnoreCaseAlgorithm(this, function (x, a) { return x.indexOf(a[0]) === 0; }, [str], maxString); + }; + WhereClause.prototype.equalsIgnoreCase = function (str) { + return addIgnoreCaseAlgorithm(this, function (x, a) { return x === a[0]; }, [str], ""); + }; + WhereClause.prototype.anyOfIgnoreCase = function () { + var set = getArrayOf.apply(NO_CHAR_ARRAY, arguments); + if (set.length === 0) + return emptyCollection(this); + return addIgnoreCaseAlgorithm(this, function (x, a) { return a.indexOf(x) !== -1; }, set, ""); + }; + WhereClause.prototype.startsWithAnyOfIgnoreCase = function () { + var set = getArrayOf.apply(NO_CHAR_ARRAY, arguments); + if (set.length === 0) + return emptyCollection(this); + return addIgnoreCaseAlgorithm(this, function (x, a) { return a.some(function (n) { return x.indexOf(n) === 0; }); }, set, maxString); + }; + WhereClause.prototype.anyOf = function () { + var _this = this; + var set = getArrayOf.apply(NO_CHAR_ARRAY, arguments); + var compare = this._cmp; + try { + set.sort(compare); + } + catch (e) { + return fail(this, INVALID_KEY_ARGUMENT); + } + if (set.length === 0) + return emptyCollection(this); + var c = new this.Collection(this, function () { return createRange(set[0], set[set.length - 1]); }); + c._ondirectionchange = function (direction) { + compare = (direction === "next" ? + _this._ascending : + _this._descending); + set.sort(compare); + }; + var i = 0; + c._addAlgorithm(function (cursor, advance, resolve) { + var key = cursor.key; + while (compare(key, set[i]) > 0) { + ++i; + if (i === set.length) { + advance(resolve); + return false; + } + } + if (compare(key, set[i]) === 0) { + return true; + } + else { + advance(function () { cursor.continue(set[i]); }); + return false; + } + }); + return c; + }; + WhereClause.prototype.notEqual = function (value) { + return this.inAnyRange([[minKey, value], [value, this.db._maxKey]], { includeLowers: false, includeUppers: false }); + }; + WhereClause.prototype.noneOf = function () { + var set = getArrayOf.apply(NO_CHAR_ARRAY, arguments); + if (set.length === 0) + return new this.Collection(this); + try { + set.sort(this._ascending); + } + catch (e) { + return fail(this, INVALID_KEY_ARGUMENT); + } + var ranges = set.reduce(function (res, val) { return res ? + res.concat([[res[res.length - 1][1], val]]) : + [[minKey, val]]; }, null); + ranges.push([set[set.length - 1], this.db._maxKey]); + return this.inAnyRange(ranges, { includeLowers: false, includeUppers: false }); + }; + WhereClause.prototype.inAnyRange = function (ranges, options) { + var _this = this; + var cmp = this._cmp, ascending = this._ascending, descending = this._descending, min = this._min, max = this._max; + if (ranges.length === 0) + return emptyCollection(this); + if (!ranges.every(function (range) { + return range[0] !== undefined && + range[1] !== undefined && + ascending(range[0], range[1]) <= 0; + })) { + return fail(this, "First argument to inAnyRange() must be an Array of two-value Arrays [lower,upper] where upper must not be lower than lower", exceptions.InvalidArgument); + } + var includeLowers = !options || options.includeLowers !== false; + var includeUppers = options && options.includeUppers === true; + function addRange(ranges, newRange) { + var i = 0, l = ranges.length; + for (; i < l; ++i) { + var range = ranges[i]; + if (cmp(newRange[0], range[1]) < 0 && cmp(newRange[1], range[0]) > 0) { + range[0] = min(range[0], newRange[0]); + range[1] = max(range[1], newRange[1]); + break; + } + } + if (i === l) + ranges.push(newRange); + return ranges; + } + var sortDirection = ascending; + function rangeSorter(a, b) { return sortDirection(a[0], b[0]); } + var set; + try { + set = ranges.reduce(addRange, []); + set.sort(rangeSorter); + } + catch (ex) { + return fail(this, INVALID_KEY_ARGUMENT); + } + var rangePos = 0; + var keyIsBeyondCurrentEntry = includeUppers ? + function (key) { return ascending(key, set[rangePos][1]) > 0; } : + function (key) { return ascending(key, set[rangePos][1]) >= 0; }; + var keyIsBeforeCurrentEntry = includeLowers ? + function (key) { return descending(key, set[rangePos][0]) > 0; } : + function (key) { return descending(key, set[rangePos][0]) >= 0; }; + function keyWithinCurrentRange(key) { + return !keyIsBeyondCurrentEntry(key) && !keyIsBeforeCurrentEntry(key); + } + var checkKey = keyIsBeyondCurrentEntry; + var c = new this.Collection(this, function () { return createRange(set[0][0], set[set.length - 1][1], !includeLowers, !includeUppers); }); + c._ondirectionchange = function (direction) { + if (direction === "next") { + checkKey = keyIsBeyondCurrentEntry; + sortDirection = ascending; + } + else { + checkKey = keyIsBeforeCurrentEntry; + sortDirection = descending; + } + set.sort(rangeSorter); + }; + c._addAlgorithm(function (cursor, advance, resolve) { + var key = cursor.key; + while (checkKey(key)) { + ++rangePos; + if (rangePos === set.length) { + advance(resolve); + return false; + } + } + if (keyWithinCurrentRange(key)) { + return true; + } + else if (_this._cmp(key, set[rangePos][1]) === 0 || _this._cmp(key, set[rangePos][0]) === 0) { + return false; + } + else { + advance(function () { + if (sortDirection === ascending) + cursor.continue(set[rangePos][0]); + else + cursor.continue(set[rangePos][1]); + }); + return false; + } + }); + return c; + }; + WhereClause.prototype.startsWithAnyOf = function () { + var set = getArrayOf.apply(NO_CHAR_ARRAY, arguments); + if (!set.every(function (s) { return typeof s === 'string'; })) { + return fail(this, "startsWithAnyOf() only works with strings"); + } + if (set.length === 0) + return emptyCollection(this); + return this.inAnyRange(set.map(function (str) { return [str, str + maxString]; })); + }; + return WhereClause; +}()); + +function createWhereClauseConstructor(db) { + return makeClassConstructor(WhereClause.prototype, function WhereClause(table, index, orCollection) { + this.db = db; + this._ctx = { + table: table, + index: index === ":id" ? null : index, + or: orCollection + }; + var indexedDB = db._deps.indexedDB; + if (!indexedDB) + throw new exceptions.MissingAPI(); + this._cmp = this._ascending = indexedDB.cmp.bind(indexedDB); + this._descending = function (a, b) { return indexedDB.cmp(b, a); }; + this._max = function (a, b) { return indexedDB.cmp(a, b) > 0 ? a : b; }; + this._min = function (a, b) { return indexedDB.cmp(a, b) < 0 ? a : b; }; + this._IDBKeyRange = db._deps.IDBKeyRange; + }); +} + +function eventRejectHandler(reject) { + return wrap(function (event) { + preventDefault(event); + reject(event.target.error); + return false; + }); +} +function preventDefault(event) { + if (event.stopPropagation) + event.stopPropagation(); + if (event.preventDefault) + event.preventDefault(); +} + +var DEXIE_STORAGE_MUTATED_EVENT_NAME = 'storagemutated'; +var STORAGE_MUTATED_DOM_EVENT_NAME = 'x-storagemutated-1'; +var globalEvents = Events(null, DEXIE_STORAGE_MUTATED_EVENT_NAME); + +var Transaction = (function () { + function Transaction() { + } + Transaction.prototype._lock = function () { + assert(!PSD.global); + ++this._reculock; + if (this._reculock === 1 && !PSD.global) + PSD.lockOwnerFor = this; + return this; + }; + Transaction.prototype._unlock = function () { + assert(!PSD.global); + if (--this._reculock === 0) { + if (!PSD.global) + PSD.lockOwnerFor = null; + while (this._blockedFuncs.length > 0 && !this._locked()) { + var fnAndPSD = this._blockedFuncs.shift(); + try { + usePSD(fnAndPSD[1], fnAndPSD[0]); + } + catch (e) { } + } + } + return this; + }; + Transaction.prototype._locked = function () { + return this._reculock && PSD.lockOwnerFor !== this; + }; + Transaction.prototype.create = function (idbtrans) { + var _this = this; + if (!this.mode) + return this; + var idbdb = this.db.idbdb; + var dbOpenError = this.db._state.dbOpenError; + assert(!this.idbtrans); + if (!idbtrans && !idbdb) { + switch (dbOpenError && dbOpenError.name) { + case "DatabaseClosedError": + throw new exceptions.DatabaseClosed(dbOpenError); + case "MissingAPIError": + throw new exceptions.MissingAPI(dbOpenError.message, dbOpenError); + default: + throw new exceptions.OpenFailed(dbOpenError); + } + } + if (!this.active) + throw new exceptions.TransactionInactive(); + assert(this._completion._state === null); + idbtrans = this.idbtrans = idbtrans || + (this.db.core + ? this.db.core.transaction(this.storeNames, this.mode, { durability: this.chromeTransactionDurability }) + : idbdb.transaction(this.storeNames, this.mode, { durability: this.chromeTransactionDurability })); + idbtrans.onerror = wrap(function (ev) { + preventDefault(ev); + _this._reject(idbtrans.error); + }); + idbtrans.onabort = wrap(function (ev) { + preventDefault(ev); + _this.active && _this._reject(new exceptions.Abort(idbtrans.error)); + _this.active = false; + _this.on("abort").fire(ev); + }); + idbtrans.oncomplete = wrap(function () { + _this.active = false; + _this._resolve(); + if ('mutatedParts' in idbtrans) { + globalEvents.storagemutated.fire(idbtrans["mutatedParts"]); + } + }); + return this; + }; + Transaction.prototype._promise = function (mode, fn, bWriteLock) { + var _this = this; + if (mode === 'readwrite' && this.mode !== 'readwrite') + return rejection(new exceptions.ReadOnly("Transaction is readonly")); + if (!this.active) + return rejection(new exceptions.TransactionInactive()); + if (this._locked()) { + return new DexiePromise(function (resolve, reject) { + _this._blockedFuncs.push([function () { + _this._promise(mode, fn, bWriteLock).then(resolve, reject); + }, PSD]); + }); + } + else if (bWriteLock) { + return newScope(function () { + var p = new DexiePromise(function (resolve, reject) { + _this._lock(); + var rv = fn(resolve, reject, _this); + if (rv && rv.then) + rv.then(resolve, reject); + }); + p.finally(function () { return _this._unlock(); }); + p._lib = true; + return p; + }); + } + else { + var p = new DexiePromise(function (resolve, reject) { + var rv = fn(resolve, reject, _this); + if (rv && rv.then) + rv.then(resolve, reject); + }); + p._lib = true; + return p; + } + }; + Transaction.prototype._root = function () { + return this.parent ? this.parent._root() : this; + }; + Transaction.prototype.waitFor = function (promiseLike) { + var root = this._root(); + var promise = DexiePromise.resolve(promiseLike); + if (root._waitingFor) { + root._waitingFor = root._waitingFor.then(function () { return promise; }); + } + else { + root._waitingFor = promise; + root._waitingQueue = []; + var store = root.idbtrans.objectStore(root.storeNames[0]); + (function spin() { + ++root._spinCount; + while (root._waitingQueue.length) + (root._waitingQueue.shift())(); + if (root._waitingFor) + store.get(-Infinity).onsuccess = spin; + }()); + } + var currentWaitPromise = root._waitingFor; + return new DexiePromise(function (resolve, reject) { + promise.then(function (res) { return root._waitingQueue.push(wrap(resolve.bind(null, res))); }, function (err) { return root._waitingQueue.push(wrap(reject.bind(null, err))); }).finally(function () { + if (root._waitingFor === currentWaitPromise) { + root._waitingFor = null; + } + }); + }); + }; + Transaction.prototype.abort = function () { + if (this.active) { + this.active = false; + if (this.idbtrans) + this.idbtrans.abort(); + this._reject(new exceptions.Abort()); + } + }; + Transaction.prototype.table = function (tableName) { + var memoizedTables = (this._memoizedTables || (this._memoizedTables = {})); + if (hasOwn(memoizedTables, tableName)) + return memoizedTables[tableName]; + var tableSchema = this.schema[tableName]; + if (!tableSchema) { + throw new exceptions.NotFound("Table " + tableName + " not part of transaction"); + } + var transactionBoundTable = new this.db.Table(tableName, tableSchema, this); + transactionBoundTable.core = this.db.core.table(tableName); + memoizedTables[tableName] = transactionBoundTable; + return transactionBoundTable; + }; + return Transaction; +}()); + +function createTransactionConstructor(db) { + return makeClassConstructor(Transaction.prototype, function Transaction(mode, storeNames, dbschema, chromeTransactionDurability, parent) { + var _this = this; + this.db = db; + this.mode = mode; + this.storeNames = storeNames; + this.schema = dbschema; + this.chromeTransactionDurability = chromeTransactionDurability; + this.idbtrans = null; + this.on = Events(this, "complete", "error", "abort"); + this.parent = parent || null; + this.active = true; + this._reculock = 0; + this._blockedFuncs = []; + this._resolve = null; + this._reject = null; + this._waitingFor = null; + this._waitingQueue = null; + this._spinCount = 0; + this._completion = new DexiePromise(function (resolve, reject) { + _this._resolve = resolve; + _this._reject = reject; + }); + this._completion.then(function () { + _this.active = false; + _this.on.complete.fire(); + }, function (e) { + var wasActive = _this.active; + _this.active = false; + _this.on.error.fire(e); + _this.parent ? + _this.parent._reject(e) : + wasActive && _this.idbtrans && _this.idbtrans.abort(); + return rejection(e); + }); + }); +} + +function createIndexSpec(name, keyPath, unique, multi, auto, compound, isPrimKey) { + return { + name: name, + keyPath: keyPath, + unique: unique, + multi: multi, + auto: auto, + compound: compound, + src: (unique && !isPrimKey ? '&' : '') + (multi ? '*' : '') + (auto ? "++" : "") + nameFromKeyPath(keyPath) + }; +} +function nameFromKeyPath(keyPath) { + return typeof keyPath === 'string' ? + keyPath : + keyPath ? ('[' + [].join.call(keyPath, '+') + ']') : ""; +} + +function createTableSchema(name, primKey, indexes) { + return { + name: name, + primKey: primKey, + indexes: indexes, + mappedClass: null, + idxByName: arrayToObject(indexes, function (index) { return [index.name, index]; }) + }; +} + +function safariMultiStoreFix(storeNames) { + return storeNames.length === 1 ? storeNames[0] : storeNames; +} +var getMaxKey = function (IdbKeyRange) { + try { + IdbKeyRange.only([[]]); + getMaxKey = function () { return [[]]; }; + return [[]]; + } + catch (e) { + getMaxKey = function () { return maxString; }; + return maxString; + } +}; + +function getKeyExtractor(keyPath) { + if (keyPath == null) { + return function () { return undefined; }; + } + else if (typeof keyPath === 'string') { + return getSinglePathKeyExtractor(keyPath); + } + else { + return function (obj) { return getByKeyPath(obj, keyPath); }; + } +} +function getSinglePathKeyExtractor(keyPath) { + var split = keyPath.split('.'); + if (split.length === 1) { + return function (obj) { return obj[keyPath]; }; + } + else { + return function (obj) { return getByKeyPath(obj, keyPath); }; + } +} + +function arrayify(arrayLike) { + return [].slice.call(arrayLike); +} +var _id_counter = 0; +function getKeyPathAlias(keyPath) { + return keyPath == null ? + ":id" : + typeof keyPath === 'string' ? + keyPath : + "[" + keyPath.join('+') + "]"; +} +function createDBCore(db, IdbKeyRange, tmpTrans) { + function extractSchema(db, trans) { + var tables = arrayify(db.objectStoreNames); + return { + schema: { + name: db.name, + tables: tables.map(function (table) { return trans.objectStore(table); }).map(function (store) { + var keyPath = store.keyPath, autoIncrement = store.autoIncrement; + var compound = isArray(keyPath); + var outbound = keyPath == null; + var indexByKeyPath = {}; + var result = { + name: store.name, + primaryKey: { + name: null, + isPrimaryKey: true, + outbound: outbound, + compound: compound, + keyPath: keyPath, + autoIncrement: autoIncrement, + unique: true, + extractKey: getKeyExtractor(keyPath) + }, + indexes: arrayify(store.indexNames).map(function (indexName) { return store.index(indexName); }) + .map(function (index) { + var name = index.name, unique = index.unique, multiEntry = index.multiEntry, keyPath = index.keyPath; + var compound = isArray(keyPath); + var result = { + name: name, + compound: compound, + keyPath: keyPath, + unique: unique, + multiEntry: multiEntry, + extractKey: getKeyExtractor(keyPath) + }; + indexByKeyPath[getKeyPathAlias(keyPath)] = result; + return result; + }), + getIndexByKeyPath: function (keyPath) { return indexByKeyPath[getKeyPathAlias(keyPath)]; } + }; + indexByKeyPath[":id"] = result.primaryKey; + if (keyPath != null) { + indexByKeyPath[getKeyPathAlias(keyPath)] = result.primaryKey; + } + return result; + }) + }, + hasGetAll: tables.length > 0 && ('getAll' in trans.objectStore(tables[0])) && + !(typeof navigator !== 'undefined' && /Safari/.test(navigator.userAgent) && + !/(Chrome\/|Edge\/)/.test(navigator.userAgent) && + [].concat(navigator.userAgent.match(/Safari\/(\d*)/))[1] < 604) + }; + } + function makeIDBKeyRange(range) { + if (range.type === 3 ) + return null; + if (range.type === 4 ) + throw new Error("Cannot convert never type to IDBKeyRange"); + var lower = range.lower, upper = range.upper, lowerOpen = range.lowerOpen, upperOpen = range.upperOpen; + var idbRange = lower === undefined ? + upper === undefined ? + null : + IdbKeyRange.upperBound(upper, !!upperOpen) : + upper === undefined ? + IdbKeyRange.lowerBound(lower, !!lowerOpen) : + IdbKeyRange.bound(lower, upper, !!lowerOpen, !!upperOpen); + return idbRange; + } + function createDbCoreTable(tableSchema) { + var tableName = tableSchema.name; + function mutate(_a) { + var trans = _a.trans, type = _a.type, keys = _a.keys, values = _a.values, range = _a.range; + return new Promise(function (resolve, reject) { + resolve = wrap(resolve); + var store = trans.objectStore(tableName); + var outbound = store.keyPath == null; + var isAddOrPut = type === "put" || type === "add"; + if (!isAddOrPut && type !== 'delete' && type !== 'deleteRange') + throw new Error("Invalid operation type: " + type); + var length = (keys || values || { length: 1 }).length; + if (keys && values && keys.length !== values.length) { + throw new Error("Given keys array must have same length as given values array."); + } + if (length === 0) + return resolve({ numFailures: 0, failures: {}, results: [], lastResult: undefined }); + var req; + var reqs = []; + var failures = []; + var numFailures = 0; + var errorHandler = function (event) { + ++numFailures; + preventDefault(event); + }; + if (type === 'deleteRange') { + if (range.type === 4 ) + return resolve({ numFailures: numFailures, failures: failures, results: [], lastResult: undefined }); + if (range.type === 3 ) + reqs.push(req = store.clear()); + else + reqs.push(req = store.delete(makeIDBKeyRange(range))); + } + else { + var _a = isAddOrPut ? + outbound ? + [values, keys] : + [values, null] : + [keys, null], args1 = _a[0], args2 = _a[1]; + if (isAddOrPut) { + for (var i = 0; i < length; ++i) { + reqs.push(req = (args2 && args2[i] !== undefined ? + store[type](args1[i], args2[i]) : + store[type](args1[i]))); + req.onerror = errorHandler; + } + } + else { + for (var i = 0; i < length; ++i) { + reqs.push(req = store[type](args1[i])); + req.onerror = errorHandler; + } + } + } + var done = function (event) { + var lastResult = event.target.result; + reqs.forEach(function (req, i) { return req.error != null && (failures[i] = req.error); }); + resolve({ + numFailures: numFailures, + failures: failures, + results: type === "delete" ? keys : reqs.map(function (req) { return req.result; }), + lastResult: lastResult + }); + }; + req.onerror = function (event) { + errorHandler(event); + done(event); + }; + req.onsuccess = done; + }); + } + function openCursor(_a) { + var trans = _a.trans, values = _a.values, query = _a.query, reverse = _a.reverse, unique = _a.unique; + return new Promise(function (resolve, reject) { + resolve = wrap(resolve); + var index = query.index, range = query.range; + var store = trans.objectStore(tableName); + var source = index.isPrimaryKey ? + store : + store.index(index.name); + var direction = reverse ? + unique ? + "prevunique" : + "prev" : + unique ? + "nextunique" : + "next"; + var req = values || !('openKeyCursor' in source) ? + source.openCursor(makeIDBKeyRange(range), direction) : + source.openKeyCursor(makeIDBKeyRange(range), direction); + req.onerror = eventRejectHandler(reject); + req.onsuccess = wrap(function (ev) { + var cursor = req.result; + if (!cursor) { + resolve(null); + return; + } + cursor.___id = ++_id_counter; + cursor.done = false; + var _cursorContinue = cursor.continue.bind(cursor); + var _cursorContinuePrimaryKey = cursor.continuePrimaryKey; + if (_cursorContinuePrimaryKey) + _cursorContinuePrimaryKey = _cursorContinuePrimaryKey.bind(cursor); + var _cursorAdvance = cursor.advance.bind(cursor); + var doThrowCursorIsNotStarted = function () { throw new Error("Cursor not started"); }; + var doThrowCursorIsStopped = function () { throw new Error("Cursor not stopped"); }; + cursor.trans = trans; + cursor.stop = cursor.continue = cursor.continuePrimaryKey = cursor.advance = doThrowCursorIsNotStarted; + cursor.fail = wrap(reject); + cursor.next = function () { + var _this = this; + var gotOne = 1; + return this.start(function () { return gotOne-- ? _this.continue() : _this.stop(); }).then(function () { return _this; }); + }; + cursor.start = function (callback) { + var iterationPromise = new Promise(function (resolveIteration, rejectIteration) { + resolveIteration = wrap(resolveIteration); + req.onerror = eventRejectHandler(rejectIteration); + cursor.fail = rejectIteration; + cursor.stop = function (value) { + cursor.stop = cursor.continue = cursor.continuePrimaryKey = cursor.advance = doThrowCursorIsStopped; + resolveIteration(value); + }; + }); + var guardedCallback = function () { + if (req.result) { + try { + callback(); + } + catch (err) { + cursor.fail(err); + } + } + else { + cursor.done = true; + cursor.start = function () { throw new Error("Cursor behind last entry"); }; + cursor.stop(); + } + }; + req.onsuccess = wrap(function (ev) { + req.onsuccess = guardedCallback; + guardedCallback(); + }); + cursor.continue = _cursorContinue; + cursor.continuePrimaryKey = _cursorContinuePrimaryKey; + cursor.advance = _cursorAdvance; + guardedCallback(); + return iterationPromise; + }; + resolve(cursor); + }, reject); + }); + } + function query(hasGetAll) { + return function (request) { + return new Promise(function (resolve, reject) { + resolve = wrap(resolve); + var trans = request.trans, values = request.values, limit = request.limit, query = request.query; + var nonInfinitLimit = limit === Infinity ? undefined : limit; + var index = query.index, range = query.range; + var store = trans.objectStore(tableName); + var source = index.isPrimaryKey ? store : store.index(index.name); + var idbKeyRange = makeIDBKeyRange(range); + if (limit === 0) + return resolve({ result: [] }); + if (hasGetAll) { + var req = values ? + source.getAll(idbKeyRange, nonInfinitLimit) : + source.getAllKeys(idbKeyRange, nonInfinitLimit); + req.onsuccess = function (event) { return resolve({ result: event.target.result }); }; + req.onerror = eventRejectHandler(reject); + } + else { + var count_1 = 0; + var req_1 = values || !('openKeyCursor' in source) ? + source.openCursor(idbKeyRange) : + source.openKeyCursor(idbKeyRange); + var result_1 = []; + req_1.onsuccess = function (event) { + var cursor = req_1.result; + if (!cursor) + return resolve({ result: result_1 }); + result_1.push(values ? cursor.value : cursor.primaryKey); + if (++count_1 === limit) + return resolve({ result: result_1 }); + cursor.continue(); + }; + req_1.onerror = eventRejectHandler(reject); + } + }); + }; + } + return { + name: tableName, + schema: tableSchema, + mutate: mutate, + getMany: function (_a) { + var trans = _a.trans, keys = _a.keys; + return new Promise(function (resolve, reject) { + resolve = wrap(resolve); + var store = trans.objectStore(tableName); + var length = keys.length; + var result = new Array(length); + var keyCount = 0; + var callbackCount = 0; + var req; + var successHandler = function (event) { + var req = event.target; + if ((result[req._pos] = req.result) != null) + ; + if (++callbackCount === keyCount) + resolve(result); + }; + var errorHandler = eventRejectHandler(reject); + for (var i = 0; i < length; ++i) { + var key = keys[i]; + if (key != null) { + req = store.get(keys[i]); + req._pos = i; + req.onsuccess = successHandler; + req.onerror = errorHandler; + ++keyCount; + } + } + if (keyCount === 0) + resolve(result); + }); + }, + get: function (_a) { + var trans = _a.trans, key = _a.key; + return new Promise(function (resolve, reject) { + resolve = wrap(resolve); + var store = trans.objectStore(tableName); + var req = store.get(key); + req.onsuccess = function (event) { return resolve(event.target.result); }; + req.onerror = eventRejectHandler(reject); + }); + }, + query: query(hasGetAll), + openCursor: openCursor, + count: function (_a) { + var query = _a.query, trans = _a.trans; + var index = query.index, range = query.range; + return new Promise(function (resolve, reject) { + var store = trans.objectStore(tableName); + var source = index.isPrimaryKey ? store : store.index(index.name); + var idbKeyRange = makeIDBKeyRange(range); + var req = idbKeyRange ? source.count(idbKeyRange) : source.count(); + req.onsuccess = wrap(function (ev) { return resolve(ev.target.result); }); + req.onerror = eventRejectHandler(reject); + }); + } + }; + } + var _a = extractSchema(db, tmpTrans), schema = _a.schema, hasGetAll = _a.hasGetAll; + var tables = schema.tables.map(function (tableSchema) { return createDbCoreTable(tableSchema); }); + var tableMap = {}; + tables.forEach(function (table) { return tableMap[table.name] = table; }); + return { + stack: "dbcore", + transaction: db.transaction.bind(db), + table: function (name) { + var result = tableMap[name]; + if (!result) + throw new Error("Table '" + name + "' not found"); + return tableMap[name]; + }, + MIN_KEY: -Infinity, + MAX_KEY: getMaxKey(IdbKeyRange), + schema: schema + }; +} + +function createMiddlewareStack(stackImpl, middlewares) { + return middlewares.reduce(function (down, _a) { + var create = _a.create; + return (__assign(__assign({}, down), create(down))); + }, stackImpl); +} +function createMiddlewareStacks(middlewares, idbdb, _a, tmpTrans) { + var IDBKeyRange = _a.IDBKeyRange; _a.indexedDB; + var dbcore = createMiddlewareStack(createDBCore(idbdb, IDBKeyRange, tmpTrans), middlewares.dbcore); + return { + dbcore: dbcore + }; +} +function generateMiddlewareStacks(_a, tmpTrans) { + var db = _a._novip; + var idbdb = tmpTrans.db; + var stacks = createMiddlewareStacks(db._middlewares, idbdb, db._deps, tmpTrans); + db.core = stacks.dbcore; + db.tables.forEach(function (table) { + var tableName = table.name; + if (db.core.schema.tables.some(function (tbl) { return tbl.name === tableName; })) { + table.core = db.core.table(tableName); + if (db[tableName] instanceof db.Table) { + db[tableName].core = table.core; + } + } + }); +} + +function setApiOnPlace(_a, objs, tableNames, dbschema) { + var db = _a._novip; + tableNames.forEach(function (tableName) { + var schema = dbschema[tableName]; + objs.forEach(function (obj) { + var propDesc = getPropertyDescriptor(obj, tableName); + if (!propDesc || ("value" in propDesc && propDesc.value === undefined)) { + if (obj === db.Transaction.prototype || obj instanceof db.Transaction) { + setProp(obj, tableName, { + get: function () { return this.table(tableName); }, + set: function (value) { + defineProperty(this, tableName, { value: value, writable: true, configurable: true, enumerable: true }); + } + }); + } + else { + obj[tableName] = new db.Table(tableName, schema); + } + } + }); + }); +} +function removeTablesApi(_a, objs) { + var db = _a._novip; + objs.forEach(function (obj) { + for (var key in obj) { + if (obj[key] instanceof db.Table) + delete obj[key]; + } + }); +} +function lowerVersionFirst(a, b) { + return a._cfg.version - b._cfg.version; +} +function runUpgraders(db, oldVersion, idbUpgradeTrans, reject) { + var globalSchema = db._dbSchema; + var trans = db._createTransaction('readwrite', db._storeNames, globalSchema); + trans.create(idbUpgradeTrans); + trans._completion.catch(reject); + var rejectTransaction = trans._reject.bind(trans); + var transless = PSD.transless || PSD; + newScope(function () { + PSD.trans = trans; + PSD.transless = transless; + if (oldVersion === 0) { + keys(globalSchema).forEach(function (tableName) { + createTable(idbUpgradeTrans, tableName, globalSchema[tableName].primKey, globalSchema[tableName].indexes); + }); + generateMiddlewareStacks(db, idbUpgradeTrans); + DexiePromise.follow(function () { return db.on.populate.fire(trans); }).catch(rejectTransaction); + } + else + updateTablesAndIndexes(db, oldVersion, trans, idbUpgradeTrans).catch(rejectTransaction); + }); +} +function updateTablesAndIndexes(_a, oldVersion, trans, idbUpgradeTrans) { + var db = _a._novip; + var queue = []; + var versions = db._versions; + var globalSchema = db._dbSchema = buildGlobalSchema(db, db.idbdb, idbUpgradeTrans); + var anyContentUpgraderHasRun = false; + var versToRun = versions.filter(function (v) { return v._cfg.version >= oldVersion; }); + versToRun.forEach(function (version) { + queue.push(function () { + var oldSchema = globalSchema; + var newSchema = version._cfg.dbschema; + adjustToExistingIndexNames(db, oldSchema, idbUpgradeTrans); + adjustToExistingIndexNames(db, newSchema, idbUpgradeTrans); + globalSchema = db._dbSchema = newSchema; + var diff = getSchemaDiff(oldSchema, newSchema); + diff.add.forEach(function (tuple) { + createTable(idbUpgradeTrans, tuple[0], tuple[1].primKey, tuple[1].indexes); + }); + diff.change.forEach(function (change) { + if (change.recreate) { + throw new exceptions.Upgrade("Not yet support for changing primary key"); + } + else { + var store_1 = idbUpgradeTrans.objectStore(change.name); + change.add.forEach(function (idx) { return addIndex(store_1, idx); }); + change.change.forEach(function (idx) { + store_1.deleteIndex(idx.name); + addIndex(store_1, idx); + }); + change.del.forEach(function (idxName) { return store_1.deleteIndex(idxName); }); + } + }); + var contentUpgrade = version._cfg.contentUpgrade; + if (contentUpgrade && version._cfg.version > oldVersion) { + generateMiddlewareStacks(db, idbUpgradeTrans); + trans._memoizedTables = {}; + anyContentUpgraderHasRun = true; + var upgradeSchema_1 = shallowClone(newSchema); + diff.del.forEach(function (table) { + upgradeSchema_1[table] = oldSchema[table]; + }); + removeTablesApi(db, [db.Transaction.prototype]); + setApiOnPlace(db, [db.Transaction.prototype], keys(upgradeSchema_1), upgradeSchema_1); + trans.schema = upgradeSchema_1; + var contentUpgradeIsAsync_1 = isAsyncFunction(contentUpgrade); + if (contentUpgradeIsAsync_1) { + incrementExpectedAwaits(); + } + var returnValue_1; + var promiseFollowed = DexiePromise.follow(function () { + returnValue_1 = contentUpgrade(trans); + if (returnValue_1) { + if (contentUpgradeIsAsync_1) { + var decrementor = decrementExpectedAwaits.bind(null, null); + returnValue_1.then(decrementor, decrementor); + } + } + }); + return (returnValue_1 && typeof returnValue_1.then === 'function' ? + DexiePromise.resolve(returnValue_1) : promiseFollowed.then(function () { return returnValue_1; })); + } + }); + queue.push(function (idbtrans) { + if (!anyContentUpgraderHasRun || !hasIEDeleteObjectStoreBug) { + var newSchema = version._cfg.dbschema; + deleteRemovedTables(newSchema, idbtrans); + } + removeTablesApi(db, [db.Transaction.prototype]); + setApiOnPlace(db, [db.Transaction.prototype], db._storeNames, db._dbSchema); + trans.schema = db._dbSchema; + }); + }); + function runQueue() { + return queue.length ? DexiePromise.resolve(queue.shift()(trans.idbtrans)).then(runQueue) : + DexiePromise.resolve(); + } + return runQueue().then(function () { + createMissingTables(globalSchema, idbUpgradeTrans); + }); +} +function getSchemaDiff(oldSchema, newSchema) { + var diff = { + del: [], + add: [], + change: [] + }; + var table; + for (table in oldSchema) { + if (!newSchema[table]) + diff.del.push(table); + } + for (table in newSchema) { + var oldDef = oldSchema[table], newDef = newSchema[table]; + if (!oldDef) { + diff.add.push([table, newDef]); + } + else { + var change = { + name: table, + def: newDef, + recreate: false, + del: [], + add: [], + change: [] + }; + if (( + '' + (oldDef.primKey.keyPath || '')) !== ('' + (newDef.primKey.keyPath || '')) || + (oldDef.primKey.auto !== newDef.primKey.auto && !isIEOrEdge)) + { + change.recreate = true; + diff.change.push(change); + } + else { + var oldIndexes = oldDef.idxByName; + var newIndexes = newDef.idxByName; + var idxName = void 0; + for (idxName in oldIndexes) { + if (!newIndexes[idxName]) + change.del.push(idxName); + } + for (idxName in newIndexes) { + var oldIdx = oldIndexes[idxName], newIdx = newIndexes[idxName]; + if (!oldIdx) + change.add.push(newIdx); + else if (oldIdx.src !== newIdx.src) + change.change.push(newIdx); + } + if (change.del.length > 0 || change.add.length > 0 || change.change.length > 0) { + diff.change.push(change); + } + } + } + } + return diff; +} +function createTable(idbtrans, tableName, primKey, indexes) { + var store = idbtrans.db.createObjectStore(tableName, primKey.keyPath ? + { keyPath: primKey.keyPath, autoIncrement: primKey.auto } : + { autoIncrement: primKey.auto }); + indexes.forEach(function (idx) { return addIndex(store, idx); }); + return store; +} +function createMissingTables(newSchema, idbtrans) { + keys(newSchema).forEach(function (tableName) { + if (!idbtrans.db.objectStoreNames.contains(tableName)) { + createTable(idbtrans, tableName, newSchema[tableName].primKey, newSchema[tableName].indexes); + } + }); +} +function deleteRemovedTables(newSchema, idbtrans) { + [].slice.call(idbtrans.db.objectStoreNames).forEach(function (storeName) { + return newSchema[storeName] == null && idbtrans.db.deleteObjectStore(storeName); + }); +} +function addIndex(store, idx) { + store.createIndex(idx.name, idx.keyPath, { unique: idx.unique, multiEntry: idx.multi }); +} +function buildGlobalSchema(db, idbdb, tmpTrans) { + var globalSchema = {}; + var dbStoreNames = slice(idbdb.objectStoreNames, 0); + dbStoreNames.forEach(function (storeName) { + var store = tmpTrans.objectStore(storeName); + var keyPath = store.keyPath; + var primKey = createIndexSpec(nameFromKeyPath(keyPath), keyPath || "", false, false, !!store.autoIncrement, keyPath && typeof keyPath !== "string", true); + var indexes = []; + for (var j = 0; j < store.indexNames.length; ++j) { + var idbindex = store.index(store.indexNames[j]); + keyPath = idbindex.keyPath; + var index = createIndexSpec(idbindex.name, keyPath, !!idbindex.unique, !!idbindex.multiEntry, false, keyPath && typeof keyPath !== "string", false); + indexes.push(index); + } + globalSchema[storeName] = createTableSchema(storeName, primKey, indexes); + }); + return globalSchema; +} +function readGlobalSchema(_a, idbdb, tmpTrans) { + var db = _a._novip; + db.verno = idbdb.version / 10; + var globalSchema = db._dbSchema = buildGlobalSchema(db, idbdb, tmpTrans); + db._storeNames = slice(idbdb.objectStoreNames, 0); + setApiOnPlace(db, [db._allTables], keys(globalSchema), globalSchema); +} +function verifyInstalledSchema(db, tmpTrans) { + var installedSchema = buildGlobalSchema(db, db.idbdb, tmpTrans); + var diff = getSchemaDiff(installedSchema, db._dbSchema); + return !(diff.add.length || diff.change.some(function (ch) { return ch.add.length || ch.change.length; })); +} +function adjustToExistingIndexNames(_a, schema, idbtrans) { + var db = _a._novip; + var storeNames = idbtrans.db.objectStoreNames; + for (var i = 0; i < storeNames.length; ++i) { + var storeName = storeNames[i]; + var store = idbtrans.objectStore(storeName); + db._hasGetAll = 'getAll' in store; + for (var j = 0; j < store.indexNames.length; ++j) { + var indexName = store.indexNames[j]; + var keyPath = store.index(indexName).keyPath; + var dexieName = typeof keyPath === 'string' ? keyPath : "[" + slice(keyPath).join('+') + "]"; + if (schema[storeName]) { + var indexSpec = schema[storeName].idxByName[dexieName]; + if (indexSpec) { + indexSpec.name = indexName; + delete schema[storeName].idxByName[dexieName]; + schema[storeName].idxByName[indexName] = indexSpec; + } + } + } + } + if (typeof navigator !== 'undefined' && /Safari/.test(navigator.userAgent) && + !/(Chrome\/|Edge\/)/.test(navigator.userAgent) && + _global.WorkerGlobalScope && _global instanceof _global.WorkerGlobalScope && + [].concat(navigator.userAgent.match(/Safari\/(\d*)/))[1] < 604) { + db._hasGetAll = false; + } +} +function parseIndexSyntax(primKeyAndIndexes) { + return primKeyAndIndexes.split(',').map(function (index, indexNum) { + index = index.trim(); + var name = index.replace(/([&*]|\+\+)/g, ""); + var keyPath = /^\[/.test(name) ? name.match(/^\[(.*)\]$/)[1].split('+') : name; + return createIndexSpec(name, keyPath || null, /\&/.test(index), /\*/.test(index), /\+\+/.test(index), isArray(keyPath), indexNum === 0); + }); +} + +var Version = (function () { + function Version() { + } + Version.prototype._parseStoresSpec = function (stores, outSchema) { + keys(stores).forEach(function (tableName) { + if (stores[tableName] !== null) { + var indexes = parseIndexSyntax(stores[tableName]); + var primKey = indexes.shift(); + if (primKey.multi) + throw new exceptions.Schema("Primary key cannot be multi-valued"); + indexes.forEach(function (idx) { + if (idx.auto) + throw new exceptions.Schema("Only primary key can be marked as autoIncrement (++)"); + if (!idx.keyPath) + throw new exceptions.Schema("Index must have a name and cannot be an empty string"); + }); + outSchema[tableName] = createTableSchema(tableName, primKey, indexes); + } + }); + }; + Version.prototype.stores = function (stores) { + var db = this.db; + this._cfg.storesSource = this._cfg.storesSource ? + extend(this._cfg.storesSource, stores) : + stores; + var versions = db._versions; + var storesSpec = {}; + var dbschema = {}; + versions.forEach(function (version) { + extend(storesSpec, version._cfg.storesSource); + dbschema = (version._cfg.dbschema = {}); + version._parseStoresSpec(storesSpec, dbschema); + }); + db._dbSchema = dbschema; + removeTablesApi(db, [db._allTables, db, db.Transaction.prototype]); + setApiOnPlace(db, [db._allTables, db, db.Transaction.prototype, this._cfg.tables], keys(dbschema), dbschema); + db._storeNames = keys(dbschema); + return this; + }; + Version.prototype.upgrade = function (upgradeFunction) { + this._cfg.contentUpgrade = promisableChain(this._cfg.contentUpgrade || nop, upgradeFunction); + return this; + }; + return Version; +}()); + +function createVersionConstructor(db) { + return makeClassConstructor(Version.prototype, function Version(versionNumber) { + this.db = db; + this._cfg = { + version: versionNumber, + storesSource: null, + dbschema: {}, + tables: {}, + contentUpgrade: null + }; + }); +} + +function getDbNamesTable(indexedDB, IDBKeyRange) { + var dbNamesDB = indexedDB["_dbNamesDB"]; + if (!dbNamesDB) { + dbNamesDB = indexedDB["_dbNamesDB"] = new Dexie$1(DBNAMES_DB, { + addons: [], + indexedDB: indexedDB, + IDBKeyRange: IDBKeyRange, + }); + dbNamesDB.version(1).stores({ dbnames: "name" }); + } + return dbNamesDB.table("dbnames"); +} +function hasDatabasesNative(indexedDB) { + return indexedDB && typeof indexedDB.databases === "function"; +} +function getDatabaseNames(_a) { + var indexedDB = _a.indexedDB, IDBKeyRange = _a.IDBKeyRange; + return hasDatabasesNative(indexedDB) + ? Promise.resolve(indexedDB.databases()).then(function (infos) { + return infos + .map(function (info) { return info.name; }) + .filter(function (name) { return name !== DBNAMES_DB; }); + }) + : getDbNamesTable(indexedDB, IDBKeyRange).toCollection().primaryKeys(); +} +function _onDatabaseCreated(_a, name) { + var indexedDB = _a.indexedDB, IDBKeyRange = _a.IDBKeyRange; + !hasDatabasesNative(indexedDB) && + name !== DBNAMES_DB && + getDbNamesTable(indexedDB, IDBKeyRange).put({ name: name }).catch(nop); +} +function _onDatabaseDeleted(_a, name) { + var indexedDB = _a.indexedDB, IDBKeyRange = _a.IDBKeyRange; + !hasDatabasesNative(indexedDB) && + name !== DBNAMES_DB && + getDbNamesTable(indexedDB, IDBKeyRange).delete(name).catch(nop); +} + +function vip(fn) { + return newScope(function () { + PSD.letThrough = true; + return fn(); + }); +} + +function idbReady() { + var isSafari = !navigator.userAgentData && + /Safari\//.test(navigator.userAgent) && + !/Chrom(e|ium)\//.test(navigator.userAgent); + if (!isSafari || !indexedDB.databases) + return Promise.resolve(); + var intervalId; + return new Promise(function (resolve) { + var tryIdb = function () { return indexedDB.databases().finally(resolve); }; + intervalId = setInterval(tryIdb, 100); + tryIdb(); + }).finally(function () { return clearInterval(intervalId); }); +} + +function dexieOpen(db) { + var state = db._state; + var indexedDB = db._deps.indexedDB; + if (state.isBeingOpened || db.idbdb) + return state.dbReadyPromise.then(function () { return state.dbOpenError ? + rejection(state.dbOpenError) : + db; }); + debug && (state.openCanceller._stackHolder = getErrorWithStack()); + state.isBeingOpened = true; + state.dbOpenError = null; + state.openComplete = false; + var openCanceller = state.openCanceller; + function throwIfCancelled() { + if (state.openCanceller !== openCanceller) + throw new exceptions.DatabaseClosed('db.open() was cancelled'); + } + var resolveDbReady = state.dbReadyResolve, + upgradeTransaction = null, wasCreated = false; + return DexiePromise.race([openCanceller, (typeof navigator === 'undefined' ? DexiePromise.resolve() : idbReady()).then(function () { return new DexiePromise(function (resolve, reject) { + throwIfCancelled(); + if (!indexedDB) + throw new exceptions.MissingAPI(); + var dbName = db.name; + var req = state.autoSchema ? + indexedDB.open(dbName) : + indexedDB.open(dbName, Math.round(db.verno * 10)); + if (!req) + throw new exceptions.MissingAPI(); + req.onerror = eventRejectHandler(reject); + req.onblocked = wrap(db._fireOnBlocked); + req.onupgradeneeded = wrap(function (e) { + upgradeTransaction = req.transaction; + if (state.autoSchema && !db._options.allowEmptyDB) { + req.onerror = preventDefault; + upgradeTransaction.abort(); + req.result.close(); + var delreq = indexedDB.deleteDatabase(dbName); + delreq.onsuccess = delreq.onerror = wrap(function () { + reject(new exceptions.NoSuchDatabase("Database " + dbName + " doesnt exist")); + }); + } + else { + upgradeTransaction.onerror = eventRejectHandler(reject); + var oldVer = e.oldVersion > Math.pow(2, 62) ? 0 : e.oldVersion; + wasCreated = oldVer < 1; + db._novip.idbdb = req.result; + runUpgraders(db, oldVer / 10, upgradeTransaction, reject); + } + }, reject); + req.onsuccess = wrap(function () { + upgradeTransaction = null; + var idbdb = db._novip.idbdb = req.result; + var objectStoreNames = slice(idbdb.objectStoreNames); + if (objectStoreNames.length > 0) + try { + var tmpTrans = idbdb.transaction(safariMultiStoreFix(objectStoreNames), 'readonly'); + if (state.autoSchema) + readGlobalSchema(db, idbdb, tmpTrans); + else { + adjustToExistingIndexNames(db, db._dbSchema, tmpTrans); + if (!verifyInstalledSchema(db, tmpTrans)) { + console.warn("Dexie SchemaDiff: Schema was extended without increasing the number passed to db.version(). Some queries may fail."); + } + } + generateMiddlewareStacks(db, tmpTrans); + } + catch (e) { + } + connections.push(db); + idbdb.onversionchange = wrap(function (ev) { + state.vcFired = true; + db.on("versionchange").fire(ev); + }); + idbdb.onclose = wrap(function (ev) { + db.on("close").fire(ev); + }); + if (wasCreated) + _onDatabaseCreated(db._deps, dbName); + resolve(); + }, reject); + }); })]).then(function () { + throwIfCancelled(); + state.onReadyBeingFired = []; + return DexiePromise.resolve(vip(function () { return db.on.ready.fire(db.vip); })).then(function fireRemainders() { + if (state.onReadyBeingFired.length > 0) { + var remainders_1 = state.onReadyBeingFired.reduce(promisableChain, nop); + state.onReadyBeingFired = []; + return DexiePromise.resolve(vip(function () { return remainders_1(db.vip); })).then(fireRemainders); + } + }); + }).finally(function () { + state.onReadyBeingFired = null; + state.isBeingOpened = false; + }).then(function () { + return db; + }).catch(function (err) { + state.dbOpenError = err; + try { + upgradeTransaction && upgradeTransaction.abort(); + } + catch (_a) { } + if (openCanceller === state.openCanceller) { + db._close(); + } + return rejection(err); + }).finally(function () { + state.openComplete = true; + resolveDbReady(); + }); +} + +function awaitIterator(iterator) { + var callNext = function (result) { return iterator.next(result); }, doThrow = function (error) { return iterator.throw(error); }, onSuccess = step(callNext), onError = step(doThrow); + function step(getNext) { + return function (val) { + var next = getNext(val), value = next.value; + return next.done ? value : + (!value || typeof value.then !== 'function' ? + isArray(value) ? Promise.all(value).then(onSuccess, onError) : onSuccess(value) : + value.then(onSuccess, onError)); + }; + } + return step(callNext)(); +} + +function extractTransactionArgs(mode, _tableArgs_, scopeFunc) { + var i = arguments.length; + if (i < 2) + throw new exceptions.InvalidArgument("Too few arguments"); + var args = new Array(i - 1); + while (--i) + args[i - 1] = arguments[i]; + scopeFunc = args.pop(); + var tables = flatten(args); + return [mode, tables, scopeFunc]; +} +function enterTransactionScope(db, mode, storeNames, parentTransaction, scopeFunc) { + return DexiePromise.resolve().then(function () { + var transless = PSD.transless || PSD; + var trans = db._createTransaction(mode, storeNames, db._dbSchema, parentTransaction); + var zoneProps = { + trans: trans, + transless: transless + }; + if (parentTransaction) { + trans.idbtrans = parentTransaction.idbtrans; + } + else { + try { + trans.create(); + db._state.PR1398_maxLoop = 3; + } + catch (ex) { + if (ex.name === errnames.InvalidState && db.isOpen() && --db._state.PR1398_maxLoop > 0) { + console.warn('Dexie: Need to reopen db'); + db._close(); + return db.open().then(function () { return enterTransactionScope(db, mode, storeNames, null, scopeFunc); }); + } + return rejection(ex); + } + } + var scopeFuncIsAsync = isAsyncFunction(scopeFunc); + if (scopeFuncIsAsync) { + incrementExpectedAwaits(); + } + var returnValue; + var promiseFollowed = DexiePromise.follow(function () { + returnValue = scopeFunc.call(trans, trans); + if (returnValue) { + if (scopeFuncIsAsync) { + var decrementor = decrementExpectedAwaits.bind(null, null); + returnValue.then(decrementor, decrementor); + } + else if (typeof returnValue.next === 'function' && typeof returnValue.throw === 'function') { + returnValue = awaitIterator(returnValue); + } + } + }, zoneProps); + return (returnValue && typeof returnValue.then === 'function' ? + DexiePromise.resolve(returnValue).then(function (x) { return trans.active ? + x + : rejection(new exceptions.PrematureCommit("Transaction committed too early. See http://bit.ly/2kdckMn")); }) + : promiseFollowed.then(function () { return returnValue; })).then(function (x) { + if (parentTransaction) + trans._resolve(); + return trans._completion.then(function () { return x; }); + }).catch(function (e) { + trans._reject(e); + return rejection(e); + }); + }); +} + +function pad(a, value, count) { + var result = isArray(a) ? a.slice() : [a]; + for (var i = 0; i < count; ++i) + result.push(value); + return result; +} +function createVirtualIndexMiddleware(down) { + return __assign(__assign({}, down), { table: function (tableName) { + var table = down.table(tableName); + var schema = table.schema; + var indexLookup = {}; + var allVirtualIndexes = []; + function addVirtualIndexes(keyPath, keyTail, lowLevelIndex) { + var keyPathAlias = getKeyPathAlias(keyPath); + var indexList = (indexLookup[keyPathAlias] = indexLookup[keyPathAlias] || []); + var keyLength = keyPath == null ? 0 : typeof keyPath === 'string' ? 1 : keyPath.length; + var isVirtual = keyTail > 0; + var virtualIndex = __assign(__assign({}, lowLevelIndex), { isVirtual: isVirtual, keyTail: keyTail, keyLength: keyLength, extractKey: getKeyExtractor(keyPath), unique: !isVirtual && lowLevelIndex.unique }); + indexList.push(virtualIndex); + if (!virtualIndex.isPrimaryKey) { + allVirtualIndexes.push(virtualIndex); + } + if (keyLength > 1) { + var virtualKeyPath = keyLength === 2 ? + keyPath[0] : + keyPath.slice(0, keyLength - 1); + addVirtualIndexes(virtualKeyPath, keyTail + 1, lowLevelIndex); + } + indexList.sort(function (a, b) { return a.keyTail - b.keyTail; }); + return virtualIndex; + } + var primaryKey = addVirtualIndexes(schema.primaryKey.keyPath, 0, schema.primaryKey); + indexLookup[":id"] = [primaryKey]; + for (var _i = 0, _a = schema.indexes; _i < _a.length; _i++) { + var index = _a[_i]; + addVirtualIndexes(index.keyPath, 0, index); + } + function findBestIndex(keyPath) { + var result = indexLookup[getKeyPathAlias(keyPath)]; + return result && result[0]; + } + function translateRange(range, keyTail) { + return { + type: range.type === 1 ? + 2 : + range.type, + lower: pad(range.lower, range.lowerOpen ? down.MAX_KEY : down.MIN_KEY, keyTail), + lowerOpen: true, + upper: pad(range.upper, range.upperOpen ? down.MIN_KEY : down.MAX_KEY, keyTail), + upperOpen: true + }; + } + function translateRequest(req) { + var index = req.query.index; + return index.isVirtual ? __assign(__assign({}, req), { query: { + index: index, + range: translateRange(req.query.range, index.keyTail) + } }) : req; + } + var result = __assign(__assign({}, table), { schema: __assign(__assign({}, schema), { primaryKey: primaryKey, indexes: allVirtualIndexes, getIndexByKeyPath: findBestIndex }), count: function (req) { + return table.count(translateRequest(req)); + }, query: function (req) { + return table.query(translateRequest(req)); + }, openCursor: function (req) { + var _a = req.query.index, keyTail = _a.keyTail, isVirtual = _a.isVirtual, keyLength = _a.keyLength; + if (!isVirtual) + return table.openCursor(req); + function createVirtualCursor(cursor) { + function _continue(key) { + key != null ? + cursor.continue(pad(key, req.reverse ? down.MAX_KEY : down.MIN_KEY, keyTail)) : + req.unique ? + cursor.continue(cursor.key.slice(0, keyLength) + .concat(req.reverse + ? down.MIN_KEY + : down.MAX_KEY, keyTail)) : + cursor.continue(); + } + var virtualCursor = Object.create(cursor, { + continue: { value: _continue }, + continuePrimaryKey: { + value: function (key, primaryKey) { + cursor.continuePrimaryKey(pad(key, down.MAX_KEY, keyTail), primaryKey); + } + }, + primaryKey: { + get: function () { + return cursor.primaryKey; + } + }, + key: { + get: function () { + var key = cursor.key; + return keyLength === 1 ? + key[0] : + key.slice(0, keyLength); + } + }, + value: { + get: function () { + return cursor.value; + } + } + }); + return virtualCursor; + } + return table.openCursor(translateRequest(req)) + .then(function (cursor) { return cursor && createVirtualCursor(cursor); }); + } }); + return result; + } }); +} +var virtualIndexMiddleware = { + stack: "dbcore", + name: "VirtualIndexMiddleware", + level: 1, + create: createVirtualIndexMiddleware +}; + +function getObjectDiff(a, b, rv, prfx) { + rv = rv || {}; + prfx = prfx || ''; + keys(a).forEach(function (prop) { + if (!hasOwn(b, prop)) { + rv[prfx + prop] = undefined; + } + else { + var ap = a[prop], bp = b[prop]; + if (typeof ap === 'object' && typeof bp === 'object' && ap && bp) { + var apTypeName = toStringTag(ap); + var bpTypeName = toStringTag(bp); + if (apTypeName !== bpTypeName) { + rv[prfx + prop] = b[prop]; + } + else if (apTypeName === 'Object') { + getObjectDiff(ap, bp, rv, prfx + prop + '.'); + } + else if (ap !== bp) { + rv[prfx + prop] = b[prop]; + } + } + else if (ap !== bp) + rv[prfx + prop] = b[prop]; + } + }); + keys(b).forEach(function (prop) { + if (!hasOwn(a, prop)) { + rv[prfx + prop] = b[prop]; + } + }); + return rv; +} + +function getEffectiveKeys(primaryKey, req) { + if (req.type === 'delete') + return req.keys; + return req.keys || req.values.map(primaryKey.extractKey); +} + +var hooksMiddleware = { + stack: "dbcore", + name: "HooksMiddleware", + level: 2, + create: function (downCore) { return (__assign(__assign({}, downCore), { table: function (tableName) { + var downTable = downCore.table(tableName); + var primaryKey = downTable.schema.primaryKey; + var tableMiddleware = __assign(__assign({}, downTable), { mutate: function (req) { + var dxTrans = PSD.trans; + var _a = dxTrans.table(tableName).hook, deleting = _a.deleting, creating = _a.creating, updating = _a.updating; + switch (req.type) { + case 'add': + if (creating.fire === nop) + break; + return dxTrans._promise('readwrite', function () { return addPutOrDelete(req); }, true); + case 'put': + if (creating.fire === nop && updating.fire === nop) + break; + return dxTrans._promise('readwrite', function () { return addPutOrDelete(req); }, true); + case 'delete': + if (deleting.fire === nop) + break; + return dxTrans._promise('readwrite', function () { return addPutOrDelete(req); }, true); + case 'deleteRange': + if (deleting.fire === nop) + break; + return dxTrans._promise('readwrite', function () { return deleteRange(req); }, true); + } + return downTable.mutate(req); + function addPutOrDelete(req) { + var dxTrans = PSD.trans; + var keys = req.keys || getEffectiveKeys(primaryKey, req); + if (!keys) + throw new Error("Keys missing"); + req = req.type === 'add' || req.type === 'put' ? __assign(__assign({}, req), { keys: keys }) : __assign({}, req); + if (req.type !== 'delete') + req.values = __spreadArray([], req.values, true); + if (req.keys) + req.keys = __spreadArray([], req.keys, true); + return getExistingValues(downTable, req, keys).then(function (existingValues) { + var contexts = keys.map(function (key, i) { + var existingValue = existingValues[i]; + var ctx = { onerror: null, onsuccess: null }; + if (req.type === 'delete') { + deleting.fire.call(ctx, key, existingValue, dxTrans); + } + else if (req.type === 'add' || existingValue === undefined) { + var generatedPrimaryKey = creating.fire.call(ctx, key, req.values[i], dxTrans); + if (key == null && generatedPrimaryKey != null) { + key = generatedPrimaryKey; + req.keys[i] = key; + if (!primaryKey.outbound) { + setByKeyPath(req.values[i], primaryKey.keyPath, key); + } + } + } + else { + var objectDiff = getObjectDiff(existingValue, req.values[i]); + var additionalChanges_1 = updating.fire.call(ctx, objectDiff, key, existingValue, dxTrans); + if (additionalChanges_1) { + var requestedValue_1 = req.values[i]; + Object.keys(additionalChanges_1).forEach(function (keyPath) { + if (hasOwn(requestedValue_1, keyPath)) { + requestedValue_1[keyPath] = additionalChanges_1[keyPath]; + } + else { + setByKeyPath(requestedValue_1, keyPath, additionalChanges_1[keyPath]); + } + }); + } + } + return ctx; + }); + return downTable.mutate(req).then(function (_a) { + var failures = _a.failures, results = _a.results, numFailures = _a.numFailures, lastResult = _a.lastResult; + for (var i = 0; i < keys.length; ++i) { + var primKey = results ? results[i] : keys[i]; + var ctx = contexts[i]; + if (primKey == null) { + ctx.onerror && ctx.onerror(failures[i]); + } + else { + ctx.onsuccess && ctx.onsuccess(req.type === 'put' && existingValues[i] ? + req.values[i] : + primKey + ); + } + } + return { failures: failures, results: results, numFailures: numFailures, lastResult: lastResult }; + }).catch(function (error) { + contexts.forEach(function (ctx) { return ctx.onerror && ctx.onerror(error); }); + return Promise.reject(error); + }); + }); + } + function deleteRange(req) { + return deleteNextChunk(req.trans, req.range, 10000); + } + function deleteNextChunk(trans, range, limit) { + return downTable.query({ trans: trans, values: false, query: { index: primaryKey, range: range }, limit: limit }) + .then(function (_a) { + var result = _a.result; + return addPutOrDelete({ type: 'delete', keys: result, trans: trans }).then(function (res) { + if (res.numFailures > 0) + return Promise.reject(res.failures[0]); + if (result.length < limit) { + return { failures: [], numFailures: 0, lastResult: undefined }; + } + else { + return deleteNextChunk(trans, __assign(__assign({}, range), { lower: result[result.length - 1], lowerOpen: true }), limit); + } + }); + }); + } + } }); + return tableMiddleware; + } })); } +}; +function getExistingValues(table, req, effectiveKeys) { + return req.type === "add" + ? Promise.resolve([]) + : table.getMany({ trans: req.trans, keys: effectiveKeys, cache: "immutable" }); +} + +function getFromTransactionCache(keys, cache, clone) { + try { + if (!cache) + return null; + if (cache.keys.length < keys.length) + return null; + var result = []; + for (var i = 0, j = 0; i < cache.keys.length && j < keys.length; ++i) { + if (cmp(cache.keys[i], keys[j]) !== 0) + continue; + result.push(clone ? deepClone(cache.values[i]) : cache.values[i]); + ++j; + } + return result.length === keys.length ? result : null; + } + catch (_a) { + return null; + } +} +var cacheExistingValuesMiddleware = { + stack: "dbcore", + level: -1, + create: function (core) { + return { + table: function (tableName) { + var table = core.table(tableName); + return __assign(__assign({}, table), { getMany: function (req) { + if (!req.cache) { + return table.getMany(req); + } + var cachedResult = getFromTransactionCache(req.keys, req.trans["_cache"], req.cache === "clone"); + if (cachedResult) { + return DexiePromise.resolve(cachedResult); + } + return table.getMany(req).then(function (res) { + req.trans["_cache"] = { + keys: req.keys, + values: req.cache === "clone" ? deepClone(res) : res, + }; + return res; + }); + }, mutate: function (req) { + if (req.type !== "add") + req.trans["_cache"] = null; + return table.mutate(req); + } }); + }, + }; + }, +}; + +var _a; +function isEmptyRange(node) { + return !("from" in node); +} +var RangeSet = function (fromOrTree, to) { + if (this) { + extend(this, arguments.length ? { d: 1, from: fromOrTree, to: arguments.length > 1 ? to : fromOrTree } : { d: 0 }); + } + else { + var rv = new RangeSet(); + if (fromOrTree && ("d" in fromOrTree)) { + extend(rv, fromOrTree); + } + return rv; + } +}; +props(RangeSet.prototype, (_a = { + add: function (rangeSet) { + mergeRanges(this, rangeSet); + return this; + }, + addKey: function (key) { + addRange(this, key, key); + return this; + }, + addKeys: function (keys) { + var _this = this; + keys.forEach(function (key) { return addRange(_this, key, key); }); + return this; + } + }, + _a[iteratorSymbol] = function () { + return getRangeSetIterator(this); + }, + _a)); +function addRange(target, from, to) { + var diff = cmp(from, to); + if (isNaN(diff)) + return; + if (diff > 0) + throw RangeError(); + if (isEmptyRange(target)) + return extend(target, { from: from, to: to, d: 1 }); + var left = target.l; + var right = target.r; + if (cmp(to, target.from) < 0) { + left + ? addRange(left, from, to) + : (target.l = { from: from, to: to, d: 1, l: null, r: null }); + return rebalance(target); + } + if (cmp(from, target.to) > 0) { + right + ? addRange(right, from, to) + : (target.r = { from: from, to: to, d: 1, l: null, r: null }); + return rebalance(target); + } + if (cmp(from, target.from) < 0) { + target.from = from; + target.l = null; + target.d = right ? right.d + 1 : 1; + } + if (cmp(to, target.to) > 0) { + target.to = to; + target.r = null; + target.d = target.l ? target.l.d + 1 : 1; + } + var rightWasCutOff = !target.r; + if (left && !target.l) { + mergeRanges(target, left); + } + if (right && rightWasCutOff) { + mergeRanges(target, right); + } +} +function mergeRanges(target, newSet) { + function _addRangeSet(target, _a) { + var from = _a.from, to = _a.to, l = _a.l, r = _a.r; + addRange(target, from, to); + if (l) + _addRangeSet(target, l); + if (r) + _addRangeSet(target, r); + } + if (!isEmptyRange(newSet)) + _addRangeSet(target, newSet); +} +function rangesOverlap(rangeSet1, rangeSet2) { + var i1 = getRangeSetIterator(rangeSet2); + var nextResult1 = i1.next(); + if (nextResult1.done) + return false; + var a = nextResult1.value; + var i2 = getRangeSetIterator(rangeSet1); + var nextResult2 = i2.next(a.from); + var b = nextResult2.value; + while (!nextResult1.done && !nextResult2.done) { + if (cmp(b.from, a.to) <= 0 && cmp(b.to, a.from) >= 0) + return true; + cmp(a.from, b.from) < 0 + ? (a = (nextResult1 = i1.next(b.from)).value) + : (b = (nextResult2 = i2.next(a.from)).value); + } + return false; +} +function getRangeSetIterator(node) { + var state = isEmptyRange(node) ? null : { s: 0, n: node }; + return { + next: function (key) { + var keyProvided = arguments.length > 0; + while (state) { + switch (state.s) { + case 0: + state.s = 1; + if (keyProvided) { + while (state.n.l && cmp(key, state.n.from) < 0) + state = { up: state, n: state.n.l, s: 1 }; + } + else { + while (state.n.l) + state = { up: state, n: state.n.l, s: 1 }; + } + case 1: + state.s = 2; + if (!keyProvided || cmp(key, state.n.to) <= 0) + return { value: state.n, done: false }; + case 2: + if (state.n.r) { + state.s = 3; + state = { up: state, n: state.n.r, s: 0 }; + continue; + } + case 3: + state = state.up; + } + } + return { done: true }; + }, + }; +} +function rebalance(target) { + var _a, _b; + var diff = (((_a = target.r) === null || _a === void 0 ? void 0 : _a.d) || 0) - (((_b = target.l) === null || _b === void 0 ? void 0 : _b.d) || 0); + var r = diff > 1 ? "r" : diff < -1 ? "l" : ""; + if (r) { + var l = r === "r" ? "l" : "r"; + var rootClone = __assign({}, target); + var oldRootRight = target[r]; + target.from = oldRootRight.from; + target.to = oldRootRight.to; + target[r] = oldRootRight[r]; + rootClone[r] = oldRootRight[l]; + target[l] = rootClone; + rootClone.d = computeDepth(rootClone); + } + target.d = computeDepth(target); +} +function computeDepth(_a) { + var r = _a.r, l = _a.l; + return (r ? (l ? Math.max(r.d, l.d) : r.d) : l ? l.d : 0) + 1; +} + +var observabilityMiddleware = { + stack: "dbcore", + level: 0, + create: function (core) { + var dbName = core.schema.name; + var FULL_RANGE = new RangeSet(core.MIN_KEY, core.MAX_KEY); + return __assign(__assign({}, core), { table: function (tableName) { + var table = core.table(tableName); + var schema = table.schema; + var primaryKey = schema.primaryKey; + var extractKey = primaryKey.extractKey, outbound = primaryKey.outbound; + var tableClone = __assign(__assign({}, table), { mutate: function (req) { + var trans = req.trans; + var mutatedParts = trans.mutatedParts || (trans.mutatedParts = {}); + var getRangeSet = function (indexName) { + var part = "idb://" + dbName + "/" + tableName + "/" + indexName; + return (mutatedParts[part] || + (mutatedParts[part] = new RangeSet())); + }; + var pkRangeSet = getRangeSet(""); + var delsRangeSet = getRangeSet(":dels"); + var type = req.type; + var _a = req.type === "deleteRange" + ? [req.range] + : req.type === "delete" + ? [req.keys] + : req.values.length < 50 + ? [[], req.values] + : [], keys = _a[0], newObjs = _a[1]; + var oldCache = req.trans["_cache"]; + return table.mutate(req).then(function (res) { + if (isArray(keys)) { + if (type !== "delete") + keys = res.results; + pkRangeSet.addKeys(keys); + var oldObjs = getFromTransactionCache(keys, oldCache); + if (!oldObjs && type !== "add") { + delsRangeSet.addKeys(keys); + } + if (oldObjs || newObjs) { + trackAffectedIndexes(getRangeSet, schema, oldObjs, newObjs); + } + } + else if (keys) { + var range = { from: keys.lower, to: keys.upper }; + delsRangeSet.add(range); + pkRangeSet.add(range); + } + else { + pkRangeSet.add(FULL_RANGE); + delsRangeSet.add(FULL_RANGE); + schema.indexes.forEach(function (idx) { return getRangeSet(idx.name).add(FULL_RANGE); }); + } + return res; + }); + } }); + var getRange = function (_a) { + var _b, _c; + var _d = _a.query, index = _d.index, range = _d.range; + return [ + index, + new RangeSet((_b = range.lower) !== null && _b !== void 0 ? _b : core.MIN_KEY, (_c = range.upper) !== null && _c !== void 0 ? _c : core.MAX_KEY), + ]; + }; + var readSubscribers = { + get: function (req) { return [primaryKey, new RangeSet(req.key)]; }, + getMany: function (req) { return [primaryKey, new RangeSet().addKeys(req.keys)]; }, + count: getRange, + query: getRange, + openCursor: getRange, + }; + keys(readSubscribers).forEach(function (method) { + tableClone[method] = function (req) { + var subscr = PSD.subscr; + if (subscr) { + var getRangeSet = function (indexName) { + var part = "idb://" + dbName + "/" + tableName + "/" + indexName; + return (subscr[part] || + (subscr[part] = new RangeSet())); + }; + var pkRangeSet_1 = getRangeSet(""); + var delsRangeSet_1 = getRangeSet(":dels"); + var _a = readSubscribers[method](req), queriedIndex = _a[0], queriedRanges = _a[1]; + getRangeSet(queriedIndex.name || "").add(queriedRanges); + if (!queriedIndex.isPrimaryKey) { + if (method === "count") { + delsRangeSet_1.add(FULL_RANGE); + } + else { + var keysPromise_1 = method === "query" && + outbound && + req.values && + table.query(__assign(__assign({}, req), { values: false })); + return table[method].apply(this, arguments).then(function (res) { + if (method === "query") { + if (outbound && req.values) { + return keysPromise_1.then(function (_a) { + var resultingKeys = _a.result; + pkRangeSet_1.addKeys(resultingKeys); + return res; + }); + } + var pKeys = req.values + ? res.result.map(extractKey) + : res.result; + if (req.values) { + pkRangeSet_1.addKeys(pKeys); + } + else { + delsRangeSet_1.addKeys(pKeys); + } + } + else if (method === "openCursor") { + var cursor_1 = res; + var wantValues_1 = req.values; + return (cursor_1 && + Object.create(cursor_1, { + key: { + get: function () { + delsRangeSet_1.addKey(cursor_1.primaryKey); + return cursor_1.key; + }, + }, + primaryKey: { + get: function () { + var pkey = cursor_1.primaryKey; + delsRangeSet_1.addKey(pkey); + return pkey; + }, + }, + value: { + get: function () { + wantValues_1 && pkRangeSet_1.addKey(cursor_1.primaryKey); + return cursor_1.value; + }, + }, + })); + } + return res; + }); + } + } + } + return table[method].apply(this, arguments); + }; + }); + return tableClone; + } }); + }, +}; +function trackAffectedIndexes(getRangeSet, schema, oldObjs, newObjs) { + function addAffectedIndex(ix) { + var rangeSet = getRangeSet(ix.name || ""); + function extractKey(obj) { + return obj != null ? ix.extractKey(obj) : null; + } + var addKeyOrKeys = function (key) { return ix.multiEntry && isArray(key) + ? key.forEach(function (key) { return rangeSet.addKey(key); }) + : rangeSet.addKey(key); }; + (oldObjs || newObjs).forEach(function (_, i) { + var oldKey = oldObjs && extractKey(oldObjs[i]); + var newKey = newObjs && extractKey(newObjs[i]); + if (cmp(oldKey, newKey) !== 0) { + if (oldKey != null) + addKeyOrKeys(oldKey); + if (newKey != null) + addKeyOrKeys(newKey); + } + }); + } + schema.indexes.forEach(addAffectedIndex); +} + +var Dexie$1 = (function () { + function Dexie(name, options) { + var _this = this; + this._middlewares = {}; + this.verno = 0; + var deps = Dexie.dependencies; + this._options = options = __assign({ + addons: Dexie.addons, autoOpen: true, + indexedDB: deps.indexedDB, IDBKeyRange: deps.IDBKeyRange }, options); + this._deps = { + indexedDB: options.indexedDB, + IDBKeyRange: options.IDBKeyRange + }; + var addons = options.addons; + this._dbSchema = {}; + this._versions = []; + this._storeNames = []; + this._allTables = {}; + this.idbdb = null; + this._novip = this; + var state = { + dbOpenError: null, + isBeingOpened: false, + onReadyBeingFired: null, + openComplete: false, + dbReadyResolve: nop, + dbReadyPromise: null, + cancelOpen: nop, + openCanceller: null, + autoSchema: true, + PR1398_maxLoop: 3 + }; + state.dbReadyPromise = new DexiePromise(function (resolve) { + state.dbReadyResolve = resolve; + }); + state.openCanceller = new DexiePromise(function (_, reject) { + state.cancelOpen = reject; + }); + this._state = state; + this.name = name; + this.on = Events(this, "populate", "blocked", "versionchange", "close", { ready: [promisableChain, nop] }); + this.on.ready.subscribe = override(this.on.ready.subscribe, function (subscribe) { + return function (subscriber, bSticky) { + Dexie.vip(function () { + var state = _this._state; + if (state.openComplete) { + if (!state.dbOpenError) + DexiePromise.resolve().then(subscriber); + if (bSticky) + subscribe(subscriber); + } + else if (state.onReadyBeingFired) { + state.onReadyBeingFired.push(subscriber); + if (bSticky) + subscribe(subscriber); + } + else { + subscribe(subscriber); + var db_1 = _this; + if (!bSticky) + subscribe(function unsubscribe() { + db_1.on.ready.unsubscribe(subscriber); + db_1.on.ready.unsubscribe(unsubscribe); + }); + } + }); + }; + }); + this.Collection = createCollectionConstructor(this); + this.Table = createTableConstructor(this); + this.Transaction = createTransactionConstructor(this); + this.Version = createVersionConstructor(this); + this.WhereClause = createWhereClauseConstructor(this); + this.on("versionchange", function (ev) { + if (ev.newVersion > 0) + console.warn("Another connection wants to upgrade database '" + _this.name + "'. Closing db now to resume the upgrade."); + else + console.warn("Another connection wants to delete database '" + _this.name + "'. Closing db now to resume the delete request."); + _this.close(); + }); + this.on("blocked", function (ev) { + if (!ev.newVersion || ev.newVersion < ev.oldVersion) + console.warn("Dexie.delete('" + _this.name + "') was blocked"); + else + console.warn("Upgrade '" + _this.name + "' blocked by other connection holding version " + ev.oldVersion / 10); + }); + this._maxKey = getMaxKey(options.IDBKeyRange); + this._createTransaction = function (mode, storeNames, dbschema, parentTransaction) { return new _this.Transaction(mode, storeNames, dbschema, _this._options.chromeTransactionDurability, parentTransaction); }; + this._fireOnBlocked = function (ev) { + _this.on("blocked").fire(ev); + connections + .filter(function (c) { return c.name === _this.name && c !== _this && !c._state.vcFired; }) + .map(function (c) { return c.on("versionchange").fire(ev); }); + }; + this.use(virtualIndexMiddleware); + this.use(hooksMiddleware); + this.use(observabilityMiddleware); + this.use(cacheExistingValuesMiddleware); + this.vip = Object.create(this, { _vip: { value: true } }); + addons.forEach(function (addon) { return addon(_this); }); + } + Dexie.prototype.version = function (versionNumber) { + if (isNaN(versionNumber) || versionNumber < 0.1) + throw new exceptions.Type("Given version is not a positive number"); + versionNumber = Math.round(versionNumber * 10) / 10; + if (this.idbdb || this._state.isBeingOpened) + throw new exceptions.Schema("Cannot add version when database is open"); + this.verno = Math.max(this.verno, versionNumber); + var versions = this._versions; + var versionInstance = versions.filter(function (v) { return v._cfg.version === versionNumber; })[0]; + if (versionInstance) + return versionInstance; + versionInstance = new this.Version(versionNumber); + versions.push(versionInstance); + versions.sort(lowerVersionFirst); + versionInstance.stores({}); + this._state.autoSchema = false; + return versionInstance; + }; + Dexie.prototype._whenReady = function (fn) { + var _this = this; + return (this.idbdb && (this._state.openComplete || PSD.letThrough || this._vip)) ? fn() : new DexiePromise(function (resolve, reject) { + if (_this._state.openComplete) { + return reject(new exceptions.DatabaseClosed(_this._state.dbOpenError)); + } + if (!_this._state.isBeingOpened) { + if (!_this._options.autoOpen) { + reject(new exceptions.DatabaseClosed()); + return; + } + _this.open().catch(nop); + } + _this._state.dbReadyPromise.then(resolve, reject); + }).then(fn); + }; + Dexie.prototype.use = function (_a) { + var stack = _a.stack, create = _a.create, level = _a.level, name = _a.name; + if (name) + this.unuse({ stack: stack, name: name }); + var middlewares = this._middlewares[stack] || (this._middlewares[stack] = []); + middlewares.push({ stack: stack, create: create, level: level == null ? 10 : level, name: name }); + middlewares.sort(function (a, b) { return a.level - b.level; }); + return this; + }; + Dexie.prototype.unuse = function (_a) { + var stack = _a.stack, name = _a.name, create = _a.create; + if (stack && this._middlewares[stack]) { + this._middlewares[stack] = this._middlewares[stack].filter(function (mw) { + return create ? mw.create !== create : + name ? mw.name !== name : + false; + }); + } + return this; + }; + Dexie.prototype.open = function () { + return dexieOpen(this); + }; + Dexie.prototype._close = function () { + var state = this._state; + var idx = connections.indexOf(this); + if (idx >= 0) + connections.splice(idx, 1); + if (this.idbdb) { + try { + this.idbdb.close(); + } + catch (e) { } + this._novip.idbdb = null; + } + state.dbReadyPromise = new DexiePromise(function (resolve) { + state.dbReadyResolve = resolve; + }); + state.openCanceller = new DexiePromise(function (_, reject) { + state.cancelOpen = reject; + }); + }; + Dexie.prototype.close = function () { + this._close(); + var state = this._state; + this._options.autoOpen = false; + state.dbOpenError = new exceptions.DatabaseClosed(); + if (state.isBeingOpened) + state.cancelOpen(state.dbOpenError); + }; + Dexie.prototype.delete = function () { + var _this = this; + var hasArguments = arguments.length > 0; + var state = this._state; + return new DexiePromise(function (resolve, reject) { + var doDelete = function () { + _this.close(); + var req = _this._deps.indexedDB.deleteDatabase(_this.name); + req.onsuccess = wrap(function () { + _onDatabaseDeleted(_this._deps, _this.name); + resolve(); + }); + req.onerror = eventRejectHandler(reject); + req.onblocked = _this._fireOnBlocked; + }; + if (hasArguments) + throw new exceptions.InvalidArgument("Arguments not allowed in db.delete()"); + if (state.isBeingOpened) { + state.dbReadyPromise.then(doDelete); + } + else { + doDelete(); + } + }); + }; + Dexie.prototype.backendDB = function () { + return this.idbdb; + }; + Dexie.prototype.isOpen = function () { + return this.idbdb !== null; + }; + Dexie.prototype.hasBeenClosed = function () { + var dbOpenError = this._state.dbOpenError; + return dbOpenError && (dbOpenError.name === 'DatabaseClosed'); + }; + Dexie.prototype.hasFailed = function () { + return this._state.dbOpenError !== null; + }; + Dexie.prototype.dynamicallyOpened = function () { + return this._state.autoSchema; + }; + Object.defineProperty(Dexie.prototype, "tables", { + get: function () { + var _this = this; + return keys(this._allTables).map(function (name) { return _this._allTables[name]; }); + }, + enumerable: false, + configurable: true + }); + Dexie.prototype.transaction = function () { + var args = extractTransactionArgs.apply(this, arguments); + return this._transaction.apply(this, args); + }; + Dexie.prototype._transaction = function (mode, tables, scopeFunc) { + var _this = this; + var parentTransaction = PSD.trans; + if (!parentTransaction || parentTransaction.db !== this || mode.indexOf('!') !== -1) + parentTransaction = null; + var onlyIfCompatible = mode.indexOf('?') !== -1; + mode = mode.replace('!', '').replace('?', ''); + var idbMode, storeNames; + try { + storeNames = tables.map(function (table) { + var storeName = table instanceof _this.Table ? table.name : table; + if (typeof storeName !== 'string') + throw new TypeError("Invalid table argument to Dexie.transaction(). Only Table or String are allowed"); + return storeName; + }); + if (mode == "r" || mode === READONLY) + idbMode = READONLY; + else if (mode == "rw" || mode == READWRITE) + idbMode = READWRITE; + else + throw new exceptions.InvalidArgument("Invalid transaction mode: " + mode); + if (parentTransaction) { + if (parentTransaction.mode === READONLY && idbMode === READWRITE) { + if (onlyIfCompatible) { + parentTransaction = null; + } + else + throw new exceptions.SubTransaction("Cannot enter a sub-transaction with READWRITE mode when parent transaction is READONLY"); + } + if (parentTransaction) { + storeNames.forEach(function (storeName) { + if (parentTransaction && parentTransaction.storeNames.indexOf(storeName) === -1) { + if (onlyIfCompatible) { + parentTransaction = null; + } + else + throw new exceptions.SubTransaction("Table " + storeName + + " not included in parent transaction."); + } + }); + } + if (onlyIfCompatible && parentTransaction && !parentTransaction.active) { + parentTransaction = null; + } + } + } + catch (e) { + return parentTransaction ? + parentTransaction._promise(null, function (_, reject) { reject(e); }) : + rejection(e); + } + var enterTransaction = enterTransactionScope.bind(null, this, idbMode, storeNames, parentTransaction, scopeFunc); + return (parentTransaction ? + parentTransaction._promise(idbMode, enterTransaction, "lock") : + PSD.trans ? + usePSD(PSD.transless, function () { return _this._whenReady(enterTransaction); }) : + this._whenReady(enterTransaction)); + }; + Dexie.prototype.table = function (tableName) { + if (!hasOwn(this._allTables, tableName)) { + throw new exceptions.InvalidTable("Table " + tableName + " does not exist"); + } + return this._allTables[tableName]; + }; + return Dexie; +}()); + +var symbolObservable = typeof Symbol !== "undefined" && "observable" in Symbol + ? Symbol.observable + : "@@observable"; +var Observable = (function () { + function Observable(subscribe) { + this._subscribe = subscribe; + } + Observable.prototype.subscribe = function (x, error, complete) { + return this._subscribe(!x || typeof x === "function" ? { next: x, error: error, complete: complete } : x); + }; + Observable.prototype[symbolObservable] = function () { + return this; + }; + return Observable; +}()); + +function extendObservabilitySet(target, newSet) { + keys(newSet).forEach(function (part) { + var rangeSet = target[part] || (target[part] = new RangeSet()); + mergeRanges(rangeSet, newSet[part]); + }); + return target; +} + +function liveQuery(querier) { + return new Observable(function (observer) { + var scopeFuncIsAsync = isAsyncFunction(querier); + function execute(subscr) { + if (scopeFuncIsAsync) { + incrementExpectedAwaits(); + } + var exec = function () { return newScope(querier, { subscr: subscr, trans: null }); }; + var rv = PSD.trans + ? + usePSD(PSD.transless, exec) + : exec(); + if (scopeFuncIsAsync) { + rv.then(decrementExpectedAwaits, decrementExpectedAwaits); + } + return rv; + } + var closed = false; + var accumMuts = {}; + var currentObs = {}; + var subscription = { + get closed() { + return closed; + }, + unsubscribe: function () { + closed = true; + globalEvents.storagemutated.unsubscribe(mutationListener); + }, + }; + observer.start && observer.start(subscription); + var querying = false, startedListening = false; + function shouldNotify() { + return keys(currentObs).some(function (key) { + return accumMuts[key] && rangesOverlap(accumMuts[key], currentObs[key]); + }); + } + var mutationListener = function (parts) { + extendObservabilitySet(accumMuts, parts); + if (shouldNotify()) { + doQuery(); + } + }; + var doQuery = function () { + if (querying || closed) + return; + accumMuts = {}; + var subscr = {}; + var ret = execute(subscr); + if (!startedListening) { + globalEvents(DEXIE_STORAGE_MUTATED_EVENT_NAME, mutationListener); + startedListening = true; + } + querying = true; + Promise.resolve(ret).then(function (result) { + querying = false; + if (closed) + return; + if (shouldNotify()) { + doQuery(); + } + else { + accumMuts = {}; + currentObs = subscr; + observer.next && observer.next(result); + } + }, function (err) { + querying = false; + observer.error && observer.error(err); + subscription.unsubscribe(); + }); + }; + doQuery(); + return subscription; + }); +} + +var domDeps; +try { + domDeps = { + indexedDB: _global.indexedDB || _global.mozIndexedDB || _global.webkitIndexedDB || _global.msIndexedDB, + IDBKeyRange: _global.IDBKeyRange || _global.webkitIDBKeyRange + }; +} +catch (e) { + domDeps = { indexedDB: null, IDBKeyRange: null }; +} + +export var Dexie = Dexie$1; +props(Dexie, __assign(__assign({}, fullNameExceptions), { + delete: function (databaseName) { + var db = new Dexie(databaseName, { addons: [] }); + return db.delete(); + }, + exists: function (name) { + return new Dexie(name, { addons: [] }).open().then(function (db) { + db.close(); + return true; + }).catch('NoSuchDatabaseError', function () { return false; }); + }, + getDatabaseNames: function (cb) { + try { + return getDatabaseNames(Dexie.dependencies).then(cb); + } + catch (_a) { + return rejection(new exceptions.MissingAPI()); + } + }, + defineClass: function () { + function Class(content) { + extend(this, content); + } + return Class; + }, ignoreTransaction: function (scopeFunc) { + return PSD.trans ? + usePSD(PSD.transless, scopeFunc) : + scopeFunc(); + }, vip: vip, async: function (generatorFn) { + return function () { + try { + var rv = awaitIterator(generatorFn.apply(this, arguments)); + if (!rv || typeof rv.then !== 'function') + return DexiePromise.resolve(rv); + return rv; + } + catch (e) { + return rejection(e); + } + }; + }, spawn: function (generatorFn, args, thiz) { + try { + var rv = awaitIterator(generatorFn.apply(thiz, args || [])); + if (!rv || typeof rv.then !== 'function') + return DexiePromise.resolve(rv); + return rv; + } + catch (e) { + return rejection(e); + } + }, + currentTransaction: { + get: function () { return PSD.trans || null; } + }, waitFor: function (promiseOrFunction, optionalTimeout) { + var promise = DexiePromise.resolve(typeof promiseOrFunction === 'function' ? + Dexie.ignoreTransaction(promiseOrFunction) : + promiseOrFunction) + .timeout(optionalTimeout || 60000); + return PSD.trans ? + PSD.trans.waitFor(promise) : + promise; + }, + Promise: DexiePromise, + debug: { + get: function () { return debug; }, + set: function (value) { + setDebug(value, value === 'dexie' ? function () { return true; } : dexieStackFrameFilter); + } + }, + derive: derive, extend: extend, props: props, override: override, + Events: Events, on: globalEvents, liveQuery: liveQuery, extendObservabilitySet: extendObservabilitySet, + getByKeyPath: getByKeyPath, setByKeyPath: setByKeyPath, delByKeyPath: delByKeyPath, shallowClone: shallowClone, deepClone: deepClone, getObjectDiff: getObjectDiff, cmp: cmp, asap: asap$1, + minKey: minKey, + addons: [], + connections: connections, + errnames: errnames, + dependencies: domDeps, + semVer: DEXIE_VERSION, version: DEXIE_VERSION.split('.') + .map(function (n) { return parseInt(n); }) + .reduce(function (p, c, i) { return p + (c / Math.pow(10, i * 2)); }) })); +Dexie.maxKey = getMaxKey(Dexie.dependencies.IDBKeyRange); + +if (typeof dispatchEvent !== 'undefined' && typeof addEventListener !== 'undefined') { + globalEvents(DEXIE_STORAGE_MUTATED_EVENT_NAME, function (updatedParts) { + if (!propagatingLocally) { + var event_1; + if (isIEOrEdge) { + event_1 = document.createEvent('CustomEvent'); + event_1.initCustomEvent(STORAGE_MUTATED_DOM_EVENT_NAME, true, true, updatedParts); + } + else { + event_1 = new CustomEvent(STORAGE_MUTATED_DOM_EVENT_NAME, { + detail: updatedParts + }); + } + propagatingLocally = true; + dispatchEvent(event_1); + propagatingLocally = false; + } + }); + addEventListener(STORAGE_MUTATED_DOM_EVENT_NAME, function (_a) { + var detail = _a.detail; + if (!propagatingLocally) { + propagateLocally(detail); + } + }); +} +function propagateLocally(updateParts) { + var wasMe = propagatingLocally; + try { + propagatingLocally = true; + globalEvents.storagemutated.fire(updateParts); + } + finally { + propagatingLocally = wasMe; + } +} +var propagatingLocally = false; + +if (typeof BroadcastChannel !== 'undefined') { + var bc_1 = new BroadcastChannel(STORAGE_MUTATED_DOM_EVENT_NAME); + globalEvents(DEXIE_STORAGE_MUTATED_EVENT_NAME, function (changedParts) { + if (!propagatingLocally) { + bc_1.postMessage(changedParts); + } + }); + bc_1.onmessage = function (ev) { + if (ev.data) + propagateLocally(ev.data); + }; +} +else if (typeof self !== 'undefined' && typeof navigator !== 'undefined') { + globalEvents(DEXIE_STORAGE_MUTATED_EVENT_NAME, function (changedParts) { + try { + if (!propagatingLocally) { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_MUTATED_DOM_EVENT_NAME, JSON.stringify({ + trig: Math.random(), + changedParts: changedParts, + })); + } + if (typeof self['clients'] === 'object') { + __spreadArray([], self['clients'].matchAll({ includeUncontrolled: true }), true).forEach(function (client) { + return client.postMessage({ + type: STORAGE_MUTATED_DOM_EVENT_NAME, + changedParts: changedParts, + }); + }); + } + } + } + catch (_a) { } + }); + if (typeof addEventListener !== 'undefined') { + addEventListener('storage', function (ev) { + if (ev.key === STORAGE_MUTATED_DOM_EVENT_NAME) { + var data = JSON.parse(ev.newValue); + if (data) + propagateLocally(data.changedParts); + } + }); + } + var swContainer = self.document && navigator.serviceWorker; + if (swContainer) { + swContainer.addEventListener('message', propagateMessageLocally); + } +} +function propagateMessageLocally(_a) { + var data = _a.data; + if (data && data.type === STORAGE_MUTATED_DOM_EVENT_NAME) { + propagateLocally(data.changedParts); + } +} + +DexiePromise.rejectionMapper = mapError; +setDebug(debug, dexieStackFrameFilter); + +var namedExports = /*#__PURE__*/Object.freeze({ + __proto__: null, + Dexie: Dexie$1, + liveQuery: liveQuery, + 'default': Dexie$1, + RangeSet: RangeSet, + mergeRanges: mergeRanges, + rangesOverlap: rangesOverlap +}); + +__assign(Dexie$1, namedExports, { default: Dexie$1 }); + diff --git a/addon/lib/dropbox/auth.js b/addon/lib/dropbox/auth.js new file mode 100644 index 00000000..b4fa2245 --- /dev/null +++ b/addon/lib/dropbox/auth.js @@ -0,0 +1,436 @@ +import { + getTokenExpiresAtDate, + isBrowserEnv, + createBrowserSafeString, +} from './utils.js'; +import { parseResponse } from './response.js'; + +let fetch; +if (isBrowserEnv()) { + fetch = globalThis.fetch.bind(globalThis); +} else { + fetch = require('node-fetch'); // eslint-disable-line global-require +} + +let crypto; +if (isBrowserEnv()) { + crypto = globalThis.crypto || globalThis.msCrypto; // for IE11 +} else { + crypto = require('crypto'); // eslint-disable-line global-require +} + +let Encoder; +if (typeof TextEncoder === 'undefined') { + Encoder = require('util').TextEncoder; // eslint-disable-line global-require +} else { + Encoder = TextEncoder; +} + +// Expiration is 300 seconds but needs to be in milliseconds for Date object +const TokenExpirationBuffer = 300 * 1000; +const PKCELength = 128; +const TokenAccessTypes = ['legacy', 'offline', 'online']; +const GrantTypes = ['code', 'token']; +const IncludeGrantedScopes = ['none', 'user', 'team']; +const BaseAuthorizeUrl = 'https://www.dropbox.com/oauth2/authorize'; +const BaseTokenUrl = 'https://api.dropboxapi.com/oauth2/token'; + +/** + * @class DropboxAuth + * @classdesc The DropboxAuth class that provides methods to manage, acquire, and refresh tokens. + * @arg {Object} options + * @arg {Function} [options.fetch] - fetch library for making requests. + * @arg {String} [options.accessToken] - An access token for making authenticated + * requests. + * @arg {Date} [options.AccessTokenExpiresAt] - Date of the current access token's + * expiration (if available) + * @arg {String} [options.refreshToken] - A refresh token for retrieving access tokens + * @arg {String} [options.clientId] - The client id for your app. Used to create + * authentication URL. + * @arg {String} [options.clientSecret] - The client secret for your app. Used to create + * authentication URL and refresh access tokens. + */ +export default class DropboxAuth { + constructor(options) { + options = options || {}; + + this.fetch = options.fetch || fetch; + this.accessToken = options.accessToken; + this.accessTokenExpiresAt = options.accessTokenExpiresAt; + this.refreshToken = options.refreshToken; + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + } + + /** + * Set the access token used to authenticate requests to the API. + * @arg {String} accessToken - An access token + * @returns {undefined} + */ + setAccessToken(accessToken) { + this.accessToken = accessToken; + } + + /** + * Get the access token + * @returns {String} Access token + */ + getAccessToken() { + return this.accessToken; + } + + /** + * Set the client id, which is used to help gain an access token. + * @arg {String} clientId - Your apps client id + * @returns {undefined} + */ + setClientId(clientId) { + this.clientId = clientId; + } + + /** + * Get the client id + * @returns {String} Client id + */ + getClientId() { + return this.clientId; + } + + /** + * Set the client secret + * @arg {String} clientSecret - Your app's client secret + * @returns {undefined} + */ + setClientSecret(clientSecret) { + this.clientSecret = clientSecret; + } + + /** + * Get the client secret + * @returns {String} Client secret + */ + getClientSecret() { + return this.clientSecret; + } + + /** + * Gets the refresh token + * @returns {String} Refresh token + */ + getRefreshToken() { + return this.refreshToken; + } + + /** + * Sets the refresh token + * @param refreshToken - A refresh token + */ + setRefreshToken(refreshToken) { + this.refreshToken = refreshToken; + } + + /** + * Gets the access token's expiration date + * @returns {Date} date of token expiration + */ + getAccessTokenExpiresAt() { + return this.accessTokenExpiresAt; + } + + /** + * Sets the access token's expiration date + * @param accessTokenExpiresAt - new expiration date + */ + setAccessTokenExpiresAt(accessTokenExpiresAt) { + this.accessTokenExpiresAt = accessTokenExpiresAt; + } + + /** + * Sets the code verifier for PKCE flow + * @param {String} codeVerifier - new code verifier + */ + setCodeVerifier(codeVerifier) { + this.codeVerifier = codeVerifier; + } + + /** + * Gets the code verifier for PKCE flow + * @returns {String} - code verifier for PKCE + */ + getCodeVerifier() { + return this.codeVerifier; + } + + generateCodeChallenge() { + const encoder = new Encoder(); + const codeData = encoder.encode(this.codeVerifier); + let codeChallenge; + if (isBrowserEnv()) { + return crypto.subtle.digest('SHA-256', codeData) + .then((digestedHash) => { + const base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(digestedHash))); + codeChallenge = createBrowserSafeString(base64String).substr(0, 128); + this.codeChallenge = codeChallenge; + }); + } + const digestedHash = crypto.createHash('sha256').update(codeData).digest(); + codeChallenge = createBrowserSafeString(digestedHash); + this.codeChallenge = codeChallenge; + return Promise.resolve(); + } + + generatePKCECodes() { + let codeVerifier; + if (isBrowserEnv()) { + const array = new Uint8Array(PKCELength); + const randomValueArray = crypto.getRandomValues(array); + const base64String = btoa(randomValueArray); + codeVerifier = createBrowserSafeString(base64String).substr(0, 128); + } else { + const randomBytes = crypto.randomBytes(PKCELength); + codeVerifier = createBrowserSafeString(randomBytes).substr(0, 128); + } + this.codeVerifier = codeVerifier; + + return this.generateCodeChallenge(); + } + + /** + * Get a URL that can be used to authenticate users for the Dropbox API. + * @arg {String} redirectUri - A URL to redirect the user to after + * authenticating. This must be added to your app through the admin interface. + * @arg {String} [state] - State that will be returned in the redirect URL to help + * prevent cross site scripting attacks. + * @arg {String} [authType] - auth type, defaults to 'token', other option is 'code' + * @arg {String} [tokenAccessType] - type of token to request. From the following: + * null - creates a token with the app default (either legacy or online) + * legacy - creates one long-lived token with no expiration + * online - create one short-lived token with an expiration + * offline - create one short-lived token with an expiration with a refresh token + * @arg {Array} [scope] - scopes to request for the grant + * @arg {String} [includeGrantedScopes] - whether or not to include previously granted scopes. + * From the following: + * user - include user scopes in the grant + * team - include team scopes in the grant + * Note: if this user has never linked the app, include_granted_scopes must be None + * @arg {boolean} [usePKCE] - Whether or not to use Sha256 based PKCE. PKCE should be only use + * on client apps which doesn't call your server. It is less secure than non-PKCE flow but + * can be used if you are unable to safely retrieve your app secret + * @returns {Promise} - Url to send user to for Dropbox API authentication + * returned in a promise + */ + getAuthenticationUrl(redirectUri, state, authType = 'token', tokenAccessType = null, scope = null, includeGrantedScopes = 'none', usePKCE = false) { + const clientId = this.getClientId(); + const baseUrl = BaseAuthorizeUrl; + + if (!clientId) { + throw new Error('A client id is required. You can set the client id using .setClientId().'); + } + if (authType !== 'code' && !redirectUri) { + throw new Error('A redirect uri is required.'); + } + if (!GrantTypes.includes(authType)) { + throw new Error('Authorization type must be code or token'); + } + if (tokenAccessType && !TokenAccessTypes.includes(tokenAccessType)) { + throw new Error('Token Access Type must be legacy, offline, or online'); + } + if (scope && !(scope instanceof Array)) { + throw new Error('Scope must be an array of strings'); + } + if (!IncludeGrantedScopes.includes(includeGrantedScopes)) { + throw new Error('includeGrantedScopes must be none, user, or team'); + } + + let authUrl; + if (authType === 'code') { + authUrl = `${baseUrl}?response_type=code&client_id=${clientId}`; + } else { + authUrl = `${baseUrl}?response_type=token&client_id=${clientId}`; + } + + if (redirectUri) { + authUrl += `&redirect_uri=${redirectUri}`; + } + if (state) { + authUrl += `&state=${state}`; + } + if (tokenAccessType) { + authUrl += `&token_access_type=${tokenAccessType}`; + } + if (scope) { + authUrl += `&scope=${scope.join(' ')}`; + } + if (includeGrantedScopes !== 'none') { + authUrl += `&include_granted_scopes=${includeGrantedScopes}`; + } + if (usePKCE) { + return this.generatePKCECodes() + .then(() => { + authUrl += '&code_challenge_method=S256'; + authUrl += `&code_challenge=${this.codeChallenge}`; + return authUrl; + }); + } + return Promise.resolve(authUrl); + } + + /** + * Get an OAuth2 access token from an OAuth2 Code. + * @arg {String} redirectUri - A URL to redirect the user to after + * authenticating. This must be added to your app through the admin interface. + * @arg {String} code - An OAuth2 code. + * @returns {Object} An object containing the token and related info (if applicable) + */ + getAccessTokenFromCode(redirectUri, code) { + const clientId = this.getClientId(); + const clientSecret = this.getClientSecret(); + + if (!clientId) { + throw new Error('A client id is required. You can set the client id using .setClientId().'); + } + let path = BaseTokenUrl; + path += '?grant_type=authorization_code'; + path += `&code=${code}`; + path += `&client_id=${clientId}`; + + if (clientSecret) { + path += `&client_secret=${clientSecret}`; + } else { + if (!this.codeVerifier) { + throw new Error('You must use PKCE when generating the authorization URL to not include a client secret'); + } + path += `&code_verifier=${this.codeVerifier}`; + } + if (redirectUri) { + path += `&redirect_uri=${redirectUri}`; + } + + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + return this.fetch(path, fetchOptions) + .then((res) => parseResponse(res)); + } + + /** + * Checks if a token is needed, can be refreshed and if the token is expired. + * If so, attempts to refresh access token + * @returns {Promise<*>} + */ + checkAndRefreshAccessToken() { + const canRefresh = this.getRefreshToken() && this.getClientId(); + const needsRefresh = !this.getAccessTokenExpiresAt() + || (new Date(Date.now() + TokenExpirationBuffer)) >= this.getAccessTokenExpiresAt(); + const needsToken = !this.getAccessToken(); + if ((needsRefresh || needsToken) && canRefresh) { + return this.refreshAccessToken(); + } + return Promise.resolve(); + } + + /** + * Refreshes the access token using the refresh token, if available + * @arg {Array} scope - a subset of scopes from the original + * refresh to acquire with an access token + * @returns {Promise<*>} + */ + refreshAccessToken(scope = null) { + let refreshUrl = BaseTokenUrl; + const clientId = this.getClientId(); + const clientSecret = this.getClientSecret(); + + if (!clientId) { + throw new Error('A client id is required. You can set the client id using .setClientId().'); + } + if (scope && !(scope instanceof Array)) { + throw new Error('Scope must be an array of strings'); + } + + const headers = {}; + headers['Content-Type'] = 'application/json'; + refreshUrl += `?grant_type=refresh_token&refresh_token=${this.getRefreshToken()}`; + refreshUrl += `&client_id=${clientId}`; + if (clientSecret) { + refreshUrl += `&client_secret=${clientSecret}`; + } + if (scope) { + refreshUrl += `&scope=${scope.join(' ')}`; + } + const fetchOptions = { + method: 'POST', + }; + + fetchOptions.headers = headers; + + return this.fetch(refreshUrl, fetchOptions) + .then((res) => parseResponse(res)) + .then((res) => { + this.setAccessToken(res.result.access_token); + this.setAccessTokenExpiresAt(getTokenExpiresAtDate(res.result.expires_in)); + }); + } + + /** + * An authentication process that works with cordova applications. + * @param {successCallback} successCallback + * @param {errorCallback} errorCallback + */ + authenticateWithCordova(successCallback, errorCallback) { + const redirectUrl = 'https://www.dropbox.com/1/oauth2/redirect_receiver'; + this.getAuthenticationUrl(redirectUrl) + .then((url) => { + let removed = false; + const browser = globalThis.open(url, '_blank'); + + function onLoadError(event) { + // Workaround to fix wrong behavior on cordova-plugin-inappbrowser + if (event.code !== -999) { + // Try to avoid a browser crash on browser.close(). + globalThis.setTimeout(() => { browser.close(); }, 10); + errorCallback(); + } + } + + function onLoadStop(event) { + const errorLabel = '&error='; + const errorIndex = event.url.indexOf(errorLabel); + + if (errorIndex > -1) { + // Try to avoid a browser crash on browser.close(). + globalThis.setTimeout(() => { browser.close(); }, 10); + errorCallback(); + } else { + const tokenLabel = '#access_token='; + let tokenIndex = event.url.indexOf(tokenLabel); + const tokenTypeIndex = event.url.indexOf('&token_type='); + if (tokenIndex > -1) { + tokenIndex += tokenLabel.length; + // Try to avoid a browser crash on browser.close(). + globalThis.setTimeout(() => { browser.close(); }, 10); + + const accessToken = event.url.substring(tokenIndex, tokenTypeIndex); + successCallback(accessToken); + } + } + } + + function onExit() { + if (removed) { + return; + } + browser.removeEventListener('loaderror', onLoadError); + browser.removeEventListener('loadstop', onLoadStop); + browser.removeEventListener('exit', onExit); + removed = true; + } + + browser.addEventListener('loaderror', onLoadError); + browser.addEventListener('loadstop', onLoadStop); + browser.addEventListener('exit', onExit); + }); + } +} diff --git a/addon/lib/dropbox/constants.js b/addon/lib/dropbox/constants.js new file mode 100644 index 00000000..747ffc38 --- /dev/null +++ b/addon/lib/dropbox/constants.js @@ -0,0 +1,8 @@ +export const RPC = 'rpc'; +export const UPLOAD = 'upload'; +export const DOWNLOAD = 'download'; + +export const APP_AUTH = 'app'; +export const USER_AUTH = 'user'; +export const TEAM_AUTH = 'team'; +export const NO_AUTH = 'noauth'; diff --git a/addon/lib/dropbox/dropbox.js b/addon/lib/dropbox/dropbox.js new file mode 100644 index 00000000..49b6bafe --- /dev/null +++ b/addon/lib/dropbox/dropbox.js @@ -0,0 +1,194 @@ +import { + UPLOAD, + DOWNLOAD, + RPC, + APP_AUTH, + TEAM_AUTH, + USER_AUTH, + NO_AUTH, +} from './constants.js'; +import { routes } from './routes.js'; +import DropboxAuth from './auth.js'; +import { getBaseURL, httpHeaderSafeJson } from './utils.js'; +import { parseDownloadResponse, parseResponse } from './response.js'; + +let fetch; +if (typeof globalThis !== 'undefined') { + fetch = globalThis.fetch.bind(globalThis); +} else { + fetch = require('node-fetch'); // eslint-disable-line global-require +} + +const b64 = typeof btoa === 'undefined' + ? (str) => Buffer.from(str).toString('base64') + : btoa; + +/** + * @class Dropbox + * @classdesc The Dropbox SDK class that provides methods to read, write and + * create files or folders in a user or team's Dropbox. + * @arg {Object} options + * @arg {Function} [options.fetch] - fetch library for making requests. + * @arg {String} [options.selectUser] - Select user is only used for team functionality. + * It specifies which user the team access token should be acting as. + * @arg {String} [options.pathRoot] - root path to access other namespaces + * Use to access team folders for example + * @arg {String} [options.selectAdmin] - Select admin is only used by team functionality. + * It specifies which team admin the team access token should be acting as. + * @arg {DropboxAuth} [options.auth] - The DropboxAuth object used to authenticate requests. + * If this is set, the remaining parameters will be ignored. + * @arg {String} [options.accessToken] - An access token for making authenticated + * requests. + * @arg {Date} [options.accessTokenExpiresAt] - Date of the current access token's + * expiration (if available) + * @arg {String} [options.refreshToken] - A refresh token for retrieving access tokens + * @arg {String} [options.clientId] - The client id for your app. Used to create + * authentication URL. + * @arg {String} [options.clientSecret] - The client secret for your app. Used to create + * authentication URL and refresh access tokens. + */ +export default class Dropbox { + constructor(options) { + options = options || {}; + + if (options.auth) { + this.auth = options.auth; + } else { + this.auth = new DropboxAuth(options); + } + + this.fetch = options.fetch || fetch; + this.selectUser = options.selectUser; + this.selectAdmin = options.selectAdmin; + this.pathRoot = options.pathRoot; + + Object.assign(this, routes); + } + + request(path, args, auth, host, style) { + // checks for multiauth and assigns auth based on priority to create header in switch case + if (auth.split(',').length > 1) { + const authTypes = auth.replace(' ', '').split(','); + if (authTypes.includes(USER_AUTH) && this.auth.getAccessToken()) { + auth = USER_AUTH; + } else if (authTypes.includes(TEAM_AUTH) && this.auth.getAccessToken()) { + auth = TEAM_AUTH; + } else if (authTypes.includes(APP_AUTH)) { + auth = APP_AUTH; + } + } + + switch (style) { + case RPC: + return this.rpcRequest(path, args, auth, host); + case DOWNLOAD: + return this.downloadRequest(path, args, auth, host); + case UPLOAD: + return this.uploadRequest(path, args, auth, host); + default: + throw new Error(`Invalid request style: ${style}`); + } + } + + rpcRequest(path, body, auth, host) { + return this.auth.checkAndRefreshAccessToken() + .then(() => { + const fetchOptions = { + method: 'POST', + body: (body) ? JSON.stringify(body) : null, + headers: {}, + }; + + if (body) { + fetchOptions.headers['Content-Type'] = 'application/json'; + } + + let authHeader; + switch (auth) { + case APP_AUTH: + if (!this.auth.clientId || !this.auth.clientSecret) { + throw new Error('A client id and secret is required for this function'); + } + authHeader = b64(`${this.auth.clientId}:${this.auth.clientSecret}`); + fetchOptions.headers.Authorization = `Basic ${authHeader}`; + break; + case TEAM_AUTH: + case USER_AUTH: + fetchOptions.headers.Authorization = `Bearer ${this.auth.getAccessToken()}`; + break; + case NO_AUTH: + break; + default: + throw new Error(`Unhandled auth type: ${auth}`); + } + + this.setCommonHeaders(fetchOptions); + return fetchOptions; + }) + .then((fetchOptions) => this.fetch(getBaseURL(host) + path, fetchOptions)) + .then((res) => parseResponse(res)); + } + + downloadRequest(path, args, auth, host) { + return this.auth.checkAndRefreshAccessToken() + .then(() => { + if (auth !== USER_AUTH) { + throw new Error(`Unexpected auth type: ${auth}`); + } + + const fetchOptions = { + method: 'POST', + headers: { + Authorization: `Bearer ${this.auth.getAccessToken()}`, + 'Dropbox-API-Arg': httpHeaderSafeJson(args), + }, + }; + + this.setCommonHeaders(fetchOptions); + + return fetchOptions; + }) + .then((fetchOptions) => fetch(getBaseURL(host) + path, fetchOptions)) + .then((res) => parseDownloadResponse(res)); + } + + uploadRequest(path, args, auth, host) { + return this.auth.checkAndRefreshAccessToken() + .then(() => { + if (auth !== USER_AUTH) { + throw new Error(`Unexpected auth type: ${auth}`); + } + + const { contents } = args; + delete args.contents; + + const fetchOptions = { + body: contents, + method: 'POST', + headers: { + Authorization: `Bearer ${this.auth.getAccessToken()}`, + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': httpHeaderSafeJson(args), + }, + }; + + this.setCommonHeaders(fetchOptions); + + return fetchOptions; + }) + .then((fetchOptions) => this.fetch(getBaseURL(host) + path, fetchOptions)) + .then((res) => parseResponse(res)); + } + + setCommonHeaders(options) { + if (this.selectUser) { + options.headers['Dropbox-API-Select-User'] = this.selectUser; + } + if (this.selectAdmin) { + options.headers['Dropbox-API-Select-Admin'] = this.selectAdmin; + } + if (this.pathRoot) { + options.headers['Dropbox-API-Path-Root'] = this.pathRoot; + } + } +} diff --git a/addon/lib/dropbox/error.js b/addon/lib/dropbox/error.js new file mode 100644 index 00000000..d3e186d1 --- /dev/null +++ b/addon/lib/dropbox/error.js @@ -0,0 +1,17 @@ +/** + * The response class of HTTP errors from API calls using the Dropbox SDK. + * @class DropboxResponseError + * @classdesc The response class of HTTP errors from API calls using the Dropbox SDK. + * @arg {number} status - HTTP Status code of the call + * @arg {Object} headers - Headers returned from the call + * @arg {Object} error - Serialized Error of the call + */ +export class DropboxResponseError extends Error { + constructor(status, headers, error) { + super(`Response failed with a ${status} code`); + this.name = 'DropboxResponseError'; + this.status = status; + this.headers = headers; + this.error = error; + } +} diff --git a/addon/lib/dropbox/response.js b/addon/lib/dropbox/response.js new file mode 100644 index 00000000..2753bb7b --- /dev/null +++ b/addon/lib/dropbox/response.js @@ -0,0 +1,64 @@ +import { isWindowOrWorker } from './utils.js'; +import { DropboxResponseError } from './error.js'; + +export class DropboxResponse { + constructor(status, headers, result) { + this.status = status; + this.headers = headers; + this.result = result; + } +} + +function throwAsError(res) { + return res.text() + .then((data) => { + let errorObject; + try { + errorObject = JSON.parse(data); + } catch (error) { + errorObject = data; + } + + throw new DropboxResponseError(res.status, res.headers, errorObject); + }); +} + +export function parseResponse(res) { + if (!res.ok) { + return throwAsError(res); + } + return res.text() + .then((data) => { + let responseObject; + try { + responseObject = JSON.parse(data); + } catch (error) { + responseObject = data; + } + + return new DropboxResponse(res.status, res.headers, responseObject); + }); +} + +export function parseDownloadResponse(res) { + if (!res.ok) { + return throwAsError(res); + } + return new Promise((resolve) => { + if (isWindowOrWorker()) { + res.blob().then((data) => resolve(data)); + } else { + res.buffer().then((data) => resolve(data)); + } + }).then((data) => { + const result = JSON.parse(res.headers.get('dropbox-api-result')); + + if (isWindowOrWorker()) { + result.fileBlob = data; + } else { + result.fileBinary = data; + } + + return new DropboxResponse(res.status, res.headers, result); + }); +} diff --git a/addon/lib/dropbox/routes.js b/addon/lib/dropbox/routes.js new file mode 100644 index 00000000..9aec2b37 --- /dev/null +++ b/addon/lib/dropbox/routes.js @@ -0,0 +1,3030 @@ +// Auto-generated by Stone, do not modify. +var routes = {}; + +/** + * Sets a user's profile photo. + * @function Dropbox#accountSetProfilePhoto + * @arg {AccountSetProfilePhotoArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.accountSetProfilePhoto = function (arg) { + return this.request('account/set_profile_photo', arg, 'user', 'api', 'rpc'); +}; + +/** + * Creates an OAuth 2.0 access token from the supplied OAuth 1.0 access token. + * @function Dropbox#authTokenFromOauth1 + * @arg {AuthTokenFromOAuth1Arg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.authTokenFromOauth1 = function (arg) { + return this.request('auth/token/from_oauth1', arg, 'app', 'api', 'rpc'); +}; + +/** + * Disables the access token used to authenticate the call. If there is a + * corresponding refresh token for the access token, this disables that refresh + * token, as well as any other access tokens for that refresh token. + * @function Dropbox#authTokenRevoke + * @returns {Promise., Error.>} + */ +routes.authTokenRevoke = function () { + return this.request('auth/token/revoke', null, 'user', 'api', 'rpc'); +}; + +/** + * This endpoint performs App Authentication, validating the supplied app key + * and secret, and returns the supplied string, to allow you to test your code + * and connection to the Dropbox API. It has no other effect. If you receive an + * HTTP 200 response with the supplied query, it indicates at least part of the + * Dropbox API infrastructure is working and that the app key and secret valid. + * @function Dropbox#checkApp + * @arg {CheckEchoArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.checkApp = function (arg) { + return this.request('check/app', arg, 'app', 'api', 'rpc'); +}; + +/** + * This endpoint performs User Authentication, validating the supplied access + * token, and returns the supplied string, to allow you to test your code and + * connection to the Dropbox API. It has no other effect. If you receive an HTTP + * 200 response with the supplied query, it indicates at least part of the + * Dropbox API infrastructure is working and that the access token is valid. + * @function Dropbox#checkUser + * @arg {CheckEchoArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.checkUser = function (arg) { + return this.request('check/user', arg, 'user', 'api', 'rpc'); +}; + +/** + * Removes all manually added contacts. You'll still keep contacts who are on + * your team or who you imported. New contacts will be added when you share. + * @function Dropbox#contactsDeleteManualContacts + * @returns {Promise., Error.>} + */ +routes.contactsDeleteManualContacts = function () { + return this.request('contacts/delete_manual_contacts', null, 'user', 'api', 'rpc'); +}; + +/** + * Removes manually added contacts from the given list. + * @function Dropbox#contactsDeleteManualContactsBatch + * @arg {ContactsDeleteManualContactsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.contactsDeleteManualContactsBatch = function (arg) { + return this.request('contacts/delete_manual_contacts_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Add property groups to a Dropbox file. See templates/add_for_user or + * templates/add_for_team to create new templates. + * @function Dropbox#filePropertiesPropertiesAdd + * @arg {FilePropertiesAddPropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesAdd = function (arg) { + return this.request('file_properties/properties/add', arg, 'user', 'api', 'rpc'); +}; + +/** + * Overwrite property groups associated with a file. This endpoint should be + * used instead of properties/update when property groups are being updated via + * a "snapshot" instead of via a "delta". In other words, this endpoint will + * delete all omitted fields from a property group, whereas properties/update + * will only delete fields that are explicitly marked for deletion. + * @function Dropbox#filePropertiesPropertiesOverwrite + * @arg {FilePropertiesOverwritePropertyGroupArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesOverwrite = function (arg) { + return this.request('file_properties/properties/overwrite', arg, 'user', 'api', 'rpc'); +}; + +/** + * Permanently removes the specified property group from the file. To remove + * specific property field key value pairs, see properties/update. To update a + * template, see templates/update_for_user or templates/update_for_team. To + * remove a template, see templates/remove_for_user or + * templates/remove_for_team. + * @function Dropbox#filePropertiesPropertiesRemove + * @arg {FilePropertiesRemovePropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesRemove = function (arg) { + return this.request('file_properties/properties/remove', arg, 'user', 'api', 'rpc'); +}; + +/** + * Search across property templates for particular property field values. + * @function Dropbox#filePropertiesPropertiesSearch + * @arg {FilePropertiesPropertiesSearchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesSearch = function (arg) { + return this.request('file_properties/properties/search', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from properties/search, use this to paginate + * through all search results. + * @function Dropbox#filePropertiesPropertiesSearchContinue + * @arg {FilePropertiesPropertiesSearchContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesSearchContinue = function (arg) { + return this.request('file_properties/properties/search/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Add, update or remove properties associated with the supplied file and + * templates. This endpoint should be used instead of properties/overwrite when + * property groups are being updated via a "delta" instead of via a "snapshot" . + * In other words, this endpoint will not delete any omitted fields from a + * property group, whereas properties/overwrite will delete any fields that are + * omitted from a property group. + * @function Dropbox#filePropertiesPropertiesUpdate + * @arg {FilePropertiesUpdatePropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesPropertiesUpdate = function (arg) { + return this.request('file_properties/properties/update', arg, 'user', 'api', 'rpc'); +}; + +/** + * Add a template associated with a team. See properties/add to add properties + * to a file or folder. Note: this endpoint will create team-owned templates. + * @function Dropbox#filePropertiesTemplatesAddForTeam + * @arg {FilePropertiesAddTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesAddForTeam = function (arg) { + return this.request('file_properties/templates/add_for_team', arg, 'team', 'api', 'rpc'); +}; + +/** + * Add a template associated with a user. See properties/add to add properties + * to a file. This endpoint can't be called on a team member or admin's behalf. + * @function Dropbox#filePropertiesTemplatesAddForUser + * @arg {FilePropertiesAddTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesAddForUser = function (arg) { + return this.request('file_properties/templates/add_for_user', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get the schema for a specified template. + * @function Dropbox#filePropertiesTemplatesGetForTeam + * @arg {FilePropertiesGetTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesGetForTeam = function (arg) { + return this.request('file_properties/templates/get_for_team', arg, 'team', 'api', 'rpc'); +}; + +/** + * Get the schema for a specified template. This endpoint can't be called on a + * team member or admin's behalf. + * @function Dropbox#filePropertiesTemplatesGetForUser + * @arg {FilePropertiesGetTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesGetForUser = function (arg) { + return this.request('file_properties/templates/get_for_user', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get the template identifiers for a team. To get the schema of each template + * use templates/get_for_team. + * @function Dropbox#filePropertiesTemplatesListForTeam + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesListForTeam = function () { + return this.request('file_properties/templates/list_for_team', null, 'team', 'api', 'rpc'); +}; + +/** + * Get the template identifiers for a team. To get the schema of each template + * use templates/get_for_user. This endpoint can't be called on a team member or + * admin's behalf. + * @function Dropbox#filePropertiesTemplatesListForUser + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesListForUser = function () { + return this.request('file_properties/templates/list_for_user', null, 'user', 'api', 'rpc'); +}; + +/** + * Permanently removes the specified template created from + * templates/add_for_user. All properties associated with the template will also + * be removed. This action cannot be undone. + * @function Dropbox#filePropertiesTemplatesRemoveForTeam + * @arg {FilePropertiesRemoveTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesRemoveForTeam = function (arg) { + return this.request('file_properties/templates/remove_for_team', arg, 'team', 'api', 'rpc'); +}; + +/** + * Permanently removes the specified template created from + * templates/add_for_user. All properties associated with the template will also + * be removed. This action cannot be undone. + * @function Dropbox#filePropertiesTemplatesRemoveForUser + * @arg {FilePropertiesRemoveTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesRemoveForUser = function (arg) { + return this.request('file_properties/templates/remove_for_user', arg, 'user', 'api', 'rpc'); +}; + +/** + * Update a template associated with a team. This route can update the template + * name, the template description and add optional properties to templates. + * @function Dropbox#filePropertiesTemplatesUpdateForTeam + * @arg {FilePropertiesUpdateTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesUpdateForTeam = function (arg) { + return this.request('file_properties/templates/update_for_team', arg, 'team', 'api', 'rpc'); +}; + +/** + * Update a template associated with a user. This route can update the template + * name, the template description and add optional properties to templates. This + * endpoint can't be called on a team member or admin's behalf. + * @function Dropbox#filePropertiesTemplatesUpdateForUser + * @arg {FilePropertiesUpdateTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filePropertiesTemplatesUpdateForUser = function (arg) { + return this.request('file_properties/templates/update_for_user', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the total number of file requests owned by this user. Includes both + * open and closed file requests. + * @function Dropbox#fileRequestsCount + * @returns {Promise., Error.>} + */ +routes.fileRequestsCount = function () { + return this.request('file_requests/count', null, 'user', 'api', 'rpc'); +}; + +/** + * Creates a file request for this user. + * @function Dropbox#fileRequestsCreate + * @arg {FileRequestsCreateFileRequestArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsCreate = function (arg) { + return this.request('file_requests/create', arg, 'user', 'api', 'rpc'); +}; + +/** + * Delete a batch of closed file requests. + * @function Dropbox#fileRequestsDelete + * @arg {FileRequestsDeleteFileRequestArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsDelete = function (arg) { + return this.request('file_requests/delete', arg, 'user', 'api', 'rpc'); +}; + +/** + * Delete all closed file requests owned by this user. + * @function Dropbox#fileRequestsDeleteAllClosed + * @returns {Promise., Error.>} + */ +routes.fileRequestsDeleteAllClosed = function () { + return this.request('file_requests/delete_all_closed', null, 'user', 'api', 'rpc'); +}; + +/** + * Returns the specified file request. + * @function Dropbox#fileRequestsGet + * @arg {FileRequestsGetFileRequestArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsGet = function (arg) { + return this.request('file_requests/get', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns a list of file requests owned by this user. For apps with the app + * folder permission, this will only return file requests with destinations in + * the app folder. + * @function Dropbox#fileRequestsListV2 + * @arg {FileRequestsListFileRequestsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsListV2 = function (arg) { + return this.request('file_requests/list_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns a list of file requests owned by this user. For apps with the app + * folder permission, this will only return file requests with destinations in + * the app folder. + * @function Dropbox#fileRequestsList + * @returns {Promise., Error.>} + */ +routes.fileRequestsList = function () { + return this.request('file_requests/list', null, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_v2, use this to paginate through + * all file requests. The cursor must come from a previous call to list_v2 or + * list/continue. + * @function Dropbox#fileRequestsListContinue + * @arg {FileRequestsListFileRequestsContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsListContinue = function (arg) { + return this.request('file_requests/list/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Update a file request. + * @function Dropbox#fileRequestsUpdate + * @arg {FileRequestsUpdateFileRequestArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.fileRequestsUpdate = function (arg) { + return this.request('file_requests/update', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the metadata for a file or folder. This is an alpha endpoint + * compatible with the properties API. Note: Metadata for the root folder is + * unsupported. + * @function Dropbox#filesAlphaGetMetadata + * @deprecated + * @arg {FilesAlphaGetMetadataArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesAlphaGetMetadata = function (arg) { + return this.request('files/alpha/get_metadata', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a new file with the contents provided in the request. Note that this + * endpoint is part of the properties API alpha and is slightly different from + * upload. Do not use this to upload a file larger than 150 MB. Instead, create + * an upload session with upload_session/start. + * @function Dropbox#filesAlphaUpload + * @deprecated + * @arg {FilesCommitInfoWithProperties} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesAlphaUpload = function (arg) { + return this.request('files/alpha/upload', arg, 'user', 'content', 'upload'); +}; + +/** + * Copy a file or folder to a different location in the user's Dropbox. If the + * source path is a folder all its contents will be copied. + * @function Dropbox#filesCopyV2 + * @arg {FilesRelocationArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyV2 = function (arg) { + return this.request('files/copy_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Copy a file or folder to a different location in the user's Dropbox. If the + * source path is a folder all its contents will be copied. + * @function Dropbox#filesCopy + * @deprecated + * @arg {FilesRelocationArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopy = function (arg) { + return this.request('files/copy', arg, 'user', 'api', 'rpc'); +}; + +/** + * Copy multiple files or folders to different locations at once in the user's + * Dropbox. This route will replace copy_batch. The main difference is this + * route will return status for each entry, while copy_batch raises failure if + * any entry fails. This route will either finish synchronously, or return a job + * ID and do the async copy job in background. Please use copy_batch/check_v2 to + * check the job status. + * @function Dropbox#filesCopyBatchV2 + * @arg {Object} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyBatchV2 = function (arg) { + return this.request('files/copy_batch_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Copy multiple files or folders to different locations at once in the user's + * Dropbox. This route will return job ID immediately and do the async copy job + * in background. Please use copy_batch/check to check the job status. + * @function Dropbox#filesCopyBatch + * @deprecated + * @arg {FilesRelocationBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyBatch = function (arg) { + return this.request('files/copy_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for copy_batch_v2. It returns list + * of results for each entry. + * @function Dropbox#filesCopyBatchCheckV2 + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyBatchCheckV2 = function (arg) { + return this.request('files/copy_batch/check_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for copy_batch. If success, it + * returns list of results for each entry. + * @function Dropbox#filesCopyBatchCheck + * @deprecated + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyBatchCheck = function (arg) { + return this.request('files/copy_batch/check', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get a copy reference to a file or folder. This reference string can be used + * to save that file or folder to another user's Dropbox by passing it to + * copy_reference/save. + * @function Dropbox#filesCopyReferenceGet + * @arg {FilesGetCopyReferenceArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyReferenceGet = function (arg) { + return this.request('files/copy_reference/get', arg, 'user', 'api', 'rpc'); +}; + +/** + * Save a copy reference returned by copy_reference/get to the user's Dropbox. + * @function Dropbox#filesCopyReferenceSave + * @arg {FilesSaveCopyReferenceArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCopyReferenceSave = function (arg) { + return this.request('files/copy_reference/save', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a folder at a given path. + * @function Dropbox#filesCreateFolderV2 + * @arg {FilesCreateFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCreateFolderV2 = function (arg) { + return this.request('files/create_folder_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a folder at a given path. + * @function Dropbox#filesCreateFolder + * @deprecated + * @arg {FilesCreateFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCreateFolder = function (arg) { + return this.request('files/create_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create multiple folders at once. This route is asynchronous for large + * batches, which returns a job ID immediately and runs the create folder batch + * asynchronously. Otherwise, creates the folders and returns the result + * synchronously for smaller inputs. You can force asynchronous behaviour by + * using the CreateFolderBatchArg.force_async flag. Use + * create_folder_batch/check to check the job status. + * @function Dropbox#filesCreateFolderBatch + * @arg {FilesCreateFolderBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCreateFolderBatch = function (arg) { + return this.request('files/create_folder_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for create_folder_batch. If + * success, it returns list of result for each entry. + * @function Dropbox#filesCreateFolderBatchCheck + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesCreateFolderBatchCheck = function (arg) { + return this.request('files/create_folder_batch/check', arg, 'user', 'api', 'rpc'); +}; + +/** + * Delete the file or folder at a given path. If the path is a folder, all its + * contents will be deleted too. A successful response indicates that the file + * or folder was deleted. The returned metadata will be the corresponding + * FileMetadata or FolderMetadata for the item at time of deletion, and not a + * DeletedMetadata object. + * @function Dropbox#filesDeleteV2 + * @arg {FilesDeleteArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDeleteV2 = function (arg) { + return this.request('files/delete_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Delete the file or folder at a given path. If the path is a folder, all its + * contents will be deleted too. A successful response indicates that the file + * or folder was deleted. The returned metadata will be the corresponding + * FileMetadata or FolderMetadata for the item at time of deletion, and not a + * DeletedMetadata object. + * @function Dropbox#filesDelete + * @deprecated + * @arg {FilesDeleteArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDelete = function (arg) { + return this.request('files/delete', arg, 'user', 'api', 'rpc'); +}; + +/** + * Delete multiple files/folders at once. This route is asynchronous, which + * returns a job ID immediately and runs the delete batch asynchronously. Use + * delete_batch/check to check the job status. + * @function Dropbox#filesDeleteBatch + * @arg {FilesDeleteBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDeleteBatch = function (arg) { + return this.request('files/delete_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for delete_batch. If success, it + * returns list of result for each entry. + * @function Dropbox#filesDeleteBatchCheck + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDeleteBatchCheck = function (arg) { + return this.request('files/delete_batch/check', arg, 'user', 'api', 'rpc'); +}; + +/** + * Download a file from a user's Dropbox. + * @function Dropbox#filesDownload + * @arg {FilesDownloadArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDownload = function (arg) { + return this.request('files/download', arg, 'user', 'content', 'download'); +}; + +/** + * Download a folder from the user's Dropbox, as a zip file. The folder must be + * less than 20 GB in size and any single file within must be less than 4 GB in + * size. The resulting zip must have fewer than 10,000 total file and folder + * entries, including the top level folder. The input cannot be a single file. + * @function Dropbox#filesDownloadZip + * @arg {FilesDownloadZipArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesDownloadZip = function (arg) { + return this.request('files/download_zip', arg, 'user', 'content', 'download'); +}; + +/** + * Export a file from a user's Dropbox. This route only supports exporting files + * that cannot be downloaded directly and whose ExportResult.file_metadata has + * ExportInfo.export_as populated. + * @function Dropbox#filesExport + * @arg {FilesExportArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesExport = function (arg) { + return this.request('files/export', arg, 'user', 'content', 'download'); +}; + +/** + * Return the lock metadata for the given list of paths. + * @function Dropbox#filesGetFileLockBatch + * @arg {FilesLockFileBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetFileLockBatch = function (arg) { + return this.request('files/get_file_lock_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the metadata for a file or folder. Note: Metadata for the root folder + * is unsupported. + * @function Dropbox#filesGetMetadata + * @arg {FilesGetMetadataArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetMetadata = function (arg) { + return this.request('files/get_metadata', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get a preview for a file. Currently, PDF previews are generated for files + * with the following extensions: .ai, .doc, .docm, .docx, .eps, .gdoc, + * .gslides, .odp, .odt, .pps, .ppsm, .ppsx, .ppt, .pptm, .pptx, .rtf. HTML + * previews are generated for files with the following extensions: .csv, .ods, + * .xls, .xlsm, .gsheet, .xlsx. Other formats will return an unsupported + * extension error. + * @function Dropbox#filesGetPreview + * @arg {FilesPreviewArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetPreview = function (arg) { + return this.request('files/get_preview', arg, 'user', 'content', 'download'); +}; + +/** + * Get a temporary link to stream content of a file. This link will expire in + * four hours and afterwards you will get 410 Gone. This URL should not be used + * to display content directly in the browser. The Content-Type of the link is + * determined automatically by the file's mime type. + * @function Dropbox#filesGetTemporaryLink + * @arg {FilesGetTemporaryLinkArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetTemporaryLink = function (arg) { + return this.request('files/get_temporary_link', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get a one-time use temporary upload link to upload a file to a Dropbox + * location. This endpoint acts as a delayed upload. The returned temporary + * upload link may be used to make a POST request with the data to be uploaded. + * The upload will then be perfomed with the CommitInfo previously provided to + * get_temporary_upload_link but evaluated only upon consumption. Hence, errors + * stemming from invalid CommitInfo with respect to the state of the user's + * Dropbox will only be communicated at consumption time. Additionally, these + * errors are surfaced as generic HTTP 409 Conflict responses, potentially + * hiding issue details. The maximum temporary upload link duration is 4 hours. + * Upon consumption or expiration, a new link will have to be generated. + * Multiple links may exist for a specific upload path at any given time. The + * POST request on the temporary upload link must have its Content-Type set to + * "application/octet-stream". Example temporary upload link consumption + * request: curl -X POST + * https://content.dropboxapi.com/apitul/1/bNi2uIYF51cVBND --header + * "Content-Type: application/octet-stream" --data-binary @local_file.txt A + * successful temporary upload link consumption request returns the content hash + * of the uploaded data in JSON format. Example succesful temporary upload link + * consumption response: {"content-hash": + * "599d71033d700ac892a0e48fa61b125d2f5994"} An unsuccessful temporary upload + * link consumption request returns any of the following status codes: HTTP 400 + * Bad Request: Content-Type is not one of application/octet-stream and + * text/plain or request is invalid. HTTP 409 Conflict: The temporary upload + * link does not exist or is currently unavailable, the upload failed, or + * another error happened. HTTP 410 Gone: The temporary upload link is expired + * or consumed. Example unsuccessful temporary upload link consumption + * response: Temporary upload link has been recently consumed. + * @function Dropbox#filesGetTemporaryUploadLink + * @arg {FilesGetTemporaryUploadLinkArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetTemporaryUploadLink = function (arg) { + return this.request('files/get_temporary_upload_link', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get a thumbnail for an image. This method currently supports files with the + * following file extensions: jpg, jpeg, png, tiff, tif, gif, webp, ppm and bmp. + * Photos that are larger than 20MB in size won't be converted to a thumbnail. + * @function Dropbox#filesGetThumbnail + * @arg {FilesThumbnailArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetThumbnail = function (arg) { + return this.request('files/get_thumbnail', arg, 'user', 'content', 'download'); +}; + +/** + * Get a thumbnail for an image. This method currently supports files with the + * following file extensions: jpg, jpeg, png, tiff, tif, gif, webp, ppm and bmp. + * Photos that are larger than 20MB in size won't be converted to a thumbnail. + * @function Dropbox#filesGetThumbnailV2 + * @arg {FilesThumbnailV2Arg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetThumbnailV2 = function (arg) { + return this.request('files/get_thumbnail_v2', arg, 'app, user', 'content', 'download'); +}; + +/** + * Get thumbnails for a list of images. We allow up to 25 thumbnails in a single + * batch. This method currently supports files with the following file + * extensions: jpg, jpeg, png, tiff, tif, gif, webp, ppm and bmp. Photos that + * are larger than 20MB in size won't be converted to a thumbnail. + * @function Dropbox#filesGetThumbnailBatch + * @arg {FilesGetThumbnailBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesGetThumbnailBatch = function (arg) { + return this.request('files/get_thumbnail_batch', arg, 'user', 'content', 'rpc'); +}; + +/** + * Starts returning the contents of a folder. If the result's + * ListFolderResult.has_more field is true, call list_folder/continue with the + * returned ListFolderResult.cursor to retrieve more entries. If you're using + * ListFolderArg.recursive set to true to keep a local cache of the contents of + * a Dropbox account, iterate through each entry in order and process them as + * follows to keep your local state in sync: For each FileMetadata, store the + * new entry at the given path in your local state. If the required parent + * folders don't exist yet, create them. If there's already something else at + * the given path, replace it and remove all its children. For each + * FolderMetadata, store the new entry at the given path in your local state. If + * the required parent folders don't exist yet, create them. If there's already + * something else at the given path, replace it but leave the children as they + * are. Check the new entry's FolderSharingInfo.read_only and set all its + * children's read-only statuses to match. For each DeletedMetadata, if your + * local state has something at the given path, remove it and all its children. + * If there's nothing at the given path, ignore this entry. Note: + * auth.RateLimitError may be returned if multiple list_folder or + * list_folder/continue calls with same parameters are made simultaneously by + * same API app for same user. If your app implements retry logic, please hold + * off the retry until the previous request finishes. + * @function Dropbox#filesListFolder + * @arg {FilesListFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesListFolder = function (arg) { + return this.request('files/list_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_folder, use this to paginate + * through all files and retrieve updates to the folder, following the same + * rules as documented for list_folder. + * @function Dropbox#filesListFolderContinue + * @arg {FilesListFolderContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesListFolderContinue = function (arg) { + return this.request('files/list_folder/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * A way to quickly get a cursor for the folder's state. Unlike list_folder, + * list_folder/get_latest_cursor doesn't return any entries. This endpoint is + * for app which only needs to know about new files and modifications and + * doesn't need to know about files that already exist in Dropbox. + * @function Dropbox#filesListFolderGetLatestCursor + * @arg {FilesListFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesListFolderGetLatestCursor = function (arg) { + return this.request('files/list_folder/get_latest_cursor', arg, 'user', 'api', 'rpc'); +}; + +/** + * A longpoll endpoint to wait for changes on an account. In conjunction with + * list_folder/continue, this call gives you a low-latency way to monitor an + * account for file changes. The connection will block until there are changes + * available or a timeout occurs. This endpoint is useful mostly for client-side + * apps. If you're looking for server-side notifications, check out our webhooks + * documentation https://www.dropbox.com/developers/reference/webhooks. + * @function Dropbox#filesListFolderLongpoll + * @arg {FilesListFolderLongpollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesListFolderLongpoll = function (arg) { + return this.request('files/list_folder/longpoll', arg, 'noauth', 'notify', 'rpc'); +}; + +/** + * Returns revisions for files based on a file path or a file id. The file path + * or file id is identified from the latest file entry at the given file path or + * id. This end point allows your app to query either by file path or file id by + * setting the mode parameter appropriately. In the ListRevisionsMode.path + * (default) mode, all revisions at the same file path as the latest file entry + * are returned. If revisions with the same file id are desired, then mode must + * be set to ListRevisionsMode.id. The ListRevisionsMode.id mode is useful to + * retrieve revisions for a given file across moves or renames. + * @function Dropbox#filesListRevisions + * @arg {FilesListRevisionsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesListRevisions = function (arg) { + return this.request('files/list_revisions', arg, 'user', 'api', 'rpc'); +}; + +/** + * Lock the files at the given paths. A locked file will be writable only by the + * lock holder. A successful response indicates that the file has been locked. + * Returns a list of the locked file paths and their metadata after this + * operation. + * @function Dropbox#filesLockFileBatch + * @arg {FilesLockFileBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesLockFileBatch = function (arg) { + return this.request('files/lock_file_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Move a file or folder to a different location in the user's Dropbox. If the + * source path is a folder all its contents will be moved. Note that we do not + * currently support case-only renaming. + * @function Dropbox#filesMoveV2 + * @arg {FilesRelocationArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMoveV2 = function (arg) { + return this.request('files/move_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Move a file or folder to a different location in the user's Dropbox. If the + * source path is a folder all its contents will be moved. + * @function Dropbox#filesMove + * @deprecated + * @arg {FilesRelocationArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMove = function (arg) { + return this.request('files/move', arg, 'user', 'api', 'rpc'); +}; + +/** + * Move multiple files or folders to different locations at once in the user's + * Dropbox. Note that we do not currently support case-only renaming. This route + * will replace move_batch. The main difference is this route will return status + * for each entry, while move_batch raises failure if any entry fails. This + * route will either finish synchronously, or return a job ID and do the async + * move job in background. Please use move_batch/check_v2 to check the job + * status. + * @function Dropbox#filesMoveBatchV2 + * @arg {FilesMoveBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMoveBatchV2 = function (arg) { + return this.request('files/move_batch_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Move multiple files or folders to different locations at once in the user's + * Dropbox. This route will return job ID immediately and do the async moving + * job in background. Please use move_batch/check to check the job status. + * @function Dropbox#filesMoveBatch + * @deprecated + * @arg {FilesRelocationBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMoveBatch = function (arg) { + return this.request('files/move_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for move_batch_v2. It returns list + * of results for each entry. + * @function Dropbox#filesMoveBatchCheckV2 + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMoveBatchCheckV2 = function (arg) { + return this.request('files/move_batch/check_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for move_batch. If success, it + * returns list of results for each entry. + * @function Dropbox#filesMoveBatchCheck + * @deprecated + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesMoveBatchCheck = function (arg) { + return this.request('files/move_batch/check', arg, 'user', 'api', 'rpc'); +}; + +/** + * Creates a new Paper doc with the provided content. + * @function Dropbox#filesPaperCreate + * @arg {FilesPaperCreateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPaperCreate = function (arg) { + return this.request('files/paper/create', arg, 'user', 'api', 'upload'); +}; + +/** + * Updates an existing Paper doc with the provided content. + * @function Dropbox#filesPaperUpdate + * @arg {FilesPaperUpdateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPaperUpdate = function (arg) { + return this.request('files/paper/update', arg, 'user', 'api', 'upload'); +}; + +/** + * Permanently delete the file or folder at a given path (see + * https://www.dropbox.com/en/help/40). If the given file or folder is not yet + * deleted, this route will first delete it. It is possible for this route to + * successfully delete, then fail to permanently delete. Note: This endpoint is + * only available for Dropbox Business apps. + * @function Dropbox#filesPermanentlyDelete + * @arg {FilesDeleteArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPermanentlyDelete = function (arg) { + return this.request('files/permanently_delete', arg, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesAdd + * @deprecated + * @arg {FilePropertiesAddPropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPropertiesAdd = function (arg) { + return this.request('files/properties/add', arg, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesOverwrite + * @deprecated + * @arg {FilePropertiesOverwritePropertyGroupArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPropertiesOverwrite = function (arg) { + return this.request('files/properties/overwrite', arg, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesRemove + * @deprecated + * @arg {FilePropertiesRemovePropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPropertiesRemove = function (arg) { + return this.request('files/properties/remove', arg, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesTemplateGet + * @deprecated + * @arg {FilePropertiesGetTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPropertiesTemplateGet = function (arg) { + return this.request('files/properties/template/get', arg, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesTemplateList + * @deprecated + * @returns {Promise., Error.>} + */ +routes.filesPropertiesTemplateList = function () { + return this.request('files/properties/template/list', null, 'user', 'api', 'rpc'); +}; + +/** + * @function Dropbox#filesPropertiesUpdate + * @deprecated + * @arg {FilePropertiesUpdatePropertiesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesPropertiesUpdate = function (arg) { + return this.request('files/properties/update', arg, 'user', 'api', 'rpc'); +}; + +/** + * Restore a specific revision of a file to the given path. + * @function Dropbox#filesRestore + * @arg {FilesRestoreArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesRestore = function (arg) { + return this.request('files/restore', arg, 'user', 'api', 'rpc'); +}; + +/** + * Save the data from a specified URL into a file in user's Dropbox. Note that + * the transfer from the URL must complete within 5 minutes, or the operation + * will time out and the job will fail. If the given path already exists, the + * file will be renamed to avoid the conflict (e.g. myfile (1).txt). + * @function Dropbox#filesSaveUrl + * @arg {FilesSaveUrlArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesSaveUrl = function (arg) { + return this.request('files/save_url', arg, 'user', 'api', 'rpc'); +}; + +/** + * Check the status of a save_url job. + * @function Dropbox#filesSaveUrlCheckJobStatus + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesSaveUrlCheckJobStatus = function (arg) { + return this.request('files/save_url/check_job_status', arg, 'user', 'api', 'rpc'); +}; + +/** + * Searches for files and folders. Note: Recent changes will be reflected in + * search results within a few seconds and older revisions of existing files may + * still match your query for up to a few days. + * @function Dropbox#filesSearch + * @deprecated + * @arg {FilesSearchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesSearch = function (arg) { + return this.request('files/search', arg, 'user', 'api', 'rpc'); +}; + +/** + * Searches for files and folders. Note: search_v2 along with search/continue_v2 + * can only be used to retrieve a maximum of 10,000 matches. Recent changes may + * not immediately be reflected in search results due to a short delay in + * indexing. Duplicate results may be returned across pages. Some results may + * not be returned. + * @function Dropbox#filesSearchV2 + * @arg {FilesSearchV2Arg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesSearchV2 = function (arg) { + return this.request('files/search_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Fetches the next page of search results returned from search_v2. Note: + * search_v2 along with search/continue_v2 can only be used to retrieve a + * maximum of 10,000 matches. Recent changes may not immediately be reflected in + * search results due to a short delay in indexing. Duplicate results may be + * returned across pages. Some results may not be returned. + * @function Dropbox#filesSearchContinueV2 + * @arg {FilesSearchV2ContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesSearchContinueV2 = function (arg) { + return this.request('files/search/continue_v2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Unlock the files at the given paths. A locked file can only be unlocked by + * the lock holder or, if a business account, a team admin. A successful + * response indicates that the file has been unlocked. Returns a list of the + * unlocked file paths and their metadata after this operation. + * @function Dropbox#filesUnlockFileBatch + * @arg {FilesUnlockFileBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUnlockFileBatch = function (arg) { + return this.request('files/unlock_file_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a new file with the contents provided in the request. Do not use this + * to upload a file larger than 150 MB. Instead, create an upload session with + * upload_session/start. Calls to this endpoint will count as data transport + * calls for any Dropbox Business teams with a limit on the number of data + * transport calls allowed per month. For more information, see the Data + * transport limit page + * https://www.dropbox.com/developers/reference/data-transport-limit. + * @function Dropbox#filesUpload + * @arg {FilesCommitInfo} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUpload = function (arg) { + return this.request('files/upload', arg, 'user', 'content', 'upload'); +}; + +/** + * Append more data to an upload session. When the parameter close is set, this + * call will close the session. A single request should not upload more than 150 + * MB. The maximum size of a file one can upload to an upload session is 350 GB. + * Calls to this endpoint will count as data transport calls for any Dropbox + * Business teams with a limit on the number of data transport calls allowed per + * month. For more information, see the Data transport limit page + * https://www.dropbox.com/developers/reference/data-transport-limit. + * @function Dropbox#filesUploadSessionAppendV2 + * @arg {FilesUploadSessionAppendArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionAppendV2 = function (arg) { + return this.request('files/upload_session/append_v2', arg, 'user', 'content', 'upload'); +}; + +/** + * Append more data to an upload session. A single request should not upload + * more than 150 MB. The maximum size of a file one can upload to an upload + * session is 350 GB. Calls to this endpoint will count as data transport calls + * for any Dropbox Business teams with a limit on the number of data transport + * calls allowed per month. For more information, see the Data transport limit + * page https://www.dropbox.com/developers/reference/data-transport-limit. + * @function Dropbox#filesUploadSessionAppend + * @deprecated + * @arg {FilesUploadSessionCursor} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionAppend = function (arg) { + return this.request('files/upload_session/append', arg, 'user', 'content', 'upload'); +}; + +/** + * Finish an upload session and save the uploaded data to the given file path. A + * single request should not upload more than 150 MB. The maximum size of a file + * one can upload to an upload session is 350 GB. Calls to this endpoint will + * count as data transport calls for any Dropbox Business teams with a limit on + * the number of data transport calls allowed per month. For more information, + * see the Data transport limit page + * https://www.dropbox.com/developers/reference/data-transport-limit. + * @function Dropbox#filesUploadSessionFinish + * @arg {FilesUploadSessionFinishArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionFinish = function (arg) { + return this.request('files/upload_session/finish', arg, 'user', 'content', 'upload'); +}; + +/** + * This route helps you commit many files at once into a user's Dropbox. Use + * upload_session/start and upload_session/append_v2 to upload file contents. We + * recommend uploading many files in parallel to increase throughput. Once the + * file contents have been uploaded, rather than calling upload_session/finish, + * use this route to finish all your upload sessions in a single request. + * UploadSessionStartArg.close or UploadSessionAppendArg.close needs to be true + * for the last upload_session/start or upload_session/append_v2 call. The + * maximum size of a file one can upload to an upload session is 350 GB. This + * route will return a job_id immediately and do the async commit job in + * background. Use upload_session/finish_batch/check to check the job status. + * For the same account, this route should be executed serially. That means you + * should not start the next job before current job finishes. We allow up to + * 1000 entries in a single request. Calls to this endpoint will count as data + * transport calls for any Dropbox Business teams with a limit on the number of + * data transport calls allowed per month. For more information, see the Data + * transport limit page + * https://www.dropbox.com/developers/reference/data-transport-limit. + * @function Dropbox#filesUploadSessionFinishBatch + * @arg {FilesUploadSessionFinishBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionFinishBatch = function (arg) { + return this.request('files/upload_session/finish_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for upload_session/finish_batch. If + * success, it returns list of result for each entry. + * @function Dropbox#filesUploadSessionFinishBatchCheck + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionFinishBatchCheck = function (arg) { + return this.request('files/upload_session/finish_batch/check', arg, 'user', 'api', 'rpc'); +}; + +/** + * Upload sessions allow you to upload a single file in one or more requests, + * for example where the size of the file is greater than 150 MB. This call + * starts a new upload session with the given data. You can then use + * upload_session/append_v2 to add more data and upload_session/finish to save + * all the data to a file in Dropbox. A single request should not upload more + * than 150 MB. The maximum size of a file one can upload to an upload session + * is 350 GB. An upload session can be used for a maximum of 7 days. Attempting + * to use an UploadSessionStartResult.session_id with upload_session/append_v2 + * or upload_session/finish more than 7 days after its creation will return a + * UploadSessionLookupError.not_found. Calls to this endpoint will count as data + * transport calls for any Dropbox Business teams with a limit on the number of + * data transport calls allowed per month. For more information, see the Data + * transport limit page + * https://www.dropbox.com/developers/reference/data-transport-limit. By + * default, upload sessions require you to send content of the file in + * sequential order via consecutive upload_session/start, + * upload_session/append_v2, upload_session/finish calls. For better + * performance, you can instead optionally use a UploadSessionType.concurrent + * upload session. To start a new concurrent session, set + * UploadSessionStartArg.session_type to UploadSessionType.concurrent. After + * that, you can send file data in concurrent upload_session/append_v2 requests. + * Finally finish the session with upload_session/finish. There are couple of + * constraints with concurrent sessions to make them work. You can not send data + * with upload_session/start or upload_session/finish call, only with + * upload_session/append_v2 call. Also data uploaded in upload_session/append_v2 + * call must be multiple of 4194304 bytes (except for last + * upload_session/append_v2 with UploadSessionStartArg.close to true, that may + * contain any remaining data). + * @function Dropbox#filesUploadSessionStart + * @arg {FilesUploadSessionStartArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.filesUploadSessionStart = function (arg) { + return this.request('files/upload_session/start', arg, 'user', 'content', 'upload'); +}; + +/** + * Marks the given Paper doc as archived. This action can be performed or undone + * by anyone with edit permissions to the doc. Note that this endpoint will + * continue to work for content created by users on the older version of Paper. + * To check which version of Paper a user is on, use /users/features/get_values. + * If the paper_as_files feature is enabled, then the user is running the new + * version of Paper. This endpoint will be retired in September 2020. Refer to + * the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * more information. + * @function Dropbox#paperDocsArchive + * @deprecated + * @arg {PaperRefPaperDoc} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsArchive = function (arg) { + return this.request('paper/docs/archive', arg, 'user', 'api', 'rpc'); +}; + +/** + * Creates a new Paper doc with the provided content. Note that this endpoint + * will continue to work for content created by users on the older version of + * Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. This endpoint will be retired + * in September 2020. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * more information. + * @function Dropbox#paperDocsCreate + * @deprecated + * @arg {PaperPaperDocCreateArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsCreate = function (arg) { + return this.request('paper/docs/create', arg, 'user', 'api', 'upload'); +}; + +/** + * Exports and downloads Paper doc either as HTML or markdown. Note that this + * endpoint will continue to work for content created by users on the older + * version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperDocsDownload + * @deprecated + * @arg {PaperPaperDocExport} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsDownload = function (arg) { + return this.request('paper/docs/download', arg, 'user', 'api', 'download'); +}; + +/** + * Lists the users who are explicitly invited to the Paper folder in which the + * Paper doc is contained. For private folders all users (including owner) + * shared on the folder are listed and for team folders all non-team users + * shared on the folder are returned. Note that this endpoint will continue to + * work for content created by users on the older version of Paper. To check + * which version of Paper a user is on, use /users/features/get_values. If the + * paper_as_files feature is enabled, then the user is running the new version + * of Paper. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsFolderUsersList + * @deprecated + * @arg {PaperListUsersOnFolderArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsFolderUsersList = function (arg) { + return this.request('paper/docs/folder_users/list', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from docs/folder_users/list, use this to + * paginate through all users on the Paper folder. Note that this endpoint will + * continue to work for content created by users on the older version of Paper. + * To check which version of Paper a user is on, use /users/features/get_values. + * If the paper_as_files feature is enabled, then the user is running the new + * version of Paper. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsFolderUsersListContinue + * @deprecated + * @arg {PaperListUsersOnFolderContinueArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsFolderUsersListContinue = function (arg) { + return this.request('paper/docs/folder_users/list/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Retrieves folder information for the given Paper doc. This includes: - + * folder sharing policy; permissions for subfolders are set by the top-level + * folder. - full 'filepath', i.e. the list of folders (both folderId and + * folderName) from the root folder to the folder directly containing the + * Paper doc. If the Paper doc is not in any folder (aka unfiled) the response + * will be empty. Note that this endpoint will continue to work for content + * created by users on the older version of Paper. To check which version of + * Paper a user is on, use /users/features/get_values. If the paper_as_files + * feature is enabled, then the user is running the new version of Paper. Refer + * to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsGetFolderInfo + * @deprecated + * @arg {PaperRefPaperDoc} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsGetFolderInfo = function (arg) { + return this.request('paper/docs/get_folder_info', arg, 'user', 'api', 'rpc'); +}; + +/** + * Return the list of all Paper docs according to the argument specifications. + * To iterate over through the full pagination, pass the cursor to + * docs/list/continue. Note that this endpoint will continue to work for content + * created by users on the older version of Paper. To check which version of + * Paper a user is on, use /users/features/get_values. If the paper_as_files + * feature is enabled, then the user is running the new version of Paper. Refer + * to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsList + * @deprecated + * @arg {PaperListPaperDocsArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsList = function (arg) { + return this.request('paper/docs/list', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from docs/list, use this to paginate through + * all Paper doc. Note that this endpoint will continue to work for content + * created by users on the older version of Paper. To check which version of + * Paper a user is on, use /users/features/get_values. If the paper_as_files + * feature is enabled, then the user is running the new version of Paper. Refer + * to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsListContinue + * @deprecated + * @arg {PaperListPaperDocsContinueArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsListContinue = function (arg) { + return this.request('paper/docs/list/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Permanently deletes the given Paper doc. This operation is final as the doc + * cannot be recovered. This action can be performed only by the doc owner. Note + * that this endpoint will continue to work for content created by users on the + * older version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperDocsPermanentlyDelete + * @deprecated + * @arg {PaperRefPaperDoc} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsPermanentlyDelete = function (arg) { + return this.request('paper/docs/permanently_delete', arg, 'user', 'api', 'rpc'); +}; + +/** + * Gets the default sharing policy for the given Paper doc. Note that this + * endpoint will continue to work for content created by users on the older + * version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperDocsSharingPolicyGet + * @deprecated + * @arg {PaperRefPaperDoc} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsSharingPolicyGet = function (arg) { + return this.request('paper/docs/sharing_policy/get', arg, 'user', 'api', 'rpc'); +}; + +/** + * Sets the default sharing policy for the given Paper doc. The default + * 'team_sharing_policy' can be changed only by teams, omit this field for + * personal accounts. The 'public_sharing_policy' policy can't be set to the + * value 'disabled' because this setting can be changed only via the team admin + * console. Note that this endpoint will continue to work for content created by + * users on the older version of Paper. To check which version of Paper a user + * is on, use /users/features/get_values. If the paper_as_files feature is + * enabled, then the user is running the new version of Paper. Refer to the + * Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsSharingPolicySet + * @deprecated + * @arg {PaperPaperDocSharingPolicy} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsSharingPolicySet = function (arg) { + return this.request('paper/docs/sharing_policy/set', arg, 'user', 'api', 'rpc'); +}; + +/** + * Updates an existing Paper doc with the provided content. Note that this + * endpoint will continue to work for content created by users on the older + * version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. This endpoint will be retired + * in September 2020. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * more information. + * @function Dropbox#paperDocsUpdate + * @deprecated + * @arg {PaperPaperDocUpdateArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsUpdate = function (arg) { + return this.request('paper/docs/update', arg, 'user', 'api', 'upload'); +}; + +/** + * Allows an owner or editor to add users to a Paper doc or change their + * permissions using their email address or Dropbox account ID. The doc owner's + * permissions cannot be changed. Note that this endpoint will continue to work + * for content created by users on the older version of Paper. To check which + * version of Paper a user is on, use /users/features/get_values. If the + * paper_as_files feature is enabled, then the user is running the new version + * of Paper. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsUsersAdd + * @deprecated + * @arg {PaperAddPaperDocUser} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.paperDocsUsersAdd = function (arg) { + return this.request('paper/docs/users/add', arg, 'user', 'api', 'rpc'); +}; + +/** + * Lists all users who visited the Paper doc or users with explicit access. This + * call excludes users who have been removed. The list is sorted by the date of + * the visit or the share date. The list will include both users, the explicitly + * shared ones as well as those who came in using the Paper url link. Note that + * this endpoint will continue to work for content created by users on the older + * version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperDocsUsersList + * @deprecated + * @arg {PaperListUsersOnPaperDocArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsUsersList = function (arg) { + return this.request('paper/docs/users/list', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from docs/users/list, use this to paginate + * through all users on the Paper doc. Note that this endpoint will continue to + * work for content created by users on the older version of Paper. To check + * which version of Paper a user is on, use /users/features/get_values. If the + * paper_as_files feature is enabled, then the user is running the new version + * of Paper. Refer to the Paper Migration Guide + * https://www.dropbox.com/lp/developers/reference/paper-migration-guide for + * migration information. + * @function Dropbox#paperDocsUsersListContinue + * @deprecated + * @arg {PaperListUsersOnPaperDocContinueArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsUsersListContinue = function (arg) { + return this.request('paper/docs/users/list/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Allows an owner or editor to remove users from a Paper doc using their email + * address or Dropbox account ID. The doc owner cannot be removed. Note that + * this endpoint will continue to work for content created by users on the older + * version of Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperDocsUsersRemove + * @deprecated + * @arg {PaperRemovePaperDocUser} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperDocsUsersRemove = function (arg) { + return this.request('paper/docs/users/remove', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a new Paper folder with the provided info. Note that this endpoint + * will continue to work for content created by users on the older version of + * Paper. To check which version of Paper a user is on, use + * /users/features/get_values. If the paper_as_files feature is enabled, then + * the user is running the new version of Paper. Refer to the Paper Migration + * Guide https://www.dropbox.com/lp/developers/reference/paper-migration-guide + * for migration information. + * @function Dropbox#paperFoldersCreate + * @deprecated + * @arg {PaperPaperFolderCreateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.paperFoldersCreate = function (arg) { + return this.request('paper/folders/create', arg, 'user', 'api', 'rpc'); +}; + +/** + * Adds specified members to a file. + * @function Dropbox#sharingAddFileMember + * @arg {SharingAddFileMemberArgs} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.sharingAddFileMember = function (arg) { + return this.request('sharing/add_file_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Allows an owner or editor (if the ACL update policy allows) of a shared + * folder to add another member. For the new member to get access to all the + * functionality for this folder, you will need to call mount_folder on their + * behalf. + * @function Dropbox#sharingAddFolderMember + * @arg {SharingAddFolderMemberArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingAddFolderMember = function (arg) { + return this.request('sharing/add_folder_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Identical to update_file_member but with less information returned. + * @function Dropbox#sharingChangeFileMemberAccess + * @deprecated + * @arg {SharingChangeFileMemberAccessArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingChangeFileMemberAccess = function (arg) { + return this.request('sharing/change_file_member_access', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job. + * @function Dropbox#sharingCheckJobStatus + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingCheckJobStatus = function (arg) { + return this.request('sharing/check_job_status', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for sharing a folder. + * @function Dropbox#sharingCheckRemoveMemberJobStatus + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingCheckRemoveMemberJobStatus = function (arg) { + return this.request('sharing/check_remove_member_job_status', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for sharing a folder. + * @function Dropbox#sharingCheckShareJobStatus + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingCheckShareJobStatus = function (arg) { + return this.request('sharing/check_share_job_status', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a shared link. If a shared link already exists for the given path, + * that link is returned. Note that in the returned PathLinkMetadata, the + * PathLinkMetadata.url field is the shortened URL if + * CreateSharedLinkArg.short_url argument is set to true. Previously, it was + * technically possible to break a shared link by moving or renaming the + * corresponding file or folder. In the future, this will no longer be the case, + * so your app shouldn't rely on this behavior. Instead, if your app needs to + * revoke a shared link, use revoke_shared_link. + * @function Dropbox#sharingCreateSharedLink + * @deprecated + * @arg {SharingCreateSharedLinkArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingCreateSharedLink = function (arg) { + return this.request('sharing/create_shared_link', arg, 'user', 'api', 'rpc'); +}; + +/** + * Create a shared link with custom settings. If no settings are given then the + * default visibility is RequestedVisibility.public (The resolved visibility, + * though, may depend on other aspects such as team and shared folder settings). + * @function Dropbox#sharingCreateSharedLinkWithSettings + * @arg {SharingCreateSharedLinkWithSettingsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingCreateSharedLinkWithSettings = function (arg) { + return this.request('sharing/create_shared_link_with_settings', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns shared file metadata. + * @function Dropbox#sharingGetFileMetadata + * @arg {SharingGetFileMetadataArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingGetFileMetadata = function (arg) { + return this.request('sharing/get_file_metadata', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns shared file metadata. + * @function Dropbox#sharingGetFileMetadataBatch + * @arg {SharingGetFileMetadataBatchArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.sharingGetFileMetadataBatch = function (arg) { + return this.request('sharing/get_file_metadata/batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns shared folder metadata by its folder ID. + * @function Dropbox#sharingGetFolderMetadata + * @arg {SharingGetMetadataArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingGetFolderMetadata = function (arg) { + return this.request('sharing/get_folder_metadata', arg, 'user', 'api', 'rpc'); +}; + +/** + * Download the shared link's file from a user's Dropbox. + * @function Dropbox#sharingGetSharedLinkFile + * @arg {Object} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingGetSharedLinkFile = function (arg) { + return this.request('sharing/get_shared_link_file', arg, 'user', 'content', 'download'); +}; + +/** + * Get the shared link's metadata. + * @function Dropbox#sharingGetSharedLinkMetadata + * @arg {SharingGetSharedLinkMetadataArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingGetSharedLinkMetadata = function (arg) { + return this.request('sharing/get_shared_link_metadata', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns a list of LinkMetadata objects for this user, including collection + * links. If no path is given, returns a list of all shared links for the + * current user, including collection links, up to a maximum of 1000 links. If a + * non-empty path is given, returns a list of all shared links that allow access + * to the given path. Collection links are never returned in this case. Note + * that the url field in the response is never the shortened URL. + * @function Dropbox#sharingGetSharedLinks + * @deprecated + * @arg {SharingGetSharedLinksArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingGetSharedLinks = function (arg) { + return this.request('sharing/get_shared_links', arg, 'user', 'api', 'rpc'); +}; + +/** + * Use to obtain the members who have been invited to a file, both inherited and + * uninherited members. + * @function Dropbox#sharingListFileMembers + * @arg {SharingListFileMembersArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFileMembers = function (arg) { + return this.request('sharing/list_file_members', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get members of multiple files at once. The arguments to this route are more + * limited, and the limit on query result size per file is more strict. To + * customize the results more, use the individual file endpoint. Inherited users + * and groups are not included in the result, and permissions are not returned + * for this endpoint. + * @function Dropbox#sharingListFileMembersBatch + * @arg {SharingListFileMembersBatchArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.sharingListFileMembersBatch = function (arg) { + return this.request('sharing/list_file_members/batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_file_members or + * list_file_members/batch, use this to paginate through all shared file + * members. + * @function Dropbox#sharingListFileMembersContinue + * @arg {SharingListFileMembersContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFileMembersContinue = function (arg) { + return this.request('sharing/list_file_members/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns shared folder membership by its folder ID. + * @function Dropbox#sharingListFolderMembers + * @arg {SharingListFolderMembersArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFolderMembers = function (arg) { + return this.request('sharing/list_folder_members', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_folder_members, use this to + * paginate through all shared folder members. + * @function Dropbox#sharingListFolderMembersContinue + * @arg {SharingListFolderMembersContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFolderMembersContinue = function (arg) { + return this.request('sharing/list_folder_members/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Return the list of all shared folders the current user has access to. + * @function Dropbox#sharingListFolders + * @arg {SharingListFoldersArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFolders = function (arg) { + return this.request('sharing/list_folders', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_folders, use this to paginate + * through all shared folders. The cursor must come from a previous call to + * list_folders or list_folders/continue. + * @function Dropbox#sharingListFoldersContinue + * @arg {SharingListFoldersContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListFoldersContinue = function (arg) { + return this.request('sharing/list_folders/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Return the list of all shared folders the current user can mount or unmount. + * @function Dropbox#sharingListMountableFolders + * @arg {SharingListFoldersArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListMountableFolders = function (arg) { + return this.request('sharing/list_mountable_folders', arg, 'user', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from list_mountable_folders, use this to + * paginate through all mountable shared folders. The cursor must come from a + * previous call to list_mountable_folders or list_mountable_folders/continue. + * @function Dropbox#sharingListMountableFoldersContinue + * @arg {SharingListFoldersContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListMountableFoldersContinue = function (arg) { + return this.request('sharing/list_mountable_folders/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * Returns a list of all files shared with current user. Does not include files + * the user has received via shared folders, and does not include unclaimed + * invitations. + * @function Dropbox#sharingListReceivedFiles + * @arg {SharingListFilesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListReceivedFiles = function (arg) { + return this.request('sharing/list_received_files', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get more results with a cursor from list_received_files. + * @function Dropbox#sharingListReceivedFilesContinue + * @arg {SharingListFilesContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListReceivedFilesContinue = function (arg) { + return this.request('sharing/list_received_files/continue', arg, 'user', 'api', 'rpc'); +}; + +/** + * List shared links of this user. If no path is given, returns a list of all + * shared links for the current user. For members of business teams using team + * space and member folders, returns all shared links in the team member's home + * folder unless the team space ID is specified in the request header. For more + * information, refer to the Namespace Guide + * https://www.dropbox.com/developers/reference/namespace-guide. If a non-empty + * path is given, returns a list of all shared links that allow access to the + * given path - direct links to the given path and links to parent folders of + * the given path. Links to parent folders can be suppressed by setting + * direct_only to true. + * @function Dropbox#sharingListSharedLinks + * @arg {SharingListSharedLinksArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingListSharedLinks = function (arg) { + return this.request('sharing/list_shared_links', arg, 'user', 'api', 'rpc'); +}; + +/** + * Modify the shared link's settings. If the requested visibility conflict with + * the shared links policy of the team or the shared folder (in case the linked + * file is part of a shared folder) then the LinkPermissions.resolved_visibility + * of the returned SharedLinkMetadata will reflect the actual visibility of the + * shared link and the LinkPermissions.requested_visibility will reflect the + * requested visibility. + * @function Dropbox#sharingModifySharedLinkSettings + * @arg {SharingModifySharedLinkSettingsArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingModifySharedLinkSettings = function (arg) { + return this.request('sharing/modify_shared_link_settings', arg, 'user', 'api', 'rpc'); +}; + +/** + * The current user mounts the designated folder. Mount a shared folder for a + * user after they have been added as a member. Once mounted, the shared folder + * will appear in their Dropbox. + * @function Dropbox#sharingMountFolder + * @arg {SharingMountFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingMountFolder = function (arg) { + return this.request('sharing/mount_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * The current user relinquishes their membership in the designated file. Note + * that the current user may still have inherited access to this file through + * the parent folder. + * @function Dropbox#sharingRelinquishFileMembership + * @arg {SharingRelinquishFileMembershipArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRelinquishFileMembership = function (arg) { + return this.request('sharing/relinquish_file_membership', arg, 'user', 'api', 'rpc'); +}; + +/** + * The current user relinquishes their membership in the designated shared + * folder and will no longer have access to the folder. A folder owner cannot + * relinquish membership in their own folder. This will run synchronously if + * leave_a_copy is false, and asynchronously if leave_a_copy is true. + * @function Dropbox#sharingRelinquishFolderMembership + * @arg {SharingRelinquishFolderMembershipArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRelinquishFolderMembership = function (arg) { + return this.request('sharing/relinquish_folder_membership', arg, 'user', 'api', 'rpc'); +}; + +/** + * Identical to remove_file_member_2 but with less information returned. + * @function Dropbox#sharingRemoveFileMember + * @deprecated + * @arg {SharingRemoveFileMemberArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRemoveFileMember = function (arg) { + return this.request('sharing/remove_file_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Removes a specified member from the file. + * @function Dropbox#sharingRemoveFileMember2 + * @arg {SharingRemoveFileMemberArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRemoveFileMember2 = function (arg) { + return this.request('sharing/remove_file_member_2', arg, 'user', 'api', 'rpc'); +}; + +/** + * Allows an owner or editor (if the ACL update policy allows) of a shared + * folder to remove another member. + * @function Dropbox#sharingRemoveFolderMember + * @arg {SharingRemoveFolderMemberArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRemoveFolderMember = function (arg) { + return this.request('sharing/remove_folder_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Revoke a shared link. Note that even after revoking a shared link to a file, + * the file may be accessible if there are shared links leading to any of the + * file parent folders. To list all shared links that enable access to a + * specific file, you can use the list_shared_links with the file as the + * ListSharedLinksArg.path argument. + * @function Dropbox#sharingRevokeSharedLink + * @arg {SharingRevokeSharedLinkArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingRevokeSharedLink = function (arg) { + return this.request('sharing/revoke_shared_link', arg, 'user', 'api', 'rpc'); +}; + +/** + * Change the inheritance policy of an existing Shared Folder. Only permitted + * for shared folders in a shared team root. If a ShareFolderLaunch.async_job_id + * is returned, you'll need to call check_share_job_status until the action + * completes to get the metadata for the folder. + * @function Dropbox#sharingSetAccessInheritance + * @arg {SharingSetAccessInheritanceArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingSetAccessInheritance = function (arg) { + return this.request('sharing/set_access_inheritance', arg, 'user', 'api', 'rpc'); +}; + +/** + * Share a folder with collaborators. Most sharing will be completed + * synchronously. Large folders will be completed asynchronously. To make + * testing the async case repeatable, set `ShareFolderArg.force_async`. If a + * ShareFolderLaunch.async_job_id is returned, you'll need to call + * check_share_job_status until the action completes to get the metadata for the + * folder. + * @function Dropbox#sharingShareFolder + * @arg {SharingShareFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingShareFolder = function (arg) { + return this.request('sharing/share_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * Transfer ownership of a shared folder to a member of the shared folder. User + * must have AccessLevel.owner access to the shared folder to perform a + * transfer. + * @function Dropbox#sharingTransferFolder + * @arg {SharingTransferFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingTransferFolder = function (arg) { + return this.request('sharing/transfer_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * The current user unmounts the designated folder. They can re-mount the folder + * at a later time using mount_folder. + * @function Dropbox#sharingUnmountFolder + * @arg {SharingUnmountFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUnmountFolder = function (arg) { + return this.request('sharing/unmount_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * Remove all members from this file. Does not remove inherited members. + * @function Dropbox#sharingUnshareFile + * @arg {SharingUnshareFileArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUnshareFile = function (arg) { + return this.request('sharing/unshare_file', arg, 'user', 'api', 'rpc'); +}; + +/** + * Allows a shared folder owner to unshare the folder. You'll need to call + * check_job_status to determine if the action has completed successfully. + * @function Dropbox#sharingUnshareFolder + * @arg {SharingUnshareFolderArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUnshareFolder = function (arg) { + return this.request('sharing/unshare_folder', arg, 'user', 'api', 'rpc'); +}; + +/** + * Changes a member's access on a shared file. + * @function Dropbox#sharingUpdateFileMember + * @arg {SharingUpdateFileMemberArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUpdateFileMember = function (arg) { + return this.request('sharing/update_file_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Allows an owner or editor of a shared folder to update another member's + * permissions. + * @function Dropbox#sharingUpdateFolderMember + * @arg {SharingUpdateFolderMemberArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUpdateFolderMember = function (arg) { + return this.request('sharing/update_folder_member', arg, 'user', 'api', 'rpc'); +}; + +/** + * Update the sharing policies for a shared folder. User must have + * AccessLevel.owner access to the shared folder to update its policies. + * @function Dropbox#sharingUpdateFolderPolicy + * @arg {SharingUpdateFolderPolicyArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.sharingUpdateFolderPolicy = function (arg) { + return this.request('sharing/update_folder_policy', arg, 'user', 'api', 'rpc'); +}; + +/** + * List all device sessions of a team's member. + * @function Dropbox#teamDevicesListMemberDevices + * @arg {TeamListMemberDevicesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamDevicesListMemberDevices = function (arg) { + return this.request('team/devices/list_member_devices', arg, 'team', 'api', 'rpc'); +}; + +/** + * List all device sessions of a team. Permission : Team member file access. + * @function Dropbox#teamDevicesListMembersDevices + * @arg {TeamListMembersDevicesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamDevicesListMembersDevices = function (arg) { + return this.request('team/devices/list_members_devices', arg, 'team', 'api', 'rpc'); +}; + +/** + * List all device sessions of a team. Permission : Team member file access. + * @function Dropbox#teamDevicesListTeamDevices + * @deprecated + * @arg {TeamListTeamDevicesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamDevicesListTeamDevices = function (arg) { + return this.request('team/devices/list_team_devices', arg, 'team', 'api', 'rpc'); +}; + +/** + * Revoke a device session of a team's member. + * @function Dropbox#teamDevicesRevokeDeviceSession + * @arg {TeamRevokeDeviceSessionArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamDevicesRevokeDeviceSession = function (arg) { + return this.request('team/devices/revoke_device_session', arg, 'team', 'api', 'rpc'); +}; + +/** + * Revoke a list of device sessions of team members. + * @function Dropbox#teamDevicesRevokeDeviceSessionBatch + * @arg {TeamRevokeDeviceSessionBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamDevicesRevokeDeviceSessionBatch = function (arg) { + return this.request('team/devices/revoke_device_session_batch', arg, 'team', 'api', 'rpc'); +}; + +/** + * Get the values for one or more featues. This route allows you to check your + * account's capability for what feature you can access or what value you have + * for certain features. Permission : Team information. + * @function Dropbox#teamFeaturesGetValues + * @arg {TeamFeaturesGetValuesBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamFeaturesGetValues = function (arg) { + return this.request('team/features/get_values', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves information about a team. + * @function Dropbox#teamGetInfo + * @returns {Promise., Error.>} + */ +routes.teamGetInfo = function () { + return this.request('team/get_info', null, 'team', 'api', 'rpc'); +}; + +/** + * Creates a new, empty group, with a requested name. Permission : Team member + * management. + * @function Dropbox#teamGroupsCreate + * @arg {TeamGroupCreateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsCreate = function (arg) { + return this.request('team/groups/create', arg, 'team', 'api', 'rpc'); +}; + +/** + * Deletes a group. The group is deleted immediately. However the revoking of + * group-owned resources may take additional time. Use the groups/job_status/get + * to determine whether this process has completed. Permission : Team member + * management. + * @function Dropbox#teamGroupsDelete + * @arg {TeamGroupSelector} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsDelete = function (arg) { + return this.request('team/groups/delete', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves information about one or more groups. Note that the optional field + * GroupFullInfo.members is not returned for system-managed groups. Permission : + * Team Information. + * @function Dropbox#teamGroupsGetInfo + * @arg {TeamGroupsSelector} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsGetInfo = function (arg) { + return this.request('team/groups/get_info', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once an async_job_id is returned from groups/delete, groups/members/add , or + * groups/members/remove use this method to poll the status of granting/revoking + * group members' access to group-owned resources. Permission : Team member + * management. + * @function Dropbox#teamGroupsJobStatusGet + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsJobStatusGet = function (arg) { + return this.request('team/groups/job_status/get', arg, 'team', 'api', 'rpc'); +}; + +/** + * Lists groups on a team. Permission : Team Information. + * @function Dropbox#teamGroupsList + * @arg {TeamGroupsListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsList = function (arg) { + return this.request('team/groups/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from groups/list, use this to paginate + * through all groups. Permission : Team Information. + * @function Dropbox#teamGroupsListContinue + * @arg {TeamGroupsListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsListContinue = function (arg) { + return this.request('team/groups/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Adds members to a group. The members are added immediately. However the + * granting of group-owned resources may take additional time. Use the + * groups/job_status/get to determine whether this process has completed. + * Permission : Team member management. + * @function Dropbox#teamGroupsMembersAdd + * @arg {TeamGroupMembersAddArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsMembersAdd = function (arg) { + return this.request('team/groups/members/add', arg, 'team', 'api', 'rpc'); +}; + +/** + * Lists members of a group. Permission : Team Information. + * @function Dropbox#teamGroupsMembersList + * @arg {TeamGroupsMembersListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsMembersList = function (arg) { + return this.request('team/groups/members/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from groups/members/list, use this to + * paginate through all members of the group. Permission : Team information. + * @function Dropbox#teamGroupsMembersListContinue + * @arg {TeamGroupsMembersListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsMembersListContinue = function (arg) { + return this.request('team/groups/members/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Removes members from a group. The members are removed immediately. However + * the revoking of group-owned resources may take additional time. Use the + * groups/job_status/get to determine whether this process has completed. This + * method permits removing the only owner of a group, even in cases where this + * is not possible via the web client. Permission : Team member management. + * @function Dropbox#teamGroupsMembersRemove + * @arg {TeamGroupMembersRemoveArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsMembersRemove = function (arg) { + return this.request('team/groups/members/remove', arg, 'team', 'api', 'rpc'); +}; + +/** + * Sets a member's access type in a group. Permission : Team member management. + * @function Dropbox#teamGroupsMembersSetAccessType + * @arg {TeamGroupMembersSetAccessTypeArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsMembersSetAccessType = function (arg) { + return this.request('team/groups/members/set_access_type', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates a group's name and/or external ID. Permission : Team member + * management. + * @function Dropbox#teamGroupsUpdate + * @arg {TeamGroupUpdateArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamGroupsUpdate = function (arg) { + return this.request('team/groups/update', arg, 'team', 'api', 'rpc'); +}; + +/** + * Creates new legal hold policy. Note: Legal Holds is a paid add-on. Not all + * teams have the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsCreatePolicy + * @arg {TeamLegalHoldsPolicyCreateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsCreatePolicy = function (arg) { + return this.request('team/legal_holds/create_policy', arg, 'team', 'api', 'rpc'); +}; + +/** + * Gets a legal hold by Id. Note: Legal Holds is a paid add-on. Not all teams + * have the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsGetPolicy + * @arg {TeamLegalHoldsGetPolicyArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsGetPolicy = function (arg) { + return this.request('team/legal_holds/get_policy', arg, 'team', 'api', 'rpc'); +}; + +/** + * List the file metadata that's under the hold. Note: Legal Holds is a paid + * add-on. Not all teams have the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsListHeldRevisions + * @arg {TeamLegalHoldsListHeldRevisionsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsListHeldRevisions = function (arg) { + return this.request('team/legal_holds/list_held_revisions', arg, 'team', 'api', 'rpc'); +}; + +/** + * Continue listing the file metadata that's under the hold. Note: Legal Holds + * is a paid add-on. Not all teams have the feature. Permission : Team member + * file access. + * @function Dropbox#teamLegalHoldsListHeldRevisionsContinue + * @arg {TeamLegalHoldsListHeldRevisionsContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsListHeldRevisionsContinue = function (arg) { + return this.request('team/legal_holds/list_held_revisions_continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Lists legal holds on a team. Note: Legal Holds is a paid add-on. Not all + * teams have the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsListPolicies + * @arg {TeamLegalHoldsListPoliciesArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsListPolicies = function (arg) { + return this.request('team/legal_holds/list_policies', arg, 'team', 'api', 'rpc'); +}; + +/** + * Releases a legal hold by Id. Note: Legal Holds is a paid add-on. Not all + * teams have the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsReleasePolicy + * @arg {TeamLegalHoldsPolicyReleaseArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsReleasePolicy = function (arg) { + return this.request('team/legal_holds/release_policy', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates a legal hold. Note: Legal Holds is a paid add-on. Not all teams have + * the feature. Permission : Team member file access. + * @function Dropbox#teamLegalHoldsUpdatePolicy + * @arg {TeamLegalHoldsPolicyUpdateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLegalHoldsUpdatePolicy = function (arg) { + return this.request('team/legal_holds/update_policy', arg, 'team', 'api', 'rpc'); +}; + +/** + * List all linked applications of the team member. Note, this endpoint does not + * list any team-linked applications. + * @function Dropbox#teamLinkedAppsListMemberLinkedApps + * @arg {TeamListMemberAppsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLinkedAppsListMemberLinkedApps = function (arg) { + return this.request('team/linked_apps/list_member_linked_apps', arg, 'team', 'api', 'rpc'); +}; + +/** + * List all applications linked to the team members' accounts. Note, this + * endpoint does not list any team-linked applications. + * @function Dropbox#teamLinkedAppsListMembersLinkedApps + * @arg {TeamListMembersAppsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLinkedAppsListMembersLinkedApps = function (arg) { + return this.request('team/linked_apps/list_members_linked_apps', arg, 'team', 'api', 'rpc'); +}; + +/** + * List all applications linked to the team members' accounts. Note, this + * endpoint doesn't list any team-linked applications. + * @function Dropbox#teamLinkedAppsListTeamLinkedApps + * @deprecated + * @arg {TeamListTeamAppsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLinkedAppsListTeamLinkedApps = function (arg) { + return this.request('team/linked_apps/list_team_linked_apps', arg, 'team', 'api', 'rpc'); +}; + +/** + * Revoke a linked application of the team member. + * @function Dropbox#teamLinkedAppsRevokeLinkedApp + * @arg {TeamRevokeLinkedApiAppArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLinkedAppsRevokeLinkedApp = function (arg) { + return this.request('team/linked_apps/revoke_linked_app', arg, 'team', 'api', 'rpc'); +}; + +/** + * Revoke a list of linked applications of the team members. + * @function Dropbox#teamLinkedAppsRevokeLinkedAppBatch + * @arg {TeamRevokeLinkedApiAppBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLinkedAppsRevokeLinkedAppBatch = function (arg) { + return this.request('team/linked_apps/revoke_linked_app_batch', arg, 'team', 'api', 'rpc'); +}; + +/** + * Add users to member space limits excluded users list. + * @function Dropbox#teamMemberSpaceLimitsExcludedUsersAdd + * @arg {TeamExcludedUsersUpdateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMemberSpaceLimitsExcludedUsersAdd = function (arg) { + return this.request('team/member_space_limits/excluded_users/add', arg, 'team', 'api', 'rpc'); +}; + +/** + * List member space limits excluded users. + * @function Dropbox#teamMemberSpaceLimitsExcludedUsersList + * @arg {TeamExcludedUsersListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMemberSpaceLimitsExcludedUsersList = function (arg) { + return this.request('team/member_space_limits/excluded_users/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Continue listing member space limits excluded users. + * @function Dropbox#teamMemberSpaceLimitsExcludedUsersListContinue + * @arg {TeamExcludedUsersListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMemberSpaceLimitsExcludedUsersListContinue = function (arg) { + return this.request('team/member_space_limits/excluded_users/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Remove users from member space limits excluded users list. + * @function Dropbox#teamMemberSpaceLimitsExcludedUsersRemove + * @arg {TeamExcludedUsersUpdateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMemberSpaceLimitsExcludedUsersRemove = function (arg) { + return this.request('team/member_space_limits/excluded_users/remove', arg, 'team', 'api', 'rpc'); +}; + +/** + * Get users custom quota. Returns none as the custom quota if none was set. A + * maximum of 1000 members can be specified in a single call. + * @function Dropbox#teamMemberSpaceLimitsGetCustomQuota + * @arg {TeamCustomQuotaUsersArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.teamMemberSpaceLimitsGetCustomQuota = function (arg) { + return this.request('team/member_space_limits/get_custom_quota', arg, 'team', 'api', 'rpc'); +}; + +/** + * Remove users custom quota. A maximum of 1000 members can be specified in a + * single call. + * @function Dropbox#teamMemberSpaceLimitsRemoveCustomQuota + * @arg {TeamCustomQuotaUsersArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.teamMemberSpaceLimitsRemoveCustomQuota = function (arg) { + return this.request('team/member_space_limits/remove_custom_quota', arg, 'team', 'api', 'rpc'); +}; + +/** + * Set users custom quota. Custom quota has to be at least 15GB. A maximum of + * 1000 members can be specified in a single call. + * @function Dropbox#teamMemberSpaceLimitsSetCustomQuota + * @arg {TeamSetCustomQuotaArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.teamMemberSpaceLimitsSetCustomQuota = function (arg) { + return this.request('team/member_space_limits/set_custom_quota', arg, 'team', 'api', 'rpc'); +}; + +/** + * Adds members to a team. Permission : Team member management A maximum of 20 + * members can be specified in a single call. If no Dropbox account exists with + * the email address specified, a new Dropbox account will be created with the + * given email address, and that account will be invited to the team. If a + * personal Dropbox account exists with the email address specified in the call, + * this call will create a placeholder Dropbox account for the user on the team + * and send an email inviting the user to migrate their existing personal + * account onto the team. Team member management apps are required to set an + * initial given_name and surname for a user to use in the team invitation and + * for 'Perform as team member' actions taken on the user before they become + * 'active'. + * @function Dropbox#teamMembersAdd + * @arg {TeamMembersAddArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersAdd = function (arg) { + return this.request('team/members/add', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once an async_job_id is returned from members/add , use this to poll the + * status of the asynchronous request. Permission : Team member management. + * @function Dropbox#teamMembersAddJobStatusGet + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersAddJobStatusGet = function (arg) { + return this.request('team/members/add/job_status/get', arg, 'team', 'api', 'rpc'); +}; + +/** + * Deletes a team member's profile photo. Permission : Team member management. + * @function Dropbox#teamMembersDeleteProfilePhoto + * @arg {TeamMembersDeleteProfilePhotoArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersDeleteProfilePhoto = function (arg) { + return this.request('team/members/delete_profile_photo', arg, 'team', 'api', 'rpc'); +}; + +/** + * Returns information about multiple team members. Permission : Team + * information This endpoint will return MembersGetInfoItem.id_not_found, for + * IDs (or emails) that cannot be matched to a valid team member. + * @function Dropbox#teamMembersGetInfo + * @arg {TeamMembersGetInfoArgs} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersGetInfo = function (arg) { + return this.request('team/members/get_info', arg, 'team', 'api', 'rpc'); +}; + +/** + * Lists members of a team. Permission : Team information. + * @function Dropbox#teamMembersList + * @arg {TeamMembersListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersList = function (arg) { + return this.request('team/members/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from members/list, use this to paginate + * through all team members. Permission : Team information. + * @function Dropbox#teamMembersListContinue + * @arg {TeamMembersListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersListContinue = function (arg) { + return this.request('team/members/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Moves removed member's files to a different member. This endpoint initiates + * an asynchronous job. To obtain the final result of the job, the client should + * periodically poll members/move_former_member_files/job_status/check. + * Permission : Team member management. + * @function Dropbox#teamMembersMoveFormerMemberFiles + * @arg {TeamMembersDataTransferArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersMoveFormerMemberFiles = function (arg) { + return this.request('team/members/move_former_member_files', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once an async_job_id is returned from members/move_former_member_files , use + * this to poll the status of the asynchronous request. Permission : Team member + * management. + * @function Dropbox#teamMembersMoveFormerMemberFilesJobStatusCheck + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersMoveFormerMemberFilesJobStatusCheck = function (arg) { + return this.request('team/members/move_former_member_files/job_status/check', arg, 'team', 'api', 'rpc'); +}; + +/** + * Recover a deleted member. Permission : Team member management Exactly one of + * team_member_id, email, or external_id must be provided to identify the user + * account. + * @function Dropbox#teamMembersRecover + * @arg {TeamMembersRecoverArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersRecover = function (arg) { + return this.request('team/members/recover', arg, 'team', 'api', 'rpc'); +}; + +/** + * Removes a member from a team. Permission : Team member management Exactly one + * of team_member_id, email, or external_id must be provided to identify the + * user account. Accounts can be recovered via members/recover for a 7 day + * period or until the account has been permanently deleted or transferred to + * another account (whichever comes first). Calling members/add while a user is + * still recoverable on your team will return with + * MemberAddResult.user_already_on_team. Accounts can have their files + * transferred via the admin console for a limited time, based on the version + * history length associated with the team (180 days for most teams). This + * endpoint may initiate an asynchronous job. To obtain the final result of the + * job, the client should periodically poll members/remove/job_status/get. + * @function Dropbox#teamMembersRemove + * @arg {TeamMembersRemoveArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersRemove = function (arg) { + return this.request('team/members/remove', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once an async_job_id is returned from members/remove , use this to poll the + * status of the asynchronous request. Permission : Team member management. + * @function Dropbox#teamMembersRemoveJobStatusGet + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersRemoveJobStatusGet = function (arg) { + return this.request('team/members/remove/job_status/get', arg, 'team', 'api', 'rpc'); +}; + +/** + * Add secondary emails to users. Permission : Team member management. Emails + * that are on verified domains will be verified automatically. For each email + * address not on a verified domain a verification email will be sent. + * @function Dropbox#teamMembersSecondaryEmailsAdd + * @arg {TeamAddSecondaryEmailsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSecondaryEmailsAdd = function (arg) { + return this.request('team/members/secondary_emails/add', arg, 'team', 'api', 'rpc'); +}; + +/** + * Delete secondary emails from users Permission : Team member management. Users + * will be notified of deletions of verified secondary emails at both the + * secondary email and their primary email. + * @function Dropbox#teamMembersSecondaryEmailsDelete + * @arg {TeamDeleteSecondaryEmailsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSecondaryEmailsDelete = function (arg) { + return this.request('team/members/secondary_emails/delete', arg, 'team', 'api', 'rpc'); +}; + +/** + * Resend secondary email verification emails. Permission : Team member + * management. + * @function Dropbox#teamMembersSecondaryEmailsResendVerificationEmails + * @arg {TeamResendVerificationEmailArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSecondaryEmailsResendVerificationEmails = function (arg) { + return this.request('team/members/secondary_emails/resend_verification_emails', arg, 'team', 'api', 'rpc'); +}; + +/** + * Sends welcome email to pending team member. Permission : Team member + * management Exactly one of team_member_id, email, or external_id must be + * provided to identify the user account. No-op if team member is not pending. + * @function Dropbox#teamMembersSendWelcomeEmail + * @arg {TeamUserSelectorArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSendWelcomeEmail = function (arg) { + return this.request('team/members/send_welcome_email', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates a team member's permissions. Permission : Team member management. + * @function Dropbox#teamMembersSetAdminPermissions + * @arg {TeamMembersSetPermissionsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSetAdminPermissions = function (arg) { + return this.request('team/members/set_admin_permissions', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates a team member's profile. Permission : Team member management. + * @function Dropbox#teamMembersSetProfile + * @arg {TeamMembersSetProfileArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSetProfile = function (arg) { + return this.request('team/members/set_profile', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates a team member's profile photo. Permission : Team member management. + * @function Dropbox#teamMembersSetProfilePhoto + * @arg {TeamMembersSetProfilePhotoArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSetProfilePhoto = function (arg) { + return this.request('team/members/set_profile_photo', arg, 'team', 'api', 'rpc'); +}; + +/** + * Suspend a member from a team. Permission : Team member management Exactly one + * of team_member_id, email, or external_id must be provided to identify the + * user account. + * @function Dropbox#teamMembersSuspend + * @arg {TeamMembersDeactivateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersSuspend = function (arg) { + return this.request('team/members/suspend', arg, 'team', 'api', 'rpc'); +}; + +/** + * Unsuspend a member from a team. Permission : Team member management Exactly + * one of team_member_id, email, or external_id must be provided to identify the + * user account. + * @function Dropbox#teamMembersUnsuspend + * @arg {TeamMembersUnsuspendArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamMembersUnsuspend = function (arg) { + return this.request('team/members/unsuspend', arg, 'team', 'api', 'rpc'); +}; + +/** + * Returns a list of all team-accessible namespaces. This list includes team + * folders, shared folders containing team members, team members' home + * namespaces, and team members' app folders. Home namespaces and app folders + * are always owned by this team or members of the team, but shared folders may + * be owned by other users or other teams. Duplicates may occur in the list. + * @function Dropbox#teamNamespacesList + * @arg {TeamTeamNamespacesListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamNamespacesList = function (arg) { + return this.request('team/namespaces/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from namespaces/list, use this to paginate + * through all team-accessible namespaces. Duplicates may occur in the list. + * @function Dropbox#teamNamespacesListContinue + * @arg {TeamTeamNamespacesListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamNamespacesListContinue = function (arg) { + return this.request('team/namespaces/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Permission : Team member file access. + * @function Dropbox#teamPropertiesTemplateAdd + * @deprecated + * @arg {FilePropertiesAddTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamPropertiesTemplateAdd = function (arg) { + return this.request('team/properties/template/add', arg, 'team', 'api', 'rpc'); +}; + +/** + * Permission : Team member file access. The scope for the route is + * files.team_metadata.write. + * @function Dropbox#teamPropertiesTemplateGet + * @deprecated + * @arg {FilePropertiesGetTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamPropertiesTemplateGet = function (arg) { + return this.request('team/properties/template/get', arg, 'team', 'api', 'rpc'); +}; + +/** + * Permission : Team member file access. The scope for the route is + * files.team_metadata.write. + * @function Dropbox#teamPropertiesTemplateList + * @deprecated + * @returns {Promise., Error.>} + */ +routes.teamPropertiesTemplateList = function () { + return this.request('team/properties/template/list', null, 'team', 'api', 'rpc'); +}; + +/** + * Permission : Team member file access. + * @function Dropbox#teamPropertiesTemplateUpdate + * @deprecated + * @arg {FilePropertiesUpdateTemplateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamPropertiesTemplateUpdate = function (arg) { + return this.request('team/properties/template/update', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves reporting data about a team's user activity. Deprecated: Will be + * removed on July 1st 2021. + * @function Dropbox#teamReportsGetActivity + * @deprecated + * @arg {TeamDateRange} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamReportsGetActivity = function (arg) { + return this.request('team/reports/get_activity', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves reporting data about a team's linked devices. Deprecated: Will be + * removed on July 1st 2021. + * @function Dropbox#teamReportsGetDevices + * @deprecated + * @arg {TeamDateRange} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamReportsGetDevices = function (arg) { + return this.request('team/reports/get_devices', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves reporting data about a team's membership. Deprecated: Will be + * removed on July 1st 2021. + * @function Dropbox#teamReportsGetMembership + * @deprecated + * @arg {TeamDateRange} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamReportsGetMembership = function (arg) { + return this.request('team/reports/get_membership', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves reporting data about a team's storage usage. Deprecated: Will be + * removed on July 1st 2021. + * @function Dropbox#teamReportsGetStorage + * @deprecated + * @arg {TeamDateRange} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamReportsGetStorage = function (arg) { + return this.request('team/reports/get_storage', arg, 'team', 'api', 'rpc'); +}; + +/** + * Sets an archived team folder's status to active. Permission : Team member + * file access. + * @function Dropbox#teamTeamFolderActivate + * @arg {TeamTeamFolderIdArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderActivate = function (arg) { + return this.request('team/team_folder/activate', arg, 'team', 'api', 'rpc'); +}; + +/** + * Sets an active team folder's status to archived and removes all folder and + * file members. Permission : Team member file access. + * @function Dropbox#teamTeamFolderArchive + * @arg {TeamTeamFolderArchiveArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderArchive = function (arg) { + return this.request('team/team_folder/archive', arg, 'team', 'api', 'rpc'); +}; + +/** + * Returns the status of an asynchronous job for archiving a team folder. + * Permission : Team member file access. + * @function Dropbox#teamTeamFolderArchiveCheck + * @arg {AsyncPollArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderArchiveCheck = function (arg) { + return this.request('team/team_folder/archive/check', arg, 'team', 'api', 'rpc'); +}; + +/** + * Creates a new, active, team folder with no members. Permission : Team member + * file access. + * @function Dropbox#teamTeamFolderCreate + * @arg {TeamTeamFolderCreateArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderCreate = function (arg) { + return this.request('team/team_folder/create', arg, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves metadata for team folders. Permission : Team member file access. + * @function Dropbox#teamTeamFolderGetInfo + * @arg {TeamTeamFolderIdListArg} arg - The request parameters. + * @returns {Promise.>, Error.>} + */ +routes.teamTeamFolderGetInfo = function (arg) { + return this.request('team/team_folder/get_info', arg, 'team', 'api', 'rpc'); +}; + +/** + * Lists all team folders. Permission : Team member file access. + * @function Dropbox#teamTeamFolderList + * @arg {TeamTeamFolderListArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderList = function (arg) { + return this.request('team/team_folder/list', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from team_folder/list, use this to paginate + * through all team folders. Permission : Team member file access. + * @function Dropbox#teamTeamFolderListContinue + * @arg {TeamTeamFolderListContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderListContinue = function (arg) { + return this.request('team/team_folder/list/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Permanently deletes an archived team folder. Permission : Team member file + * access. + * @function Dropbox#teamTeamFolderPermanentlyDelete + * @arg {TeamTeamFolderIdArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderPermanentlyDelete = function (arg) { + return this.request('team/team_folder/permanently_delete', arg, 'team', 'api', 'rpc'); +}; + +/** + * Changes an active team folder's name. Permission : Team member file access. + * @function Dropbox#teamTeamFolderRename + * @arg {TeamTeamFolderRenameArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderRename = function (arg) { + return this.request('team/team_folder/rename', arg, 'team', 'api', 'rpc'); +}; + +/** + * Updates the sync settings on a team folder or its contents. Use of this + * endpoint requires that the team has team selective sync enabled. + * @function Dropbox#teamTeamFolderUpdateSyncSettings + * @arg {TeamTeamFolderUpdateSyncSettingsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamTeamFolderUpdateSyncSettings = function (arg) { + return this.request('team/team_folder/update_sync_settings', arg, 'team', 'api', 'rpc'); +}; + +/** + * Returns the member profile of the admin who generated the team access token + * used to make the call. + * @function Dropbox#teamTokenGetAuthenticatedAdmin + * @returns {Promise., Error.>} + */ +routes.teamTokenGetAuthenticatedAdmin = function () { + return this.request('team/token/get_authenticated_admin', null, 'team', 'api', 'rpc'); +}; + +/** + * Retrieves team events. If the result's GetTeamEventsResult.has_more field is + * true, call get_events/continue with the returned cursor to retrieve more + * entries. If end_time is not specified in your request, you may use the + * returned cursor to poll get_events/continue for new events. Many attributes + * note 'may be missing due to historical data gap'. Note that the + * file_operations category and & analogous paper events are not available on + * all Dropbox Business plans /business/plans-comparison. Use + * features/get_values + * /developers/documentation/http/teams#team-features-get_values to check for + * this feature. Permission : Team Auditing. + * @function Dropbox#teamLogGetEvents + * @arg {TeamLogGetTeamEventsArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLogGetEvents = function (arg) { + return this.request('team_log/get_events', arg, 'team', 'api', 'rpc'); +}; + +/** + * Once a cursor has been retrieved from get_events, use this to paginate + * through all events. Permission : Team Auditing. + * @function Dropbox#teamLogGetEventsContinue + * @arg {TeamLogGetTeamEventsContinueArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.teamLogGetEventsContinue = function (arg) { + return this.request('team_log/get_events/continue', arg, 'team', 'api', 'rpc'); +}; + +/** + * Get a list of feature values that may be configured for the current account. + * @function Dropbox#usersFeaturesGetValues + * @arg {UsersUserFeaturesGetValuesBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.usersFeaturesGetValues = function (arg) { + return this.request('users/features/get_values', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get information about a user's account. + * @function Dropbox#usersGetAccount + * @arg {UsersGetAccountArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.usersGetAccount = function (arg) { + return this.request('users/get_account', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get information about multiple user accounts. At most 300 accounts may be + * queried per request. + * @function Dropbox#usersGetAccountBatch + * @arg {UsersGetAccountBatchArg} arg - The request parameters. + * @returns {Promise., Error.>} + */ +routes.usersGetAccountBatch = function (arg) { + return this.request('users/get_account_batch', arg, 'user', 'api', 'rpc'); +}; + +/** + * Get information about the current user's account. + * @function Dropbox#usersGetCurrentAccount + * @returns {Promise., Error.>} + */ +routes.usersGetCurrentAccount = function () { + return this.request('users/get_current_account', null, 'user', 'api', 'rpc'); +}; + +/** + * Get the space usage information for the current user's account. + * @function Dropbox#usersGetSpaceUsage + * @returns {Promise., Error.>} + */ +routes.usersGetSpaceUsage = function () { + return this.request('users/get_space_usage', null, 'user', 'api', 'rpc'); +}; + +export { routes }; diff --git a/addon/lib/dropbox/utils.js b/addon/lib/dropbox/utils.js new file mode 100644 index 00000000..90c7f411 --- /dev/null +++ b/addon/lib/dropbox/utils.js @@ -0,0 +1,43 @@ +function getSafeUnicode(c) { + const unicode = `000${c.charCodeAt(0).toString(16)}`.slice(-4); + return `\\u${unicode}`; +} + +export function getBaseURL(host) { + return `https://${host}.dropboxapi.com/2/`; +} + +// source https://www.dropboxforum.com/t5/API-support/HTTP-header-quot-Dropbox-API-Arg-quot-could-not-decode-input-as/m-p/173823/highlight/true#M6786 +export function httpHeaderSafeJson(args) { + return JSON.stringify(args).replace(/[\u007f-\uffff]/g, getSafeUnicode); +} + +export function getTokenExpiresAtDate(expiresIn) { + return new Date(Date.now() + (expiresIn * 1000)); +} + +/* global WorkerGlobalScope */ +export function isWindowOrWorker() { + return ( + ( + typeof WorkerGlobalScope !== 'undefined' + && self instanceof WorkerGlobalScope // eslint-disable-line no-restricted-globals + ) + || ( + typeof module === 'undefined' + || typeof globalThis !== 'undefined' + ) + ); +} + +export function isBrowserEnv() { + return typeof globalThis !== 'undefined'; +} + +export function createBrowserSafeString(toBeConverted) { + const convertedString = toBeConverted.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return convertedString; +} diff --git a/libs/jquery-3.3.1.js b/addon/lib/jquery.js similarity index 94% rename from libs/jquery-3.3.1.js rename to addon/lib/jquery.js index 9b5206bc..773ad95c 100644 --- a/libs/jquery-3.3.1.js +++ b/addon/lib/jquery.js @@ -1,5 +1,5 @@ /*! - * jQuery JavaScript Library v3.3.1 + * jQuery JavaScript Library v3.4.1 * https://jquery.com/ * * Includes Sizzle.js @@ -9,7 +9,7 @@ * Released under the MIT license * https://jquery.org/license * - * Date: 2018-01-20T17:24Z + * Date: 2019-05-01T21:04Z */ ( function( global, factory ) { @@ -91,20 +91,33 @@ var isWindow = function isWindow( obj ) { var preservedScriptAttributes = { type: true, src: true, + nonce: true, noModule: true }; - function DOMEval( code, doc, node ) { + function DOMEval( code, node, doc ) { doc = doc || document; - var i, + var i, val, script = doc.createElement( "script" ); script.text = code; if ( node ) { for ( i in preservedScriptAttributes ) { - if ( node[ i ] ) { - script[ i ] = node[ i ]; + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); } } } @@ -129,7 +142,7 @@ function toType( obj ) { var - version = "3.3.1", + version = "3.4.1", // Define a local copy of jQuery jQuery = function( selector, context ) { @@ -258,25 +271,28 @@ jQuery.extend = jQuery.fn.extend = function() { // Extend the base object for ( name in options ) { - src = target[ name ]; copy = options[ name ]; + // Prevent Object.prototype pollution // Prevent never-ending loop - if ( target === copy ) { + if ( name === "__proto__" || target === copy ) { continue; } // Recurse if we're merging plain objects or arrays if ( deep && copy && ( jQuery.isPlainObject( copy ) || ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; - if ( copyIsArray ) { - copyIsArray = false; - clone = src && Array.isArray( src ) ? src : []; - + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; } else { - clone = src && jQuery.isPlainObject( src ) ? src : {}; + clone = src; } + copyIsArray = false; // Never move original objects, clone them target[ name ] = jQuery.extend( deep, clone, copy ); @@ -329,9 +345,6 @@ jQuery.extend( { }, isEmptyObject: function( obj ) { - - /* eslint-disable no-unused-vars */ - // See https://github.com/eslint/eslint/issues/6125 var name; for ( name in obj ) { @@ -341,8 +354,8 @@ jQuery.extend( { }, // Evaluates a script in a global context - globalEval: function( code ) { - DOMEval( code ); + globalEval: function( code, options ) { + DOMEval( code, { nonce: options && options.nonce } ); }, each: function( obj, callback ) { @@ -498,14 +511,14 @@ function isArrayLike( obj ) { } var Sizzle = /*! - * Sizzle CSS Selector Engine v2.3.3 + * Sizzle CSS Selector Engine v2.3.4 * https://sizzlejs.com/ * - * Copyright jQuery Foundation and other contributors + * Copyright JS Foundation and other contributors * Released under the MIT license - * http://jquery.org/license + * https://js.foundation/ * - * Date: 2016-08-08 + * Date: 2019-04-08 */ (function( window ) { @@ -539,6 +552,7 @@ var i, classCache = createCache(), tokenCache = createCache(), compilerCache = createCache(), + nonnativeSelectorCache = createCache(), sortOrder = function( a, b ) { if ( a === b ) { hasDuplicate = true; @@ -600,8 +614,7 @@ var i, rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), - - rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), + rdescend = new RegExp( whitespace + "|>" ), rpseudo = new RegExp( pseudos ), ridentifier = new RegExp( "^" + identifier + "$" ), @@ -622,6 +635,7 @@ var i, whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) }, + rhtml = /HTML$/i, rinputs = /^(?:input|select|textarea|button)$/i, rheader = /^h\d$/i, @@ -676,9 +690,9 @@ var i, setDocument(); }, - disabledAncestor = addCombinator( + inDisabledFieldset = addCombinator( function( elem ) { - return elem.disabled === true && ("form" in elem || "label" in elem); + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; }, { dir: "parentNode", next: "legend" } ); @@ -791,18 +805,22 @@ function Sizzle( selector, context, results, seed ) { // Take advantage of querySelectorAll if ( support.qsa && - !compilerCache[ selector + " " ] && - (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { - - if ( nodeType !== 1 ) { - newContext = context; - newSelector = selector; + !nonnativeSelectorCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) && - // qSA looks outside Element context, which is not what we want - // Thanks to Andrew Dupont for this workaround technique - // Support: IE <=8 + // Support: IE 8 only // Exclude object elements - } else if ( context.nodeName.toLowerCase() !== "object" ) { + (nodeType !== 1 || context.nodeName.toLowerCase() !== "object") ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && rdescend.test( selector ) ) { // Capture the context ID, setting it first if necessary if ( (nid = context.getAttribute( "id" )) ) { @@ -824,17 +842,16 @@ function Sizzle( selector, context, results, seed ) { context; } - if ( newSelector ) { - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); - return results; - } catch ( qsaError ) { - } finally { - if ( nid === expando ) { - context.removeAttribute( "id" ); - } + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); } } } @@ -998,7 +1015,7 @@ function createDisabledPseudo( disabled ) { // Where there is no isDisabled, check manually /* jshint -W018 */ elem.isDisabled !== !disabled && - disabledAncestor( elem ) === disabled; + inDisabledFieldset( elem ) === disabled; } return elem.disabled === disabled; @@ -1055,10 +1072,13 @@ support = Sizzle.support = {}; * @returns {Boolean} True iff elem is a non-HTML XML node */ isXML = Sizzle.isXML = function( elem ) { - // documentElement is verified for cases where it doesn't yet exist - // (such as loading iframes in IE - #4833) - var documentElement = elem && (elem.ownerDocument || elem).documentElement; - return documentElement ? documentElement.nodeName !== "HTML" : false; + var namespace = elem.namespaceURI, + docElem = (elem.ownerDocument || elem).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); }; /** @@ -1480,11 +1500,8 @@ Sizzle.matchesSelector = function( elem, expr ) { setDocument( elem ); } - // Make sure that attribute selectors are quoted - expr = expr.replace( rattributeQuotes, "='$1']" ); - if ( support.matchesSelector && documentIsHTML && - !compilerCache[ expr + " " ] && + !nonnativeSelectorCache[ expr + " " ] && ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { @@ -1498,7 +1515,9 @@ Sizzle.matchesSelector = function( elem, expr ) { elem.document && elem.document.nodeType !== 11 ) { return ret; } - } catch (e) {} + } catch (e) { + nonnativeSelectorCache( expr, true ); + } } return Sizzle( expr, document, null, [ elem ] ).length > 0; @@ -1957,7 +1976,7 @@ Expr = Sizzle.selectors = { "contains": markFunction(function( text ) { text = text.replace( runescape, funescape ); return function( elem ) { - return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; + return ( elem.textContent || getText( elem ) ).indexOf( text ) > -1; }; }), @@ -2096,7 +2115,11 @@ Expr = Sizzle.selectors = { }), "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { - var i = argument < 0 ? argument + length : argument; + var i = argument < 0 ? + argument + length : + argument > length ? + length : + argument; for ( ; --i >= 0; ) { matchIndexes.push( i ); } @@ -3146,18 +3169,18 @@ jQuery.each( { return siblings( elem.firstChild ); }, contents: function( elem ) { - if ( nodeName( elem, "iframe" ) ) { - return elem.contentDocument; - } + if ( typeof elem.contentDocument !== "undefined" ) { + return elem.contentDocument; + } - // Support: IE 9 - 11 only, iOS 7 only, Android Browser <=4.3 only - // Treat the template element as a regular one in browsers that - // don't support it. - if ( nodeName( elem, "template" ) ) { - elem = elem.content || elem; - } + // Support: IE 9 - 11 only, iOS 7 only, Android Browser <=4.3 only + // Treat the template element as a regular one in browsers that + // don't support it. + if ( nodeName( elem, "template" ) ) { + elem = elem.content || elem; + } - return jQuery.merge( [], elem.childNodes ); + return jQuery.merge( [], elem.childNodes ); } }, function( name, fn ) { jQuery.fn[ name ] = function( until, selector ) { @@ -4466,6 +4489,26 @@ var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; +var documentElement = document.documentElement; + + + + var isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ); + }, + composed = { composed: true }; + + // Support: IE 9 - 11+, Edge 12 - 18+, iOS 10.0 - 10.2 only + // Check attachment across shadow DOM boundaries when possible (gh-3504) + // Support: iOS 10.0-10.2 only + // Early iOS 10 versions support `attachShadow` but not `getRootNode`, + // leading to errors. We need to check for `getRootNode`. + if ( documentElement.getRootNode ) { + isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ) || + elem.getRootNode( composed ) === elem.ownerDocument; + }; + } var isHiddenWithinTree = function( elem, el ) { // isHiddenWithinTree might be called from jQuery#filter function; @@ -4480,7 +4523,7 @@ var isHiddenWithinTree = function( elem, el ) { // Support: Firefox <=43 - 45 // Disconnected elements can have computed display: none, so first confirm that elem is // in the document. - jQuery.contains( elem.ownerDocument, elem ) && + isAttached( elem ) && jQuery.css( elem, "display" ) === "none"; }; @@ -4522,7 +4565,8 @@ function adjustCSS( elem, prop, valueParts, tween ) { unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), // Starting value computation is required for potential unit mismatches - initialInUnit = ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + initialInUnit = elem.nodeType && + ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && rcssNum.exec( jQuery.css( elem, prop ) ); if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { @@ -4669,7 +4713,7 @@ jQuery.fn.extend( { } ); var rcheckableType = ( /^(?:checkbox|radio)$/i ); -var rtagName = ( /<([a-z][^\/\0>\x20\t\r\n\f]+)/i ); +var rtagName = ( /<([a-z][^\/\0>\x20\t\r\n\f]*)/i ); var rscriptType = ( /^$|^module$|\/(?:java|ecma)script/i ); @@ -4741,7 +4785,7 @@ function setGlobalEval( elems, refElements ) { var rhtml = /<|&#?\w+;/; function buildFragment( elems, context, scripts, selection, ignored ) { - var elem, tmp, tag, wrap, contains, j, + var elem, tmp, tag, wrap, attached, j, fragment = context.createDocumentFragment(), nodes = [], i = 0, @@ -4805,13 +4849,13 @@ function buildFragment( elems, context, scripts, selection, ignored ) { continue; } - contains = jQuery.contains( elem.ownerDocument, elem ); + attached = isAttached( elem ); // Append to fragment tmp = getAll( fragment.appendChild( elem ), "script" ); // Preserve script evaluation history - if ( contains ) { + if ( attached ) { setGlobalEval( tmp ); } @@ -4854,8 +4898,6 @@ function buildFragment( elems, context, scripts, selection, ignored ) { div.innerHTML = ""; support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; } )(); -var documentElement = document.documentElement; - var @@ -4871,8 +4913,19 @@ function returnFalse() { return false; } +// Support: IE <=9 - 11+ +// focus() and blur() are asynchronous, except when they are no-op. +// So expect focus to be synchronous when the element is already active, +// and blur to be synchronous when the element is not already active. +// (focus and blur are always synchronous in other supported browsers, +// this just defines when we can count on it). +function expectSync( elem, type ) { + return ( elem === safeActiveElement() ) === ( type === "focus" ); +} + // Support: IE <=9 only -// See #13393 for more info +// Accessing document.activeElement can throw unexpectedly +// https://bugs.jquery.com/ticket/13393 function safeActiveElement() { try { return document.activeElement; @@ -5172,9 +5225,10 @@ jQuery.event = { while ( ( handleObj = matched.handlers[ j++ ] ) && !event.isImmediatePropagationStopped() ) { - // Triggered event must either 1) have no namespace, or 2) have namespace(s) - // a subset or equal to those in the bound event (both can have no namespace). - if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) { + // If the event is namespaced, then each handler is only invoked if it is + // specially universal or its namespaces are a superset of the event's. + if ( !event.rnamespace || handleObj.namespace === false || + event.rnamespace.test( handleObj.namespace ) ) { event.handleObj = handleObj; event.data = handleObj.data; @@ -5298,39 +5352,51 @@ jQuery.event = { // Prevent triggered image.load events from bubbling to window.load noBubble: true }, - focus: { + click: { - // Fire native event if possible so blur/focus sequence is correct - trigger: function() { - if ( this !== safeActiveElement() && this.focus ) { - this.focus(); - return false; - } - }, - delegateType: "focusin" - }, - blur: { - trigger: function() { - if ( this === safeActiveElement() && this.blur ) { - this.blur(); - return false; + // Utilize native event to ensure correct state for checkable inputs + setup: function( data ) { + + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Claim the first handler + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + // dataPriv.set( el, "click", ... ) + leverageNative( el, "click", returnTrue ); } + + // Return false to allow normal processing in the caller + return false; }, - delegateType: "focusout" - }, - click: { + trigger: function( data ) { - // For checkbox, fire native event so checked state will be right - trigger: function() { - if ( this.type === "checkbox" && this.click && nodeName( this, "input" ) ) { - this.click(); - return false; + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Force setup before triggering a click + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + leverageNative( el, "click" ); } + + // Return non-false to allow normal event-path propagation + return true; }, - // For cross-browser consistency, don't fire native .click() on links + // For cross-browser consistency, suppress native .click() on links + // Also prevent it if we're currently inside a leveraged native-event stack _default: function( event ) { - return nodeName( event.target, "a" ); + var target = event.target; + return rcheckableType.test( target.type ) && + target.click && nodeName( target, "input" ) && + dataPriv.get( target, "click" ) || + nodeName( target, "a" ); } }, @@ -5347,6 +5413,93 @@ jQuery.event = { } }; +// Ensure the presence of an event listener that handles manually-triggered +// synthetic events by interrupting progress until reinvoked in response to +// *native* events that it fires directly, ensuring that state changes have +// already occurred before other listeners are invoked. +function leverageNative( el, type, expectSync ) { + + // Missing expectSync indicates a trigger call, which must force setup through jQuery.event.add + if ( !expectSync ) { + if ( dataPriv.get( el, type ) === undefined ) { + jQuery.event.add( el, type, returnTrue ); + } + return; + } + + // Register the controller as a special universal handler for all event namespaces + dataPriv.set( el, type, false ); + jQuery.event.add( el, type, { + namespace: false, + handler: function( event ) { + var notAsync, result, + saved = dataPriv.get( this, type ); + + if ( ( event.isTrigger & 1 ) && this[ type ] ) { + + // Interrupt processing of the outer synthetic .trigger()ed event + // Saved data should be false in such cases, but might be a leftover capture object + // from an async native handler (gh-4350) + if ( !saved.length ) { + + // Store arguments for use when handling the inner native event + // There will always be at least one argument (an event object), so this array + // will not be confused with a leftover capture object. + saved = slice.call( arguments ); + dataPriv.set( this, type, saved ); + + // Trigger the native event and capture its result + // Support: IE <=9 - 11+ + // focus() and blur() are asynchronous + notAsync = expectSync( this, type ); + this[ type ](); + result = dataPriv.get( this, type ); + if ( saved !== result || notAsync ) { + dataPriv.set( this, type, false ); + } else { + result = {}; + } + if ( saved !== result ) { + + // Cancel the outer synthetic event + event.stopImmediatePropagation(); + event.preventDefault(); + return result.value; + } + + // If this is an inner synthetic event for an event with a bubbling surrogate + // (focus or blur), assume that the surrogate already propagated from triggering the + // native event and prevent that from happening again here. + // This technically gets the ordering wrong w.r.t. to `.trigger()` (in which the + // bubbling surrogate propagates *after* the non-bubbling base), but that seems + // less bad than duplication. + } else if ( ( jQuery.event.special[ type ] || {} ).delegateType ) { + event.stopPropagation(); + } + + // If this is a native event triggered above, everything is now in order + // Fire an inner synthetic event with the original arguments + } else if ( saved.length ) { + + // ...and capture the result + dataPriv.set( this, type, { + value: jQuery.event.trigger( + + // Support: IE <=9 - 11+ + // Extend with the prototype to reset the above stopImmediatePropagation() + jQuery.extend( saved[ 0 ], jQuery.Event.prototype ), + saved.slice( 1 ), + this + ) + } ); + + // Abort handling of the native event + event.stopImmediatePropagation(); + } + } + } ); +} + jQuery.removeEvent = function( elem, type, handle ) { // This "if" is needed for plain objects @@ -5459,6 +5612,7 @@ jQuery.each( { shiftKey: true, view: true, "char": true, + code: true, charCode: true, key: true, keyCode: true, @@ -5505,6 +5659,33 @@ jQuery.each( { } }, jQuery.event.addProp ); +jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateType ) { + jQuery.event.special[ type ] = { + + // Utilize native event if possible so blur/focus sequence is correct + setup: function() { + + // Claim the first handler + // dataPriv.set( this, "focus", ... ) + // dataPriv.set( this, "blur", ... ) + leverageNative( this, type, expectSync ); + + // Return false to allow normal processing in the caller + return false; + }, + trigger: function() { + + // Force setup before trigger + leverageNative( this, type ); + + // Return non-false to allow normal event-path propagation + return true; + }, + + delegateType: delegateType + }; +} ); + // Create mouseenter/leave events using mouseover/out and event-time checks // so that event delegation works in jQuery. // Do the same for pointerenter/pointerleave and pointerover/pointerout @@ -5755,11 +5936,13 @@ function domManip( collection, args, callback, ignored ) { if ( node.src && ( node.type || "" ).toLowerCase() !== "module" ) { // Optional AJAX dependency, but won't run scripts if not present - if ( jQuery._evalUrl ) { - jQuery._evalUrl( node.src ); + if ( jQuery._evalUrl && !node.noModule ) { + jQuery._evalUrl( node.src, { + nonce: node.nonce || node.getAttribute( "nonce" ) + } ); } } else { - DOMEval( node.textContent.replace( rcleanScript, "" ), doc, node ); + DOMEval( node.textContent.replace( rcleanScript, "" ), node, doc ); } } } @@ -5781,7 +5964,7 @@ function remove( elem, selector, keepData ) { } if ( node.parentNode ) { - if ( keepData && jQuery.contains( node.ownerDocument, node ) ) { + if ( keepData && isAttached( node ) ) { setGlobalEval( getAll( node, "script" ) ); } node.parentNode.removeChild( node ); @@ -5799,7 +5982,7 @@ jQuery.extend( { clone: function( elem, dataAndEvents, deepDataAndEvents ) { var i, l, srcElements, destElements, clone = elem.cloneNode( true ), - inPage = jQuery.contains( elem.ownerDocument, elem ); + inPage = isAttached( elem ); // Fix IE cloning issues if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && @@ -6095,8 +6278,10 @@ var rboxStyle = new RegExp( cssExpand.join( "|" ), "i" ); // Support: IE 9 only // Detect overflow:scroll screwiness (gh-3699) + // Support: Chrome <=64 + // Don't get tricked when zoom affects offsetWidth (gh-4029) div.style.position = "absolute"; - scrollboxSizeVal = div.offsetWidth === 36 || "absolute"; + scrollboxSizeVal = roundPixelMeasures( div.offsetWidth / 3 ) === 12; documentElement.removeChild( container ); @@ -6167,7 +6352,7 @@ function curCSS( elem, name, computed ) { if ( computed ) { ret = computed.getPropertyValue( name ) || computed[ name ]; - if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { + if ( ret === "" && !isAttached( elem ) ) { ret = jQuery.style( elem, name ); } @@ -6223,30 +6408,13 @@ function addGetHookIf( conditionFn, hookFn ) { } -var +var cssPrefixes = [ "Webkit", "Moz", "ms" ], + emptyStyle = document.createElement( "div" ).style, + vendorProps = {}; - // Swappable if display is none or starts with table - // except "table", "table-cell", or "table-caption" - // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display - rdisplayswap = /^(none|table(?!-c[ea]).+)/, - rcustomProp = /^--/, - cssShow = { position: "absolute", visibility: "hidden", display: "block" }, - cssNormalTransform = { - letterSpacing: "0", - fontWeight: "400" - }, - - cssPrefixes = [ "Webkit", "Moz", "ms" ], - emptyStyle = document.createElement( "div" ).style; - -// Return a css property mapped to a potentially vendor prefixed property +// Return a vendor-prefixed property or undefined function vendorPropName( name ) { - // Shortcut for names that are not vendor prefixed - if ( name in emptyStyle ) { - return name; - } - // Check for vendor prefixed names var capName = name[ 0 ].toUpperCase() + name.slice( 1 ), i = cssPrefixes.length; @@ -6259,16 +6427,33 @@ function vendorPropName( name ) { } } -// Return a property mapped along what jQuery.cssProps suggests or to -// a vendor prefixed property. +// Return a potentially-mapped jQuery.cssProps or vendor prefixed property function finalPropName( name ) { - var ret = jQuery.cssProps[ name ]; - if ( !ret ) { - ret = jQuery.cssProps[ name ] = vendorPropName( name ) || name; + var final = jQuery.cssProps[ name ] || vendorProps[ name ]; + + if ( final ) { + return final; } - return ret; + if ( name in emptyStyle ) { + return name; + } + return vendorProps[ name ] = vendorPropName( name ) || name; } + +var + + // Swappable if display is none or starts with table + // except "table", "table-cell", or "table-caption" + // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + rcustomProp = /^--/, + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: "0", + fontWeight: "400" + }; + function setPositiveNumber( elem, value, subtract ) { // Any relative (+/-) values have already been @@ -6340,7 +6525,10 @@ function boxModelAdjustment( elem, dimension, box, isBorderBox, styles, computed delta - extra - 0.5 - ) ); + + // If offsetWidth/offsetHeight is unknown, then we can't determine content-box scroll gutter + // Use an explicit zero to avoid NaN (gh-3964) + ) ) || 0; } return delta; @@ -6350,9 +6538,16 @@ function getWidthOrHeight( elem, dimension, extra ) { // Start with computed style var styles = getStyles( elem ), + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-4322). + // Fake content-box until we know it's needed to know the true value. + boxSizingNeeded = !support.boxSizingReliable() || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + valueIsBorderBox = isBorderBox, + val = curCSS( elem, dimension, styles ), - isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box", - valueIsBorderBox = isBorderBox; + offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ); // Support: Firefox <=54 // Return a confounding non-pixel value or feign ignorance, as appropriate. @@ -6363,22 +6558,29 @@ function getWidthOrHeight( elem, dimension, extra ) { val = "auto"; } - // Check for style in case a browser which returns unreliable values - // for getComputedStyle silently falls back to the reliable elem.style - valueIsBorderBox = valueIsBorderBox && - ( support.boxSizingReliable() || val === elem.style[ dimension ] ); // Fall back to offsetWidth/offsetHeight when value is "auto" // This happens for inline elements with no explicit setting (gh-3571) // Support: Android <=4.1 - 4.3 only // Also use offsetWidth/offsetHeight for misreported inline dimensions (gh-3602) - if ( val === "auto" || - !parseFloat( val ) && jQuery.css( elem, "display", false, styles ) === "inline" ) { + // Support: IE 9-11 only + // Also use offsetWidth/offsetHeight for when box sizing is unreliable + // We use getClientRects() to check for hidden/disconnected. + // In those cases, the computed value can be trusted to be border-box + if ( ( !support.boxSizingReliable() && isBorderBox || + val === "auto" || + !parseFloat( val ) && jQuery.css( elem, "display", false, styles ) === "inline" ) && + elem.getClientRects().length ) { - val = elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ]; + isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; - // offsetWidth/offsetHeight provide border-box values - valueIsBorderBox = true; + // Where available, offsetWidth/offsetHeight approximate border box dimensions. + // Where not available (e.g., SVG), assume unreliable box-sizing and interpret the + // retrieved value as a content box dimension. + valueIsBorderBox = offsetProp in elem; + if ( valueIsBorderBox ) { + val = elem[ offsetProp ]; + } } // Normalize "" and auto @@ -6424,6 +6626,13 @@ jQuery.extend( { "flexGrow": true, "flexShrink": true, "fontWeight": true, + "gridArea": true, + "gridColumn": true, + "gridColumnEnd": true, + "gridColumnStart": true, + "gridRow": true, + "gridRowEnd": true, + "gridRowStart": true, "lineHeight": true, "opacity": true, "order": true, @@ -6479,7 +6688,9 @@ jQuery.extend( { } // If a number was passed in, add the unit (except for certain CSS properties) - if ( type === "number" ) { + // The isCustomProp check can be removed in jQuery 4.0 when we only auto-append + // "px" to a few hardcoded values. + if ( type === "number" && !isCustomProp ) { value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? "" : "px" ); } @@ -6579,18 +6790,29 @@ jQuery.each( [ "height", "width" ], function( i, dimension ) { set: function( elem, value, extra ) { var matches, styles = getStyles( elem ), - isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box", - subtract = extra && boxModelAdjustment( - elem, - dimension, - extra, - isBorderBox, - styles - ); + + // Only read styles.position if the test has a chance to fail + // to avoid forcing a reflow. + scrollboxSizeBuggy = !support.scrollboxSize() && + styles.position === "absolute", + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-3991) + boxSizingNeeded = scrollboxSizeBuggy || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + subtract = extra ? + boxModelAdjustment( + elem, + dimension, + extra, + isBorderBox, + styles + ) : + 0; // Account for unreliable border-box dimensions by comparing offset* to computed and // faking a content-box to get border and padding (gh-3699) - if ( isBorderBox && support.scrollboxSize() === styles.position ) { + if ( isBorderBox && scrollboxSizeBuggy ) { subtract -= Math.ceil( elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] - parseFloat( styles[ dimension ] ) - @@ -6758,9 +6980,9 @@ Tween.propHooks = { // Use .style if available and use plain properties where available. if ( jQuery.fx.step[ tween.prop ] ) { jQuery.fx.step[ tween.prop ]( tween ); - } else if ( tween.elem.nodeType === 1 && - ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || - jQuery.cssHooks[ tween.prop ] ) ) { + } else if ( tween.elem.nodeType === 1 && ( + jQuery.cssHooks[ tween.prop ] || + tween.elem.style[ finalPropName( tween.prop ) ] != null ) ) { jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); } else { tween.elem[ tween.prop ] = tween.now; @@ -8467,6 +8689,10 @@ jQuery.param = function( a, traditional ) { encodeURIComponent( value == null ? "" : value ); }; + if ( a == null ) { + return ""; + } + // If an array was passed in, assume that it is an array of form elements. if ( Array.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { @@ -8969,12 +9195,14 @@ jQuery.extend( { if ( !responseHeaders ) { responseHeaders = {}; while ( ( match = rheaders.exec( responseHeadersString ) ) ) { - responseHeaders[ match[ 1 ].toLowerCase() ] = match[ 2 ]; + responseHeaders[ match[ 1 ].toLowerCase() + " " ] = + ( responseHeaders[ match[ 1 ].toLowerCase() + " " ] || [] ) + .concat( match[ 2 ] ); } } - match = responseHeaders[ key.toLowerCase() ]; + match = responseHeaders[ key.toLowerCase() + " " ]; } - return match == null ? null : match; + return match == null ? null : match.join( ", " ); }, // Raw string @@ -9363,7 +9591,7 @@ jQuery.each( [ "get", "post" ], function( i, method ) { } ); -jQuery._evalUrl = function( url ) { +jQuery._evalUrl = function( url, options ) { return jQuery.ajax( { url: url, @@ -9373,7 +9601,16 @@ jQuery._evalUrl = function( url ) { cache: true, async: false, global: false, - "throws": true + + // Only evaluate the response if it is successful (gh-4126) + // dataFilter is not invoked for failure responses, so using it instead + // of the default converter is kludgy but it works. + converters: { + "text script": function() {} + }, + dataFilter: function( response ) { + jQuery.globalEval( response, options ); + } } ); }; @@ -9656,24 +9893,21 @@ jQuery.ajaxPrefilter( "script", function( s ) { // Bind script tag hack transport jQuery.ajaxTransport( "script", function( s ) { - // This transport only deals with cross domain requests - if ( s.crossDomain ) { + // This transport only deals with cross domain or forced-by-attrs requests + if ( s.crossDomain || s.scriptAttrs ) { var script, callback; return { send: function( _, complete ) { - script = jQuery( " + + + + + +
+
NOT FOUND
+ +
+ + diff --git a/addon/reference.js b/addon/reference.js new file mode 100644 index 00000000..08029ae0 --- /dev/null +++ b/addon/reference.js @@ -0,0 +1,44 @@ +import {send} from "./proxy.js"; +import {Node} from "./storage_entities.js"; +import {systemInitialization} from "./bookmarks_init.js"; +import {updateTabURL} from "./utils_browser.js"; +import {removeFromBookmarksToolbar} from "./bookmarking.js"; + +async function openReference(tab) { + await systemInitialization; + + let url = decodeURIComponent(new URL(tab.url).hash.substr(1)); + + if (url && url.startsWith("ext+scrapyard:")) { + let id = /ext\+scrapyard:\/\/([^#/]+)/i.exec(url)[1]; + + switch (id) { + case "advanced": + return updateTabURL(tab, browser.runtime.getURL("ui/options.html#advanced"), false); + } + + let [prefix, uuid] = id.includes(":")? id.split(":"): [null, id]; + let node = await Node.getByUUID(uuid); + + if (node) { + if (!prefix) + send.browseNode({node: node, tab: tab}); + else + switch (prefix) { + case "notes": + send.browseNotes({uuid: node.uuid, tab: tab}); + break; + } + } + else { + const notFoundWrapperDiv = document.getElementById("not-found-wrapper"); + notFoundWrapperDiv.style.display = "block"; + + if (location.search.includes("toolbar")) + await removeFromBookmarksToolbar(uuid); + } + } +} + +browser.tabs.getCurrent().then(openReference); +window.onhashchange = () => browser.tabs.getCurrent().then(openReference); diff --git a/addon/runtime_listeners.js b/addon/runtime_listeners.js new file mode 100644 index 00000000..cee62a22 --- /dev/null +++ b/addon/runtime_listeners.js @@ -0,0 +1,39 @@ +const SCRAPYARD_SETTINGS_KEY = "scrapyard-settings"; +const INSTALL_SETTINGS_KEY = "install-settings"; + +browser.runtime.onInstalled.addListener(async details => { + if (details.reason === "install") { + await writeInstallVersion(); + } + else if (details.reason === "update") { + const scrapyardSettings = (await browser.storage.local.get(SCRAPYARD_SETTINGS_KEY))?.[SCRAPYARD_SETTINGS_KEY] || {}; + await setAnnouncement(scrapyardSettings); + await setTransitionToDiskFlag(scrapyardSettings); + } +}); + +function writeInstallVersion() { + return browser.storage.local.set({[INSTALL_SETTINGS_KEY]: { + install_date: Date.now(), + install_version: _ADDON_VERSION + }}); +} + +async function setAnnouncement(scrapyardSettings) { + if (/^\d+\.\d+$/.test(_ADDON_VERSION)) { + scrapyardSettings["pending_announcement"] = "/ui/options.html#about"; + return browser.storage.local.set({[SCRAPYARD_SETTINGS_KEY]: scrapyardSettings}); + } +} + +async function setTransitionToDiskFlag(scrapyardSettings) { + const installSettings = (await browser.storage.local.get(INSTALL_SETTINGS_KEY))?.[INSTALL_SETTINGS_KEY]; + const installVersionMajor = installSettings?.install_version?.split(".")?.[0]; + const updatedToV2 = !installVersionMajor || parseInt(installVersionMajor) < 2; + const transitionToDisk = scrapyardSettings["transition_to_disk"]; + + if (updatedToV2 && transitionToDisk !== false) { + scrapyardSettings["transition_to_disk"] = true; + return browser.storage.local.set({[SCRAPYARD_SETTINGS_KEY]: scrapyardSettings}); + } +} diff --git a/addon/savepage/background.js b/addon/savepage/background.js new file mode 100644 index 00000000..c78c3c6b --- /dev/null +++ b/addon/savepage/background.js @@ -0,0 +1,1025 @@ +/************************************************************************/ +/* */ +/* Save Page WE - Generic WebExtension - Background Page */ +/* */ +/* Javascript for Background Page */ +/* */ +/* Last Edit - 19 Apr 2022 */ +/* */ +/* Copyright (C) 2016-2022 DW-dev */ +/* */ +/* Distributed under the GNU General Public License version 2 */ +/* See LICENCE.txt file and http://www.gnu.org/licenses/ */ +/* */ +/************************************************************************/ + +/************************************************************************/ +/* */ +/* Refer to Google Chrome developer documentation: */ +/* */ +/* https://developer.chrome.com/extensions/overview */ +/* https://developer.chrome.com/extensions/content_scripts */ +/* https://developer.chrome.com/extensions/messaging */ +/* https://developer.chrome.com/extensions/xhr */ +/* https://developer.chrome.com/extensions/contentSecurityPolicy */ +/* */ +/* https://developer.chrome.com/extensions/manifest */ +/* https://developer.chrome.com/extensions/declare_permissions */ +/* https://developer.chrome.com/extensions/match_patterns */ +/* */ +/* https://developer.chrome.com/extensions/browserAction */ +/* https://developer.chrome.com/extensions/contextMenus */ +/* https://developer.chrome.com/extensions/downloads */ +/* https://developer.chrome.com/extensions/notifications */ +/* https://developer.chrome.com/extensions/runtime */ +/* https://developer.chrome.com/extensions/storage */ +/* https://developer.chrome.com/extensions/tabs */ +/* */ +/* Refer to IETF data uri and mime type documentation: */ +/* */ +/* RFC 2397 - https://tools.ietf.org/html/rfc2397 */ +/* RFC 2045 - https://tools.ietf.org/html/rfc2045 */ +/* */ +/************************************************************************/ + +/************************************************************************/ +/* */ +/* Notes on Save Page WE Operation */ +/* */ +/* 1. The basic approach is to identify all frames in the page and */ +/* then traverse the DOM tree in three passes. */ +/* */ +/* 2. The current states of the HTML elements are extracted from */ +/* the DOM tree. External resources are downloaded and scanned. */ +/* */ +/* 3. A content script in each frame finds and sets keys on all */ +/* sub-frame elements that are reachable from that frame. */ +/* */ +/* 4. The first pass gathers external style sheet resources: */ +/* */ +/* - end tags that may appear inside CSS strings */ + + csstext = csstext.replace(/<\/style>/gi,"<\\/style>"); + + baseuri = element.href; + + documenturi = element.href; + + textContent = replaceCSSURLsInStyleSheet(csstext,baseuri,documenturi,[location],framekey); + + if (swapDevices) textContent = swapScreenAndPrintDevices(textContent); + + // Scrapyard ////////////////////////////////////////////////////////////////// + // TODO: deprecate save unpacked + if (saveUnpacked) { + origurl = element.getAttribute("href"); + + datauri = `style_${styleCounter++}.css`; + + origstr = (datauri == origurl)? "": " data-savepage-href=\"" + origurl + "\""; + + startTag = startTag.replace(/ href="[^"]*"/, origstr + " href=\"" + datauri + "\""); + + await chrome.runtime.sendMessage({type: "saveResource", content: textContent, filename: datauri, node: scrapyardBookmark}); + + textContent = ""; + } + else { + + /* Converting into end tags that may appear inside CSS strings */ + + textContent = textContent.replace(/<\/style>/gi,"<\\/style>"); + + ////////////////////////////////////////////////////////////////// Scrapyard // + startTag = "`; + // htmlStrings[htmlStrings.length] = htmltext; + ////////////////////////////////////////////////////////////////// Scrapyard // + + /* Add first favicon from document head or if none add favicon from website root */ + + if (depth == 0 && (firstIconLocation != "" || rootIconLocation != "")) + { + baseuri = element.ownerDocument.baseURI; + + documenturi = element.ownerDocument.documentURI; + + location = (firstIconLocation != "") ? firstIconLocation : rootIconLocation; + + datauri = replaceURL(location,baseuri,documenturi); + + htmltext = prefix + ""; + + htmlStrings[htmlStrings.length] = htmltext; + } + } + else if (startTag != "") + { + if (pageType == 0 && formatHTML && depth == 0 && !inline && parentpreserve == 0) htmlStrings[htmlStrings.length] = newlineIndent(indent); + htmlStrings[htmlStrings.length] = startTag; + } + + if (element.localName == "style" || /* "; + + htmlStrings[htmlStrings.length] = htmltext; + } + + if (depth == 0) + { + /* Add shadow loader script */ + // Scrapyard ////////////////////////////////////////////////////////////////// + if (loadShadowDom) { + htmltext = prefix + ""; + + htmlStrings[htmlStrings.length] = htmltext; + } + /////////////////////////////////////////////////////////////////// Scrapyard / + + /* Add page info bar html, css and script */ + + if (includeInfoBar) + { + date = new Date(); + + pageurl = (pageType == 0) ? document.URL : document.querySelector("meta[name='savepage-url']").content; + + pageInfoBarText = pageInfoBarText.replace(/%URL%/,pageurl); + pageInfoBarText = pageInfoBarText.replace(/%DECODED-URL%/,decodeURIComponent(pageurl).replace(/'/g,"\\'")); + pageInfoBarText = pageInfoBarText.replace(/%DATE%/,date.toDateString().substr(8,2) + " " + date.toDateString().substr(4,3) + " " + date.toDateString().substr(11,4)); + pageInfoBarText = pageInfoBarText.replace(/%TIME%/,date.toTimeString().substr(0,8)); + + htmltext = prefix + ""; + + htmlStrings[htmlStrings.length] = htmltext; + } + + /* Add saved page information */ + + date = new Date(); + datestr = date.toString(); + + if ((pubelement = document.querySelector("meta[property='article:published_time'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Open Graph - ISO8601 */ + else if ((pubelement = document.querySelector("meta[property='datePublished'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Generic RDFa - ISO8601 */ + else if ((pubelement = document.querySelector("meta[itemprop='datePublished'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Microdata - ISO8601 */ + else if ((pubelement = document.querySelector("script[type='application/ld+json']")) != null) /* JSON-LD - ISO8601 */ + { + pubmatches = pubelement.textContent.match(/"datePublished"\s*:\s*"([^"]*)"/); + pubstr = pubmatches ? pubmatches[1] : null; + } + else if ((pubelement = document.querySelector("time[datetime]")) != null) pubstr = pubelement.getAttribute("datetime"); /* HTML5 - ISO8601 and similar formats */ + else pubstr = null; + + try + { + if (!pubstr) throw false; + pubmatches = pubstr.match(/(Z|(-|\+)\d\d:?\d\d)$/); + pubzone = pubmatches ? (pubmatches[1] == "Z" ? " GMT+0000" : " GMT" + pubmatches[1].replace(":","")) : ""; /* extract timezone */ + pubstr = pubstr.replace(/(Z|(-|\+)\d\d:?\d\d)$/,""); /* remove timezone */ + pubdate = new Date(pubstr); + pubdatestr = pubdate.toString(); + pubdatestr = pubdatestr.substr(0,24) + pubzone; + } + catch (e) { pubdatestr = "Unknown"; } + + if (savedItems == 0) + { + state = "Basic Items;"; + } + else if (savedItems == 1) + { + state = "Standard Items;"; + } + else if (savedItems == 2) + { + state = "Custom Items;"; + if (saveHTMLImagesAll) state += " HTML image files (all);"; + if (saveHTMLAudioVideo) state += " HTML audio & video files;"; + if (saveHTMLObjectEmbed) state += " HTML object & embed files;"; + if (saveCSSImagesAll) state += " CSS image files (all);"; + if (saveCSSFontsAll) state += " CSS font files (all);"; + else if (saveCSSFontsWoff) state += " CSS font files (woff for any browser);"; + if (saveScripts) state += " Scripts (in same-origin frames);"; + } + + if (retainCrossFrames) state += " Retain cross-origin frames;"; + if (mergeCSSImages) state += " Merge CSS images;"; + if (executeScripts) state += " Allow scripts to execute;"; + if (removeUnsavedURLs) state += " Remove unsaved URLs;"; + if (removeElements) state += " Remove hidden elements;"; + if (rehideElements) state += " Rehide hidden elements;"; + if (allowPassive) state += " Allow passive mixed content;"; + if (crossOrigin == 1) state += " Send referrer headers with origin and path;"; + + if (loadLazyContent) + { + state += " Load lazy content - "; + // Scrapyard ////////////////////////////////////////////////////////////////// + if (lazyLoadType == 1) state += "scroll steps = " + lazyLoadScrollTime + "s;"; + else if (lazyLoadType == 2) state += "shrink checks = " + lazyLoadShrinkTime + "s;"; + ////////////////////////////////////////////////////////////////// Scrapyard // + } + + if (loadLazyImages) state += " Load lazy images in existing content;"; + + state += " Max frame depth = " + maxFrameDepth + ";"; + state += " Max resource size = " + maxResourceSize + "MB;"; + state += " Max resource time = " + maxResourceTime + "s;"; + + pageurl = (pageType == 0) ? document.URL : document.querySelector("meta[name='savepage-url']").content; + + htmltext = prefix + ""; + htmltext += prefix + ""; + htmltext += prefix + ""; + htmltext += prefix + ""; + htmltext += prefix + ""; + htmltext += prefix + ""; + htmltext += prefix + ""; + // Scrapyard ////////////////////////////////////////////////////////////////// + //htmltext += prefix + ""; + ////////////////////////////////////////////////////////////////// Scrapyard // + + htmlStrings[htmlStrings.length] = htmltext; + } + + htmlStrings[htmlStrings.length] = newlineIndent(indent); + htmlStrings[htmlStrings.length] = endTag; + } + else if (endTag != "") + { + if (pageType == 0 && formatHTML && depth == 0 && !inline && preserve == 0 && element.children.length > 0) + { + htmlStrings[htmlStrings.length] = newlineIndent(indent); + } + + htmlStrings[htmlStrings.length] = endTag; + } + } +} + +function enumerateCSSInsetProperty(csstext) +{ + /* CSS inset property is supported by Firefox but not by Chrome */ + /* So enumerate inset property as top/right/bottom/left properties */ + + csstext = csstext.replace(/[{;]\s*inset\s*:\s*([^\s]+)\s*;/gi,"top: $1; right: $1; bottom: $1; left: $1;"); + csstext = csstext.replace(/[{;]\s*inset\s*:\s*([^\s]+)\s+([^\s]+)\s*;/gi,"top: $1; right: $2; bottom: $1; left: $2;"); + csstext = csstext.replace(/[{;]\s*inset\s*:\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s*;/gi,"top: $1; right: $2; bottom: $3; left: $2;"); + csstext = csstext.replace(/[{;]\s*inset\s*:\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s*;/gi,"top: $1; right: $2; bottom: $3; left: $4;"); + + return csstext; +} + +function replaceCSSURLsInStyleSheet(csstext,baseuri,documenturi,importstack,framekey) +{ + var regex; + var matches = new Array(); + + /* @import url() or */ + /* @font-face rule with font url()'s or */ + /* image url() or */ + /* avoid matches inside double-quote strings or */ + /* avoid matches inside single-quote strings or */ + /* avoid matches inside comments */ + + regex = new RegExp(/(?:( ?)@import\s*(?:url\(\s*)?((?:"[^"]+")|(?:'[^']+')|(?:[^\s);]+))(?:\s*\))?\s*;)|/.source + /* p1 & p2 */ + /(?:( ?)@font-face\s*({[^}]*}))|/.source + /* p3 & p4 */ + /(?:( ?)url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\))|/.source + /* p5 & p6 */ + /(?:"(?:\\"|[^"])*")|/.source + + /(?:'(?:\\'|[^'])*')|/.source + + /(?:\/\*(?:\*[^\/]|[^\*])*?\*\/)/.source, + "gi"); + + csstext = csstext.replace(regex,_replaceCSSURLOrImportStyleSheet); + + return csstext; + + function _replaceCSSURLOrImportStyleSheet(match,p1,p2,p3,p4,p5,p6,offset,string) + { + var i,location,csstext,newurl,datauriorcssvar,origstr,urlorvar; + + if (match.trim().substr(0,7).toLowerCase() == "@import") /* @import url() */ + { + p2 = removeQuotes(p2); + + if (replaceableResourceURL(p2)) + { + if (baseuri != null) + { + location = resolveURL(p2,baseuri); + + if (location != null) + { + location = removeFragment(location); + + for (i = 0; i < resourceLocation.length; i++) + if (resourceLocation[i] == location && resourceStatus[i] == "success") break; + + if (i < resourceLocation.length) /* style sheet found */ + { + if (importstack.indexOf(location) < 0) + { + importstack.push(location); + + csstext = replaceCSSURLsInStyleSheet(resourceContent[i],resourceLocation[i],resourceLocation[i],importstack,framekey); + + importstack.pop(); + + return p1 + "/*savepage-import-url=" + p2 + "*/" + p1 + csstext; + } + } + } + } + + if (removeUnsavedURLs) return p1 + "/*savepage-import-url=" + p2 + "*/" + p1; + else + { + newurl = adjustURL(p2,baseuri,documenturi); + + if (newurl != p2) + { + match = match.replace(p2,newurl); + match = match.replace(/(@import)/i,"/*savepage-import-url=" + p2 + "*/" + p1 + "$1"); + return match; + } + else return match; /* original @import rule */ + } + } + } + else if (match.trim().substr(0,10).toLowerCase() == "@font-face") /* @font-face rule */ + { + match = match.replace(/font-display\s*:\s*([^\s;}]*)\s*;?/gi,"/*savepage-font-display=$1*/"); /* remove font-display to avoid Chrome using fallback font */ + + regex = /( ?)url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; /* font url() */ + + return match.replace(regex,_replaceURL); + + function _replaceURL(match,p1,p2,offset,string) + { + var cssvar,datauri,origstr; + + p2 = removeQuotes(p2); + + if (replaceableResourceURL(p2)) + { + datauri = replaceURL(p2,baseuri,documenturi); + + origstr = (datauri == p2) ? p1 : p1 + "/*savepage-url=" + p2 + "*/" + p1; + + return origstr + "url(" + datauri + ")"; + } + else return match; /* unreplaceable - original font url() */ + } + } + else if (match.trim().substr(0,4).toLowerCase() == "url(") /* image url() */ + { + p6 = removeQuotes(p6); + + if (replaceableResourceURL(p6)) + { + datauriorcssvar = replaceCSSImageURL(p6,baseuri,documenturi,framekey); + + origstr = (datauriorcssvar == p6) ? p5 : p5 + "/*savepage-url=" + p6 + "*/" + p5; + + urlorvar = (datauriorcssvar.substr(0,2) == "--") ? "var" : "url"; + + return origstr + urlorvar + "(" + datauriorcssvar + ")"; + } + else return match; /* unreplaceable - original image url() */ + } + else if (match.substr(0,1) == "\"") return match; /* double-quote string */ + else if (match.substr(0,1) == "'") return match; /* single-quote string */ + else if (match.substr(0,2) == "/*") return match; /* comment */ + } +} + +function replaceCSSImageURLs(csstext,baseuri,documenturi,framekey) +{ + var regex; + + regex = /( ?)url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; /* image url() */ + + csstext = csstext.replace(regex,_replaceCSSImageURL); + + return csstext; + + function _replaceCSSImageURL(match,p1,p2,offset,string) + { + var datauriorcssvar,origstr,urlorvar; + + p2 = removeQuotes(p2); + + if (replaceableResourceURL(p2)) + { + datauriorcssvar = replaceCSSImageURL(p2,baseuri,documenturi,framekey); + + origstr = (datauriorcssvar == p2) ? p1 : p1 + "/*savepage-url=" + p2 + "*/" + p1; + + urlorvar = (datauriorcssvar.substr(0,2) == "--") ? "var" : "url"; + + return origstr + urlorvar + "(" + datauriorcssvar + ")"; + } + else return match; /* unreplaceable - original image url() */ + } +} + +function replaceCSSImageURL(url,baseuri,documenturi,framekey) +{ + var i,location,count,asciistring; + + if (pageType > 0) return url; /* saved page - ignore new resources when re-saving */ + + if (baseuri != null) + { + url = url.replace(/\\26 ?/g,"&"); /* remove CSS escape */ + url = url.replace(/\\3[Aa] ?/g,":"); /* remove CSS escape */ + url = url.replace(/\\3[Dd] ?/g,"="); /* remove CSS escape */ + + location = resolveURL(url,baseuri); + + if (location != null) + { + location = removeFragment(location); + + for (i = 0; i < resourceLocation.length; i++) + if (resourceLocation[i] == location && resourceStatus[i] == "success") break; + + if (i < resourceLocation.length) + { + if (resourceCharSet[i] == "") /* charset not defined - binary data */ + { + count = mergeCSSImages ? resourceRemembered[i]-resourceCSSRemembered[i]+Object.keys(resourceCSSFrameKeys[i]).length : resourceRemembered[i]; + + if (resourceContent[i].length*count <= maxResourceSize*1024*1024) /* skip large and/or repeated resource */ + { + if (mergeCSSImages) + { + if (resourceCSSFrameKeys[i][framekey] == true) + { + resourceReplaced[i]++; + + return "--savepage-url-" + i; + } + } + else + { + resourceReplaced[i]++; + + try { asciistring = btoa(resourceContent[i]); } + catch (e) { asciistring = ""; } /* resource content not a binary string */ + + // Scrapyard ////////////////////////////////////////////////////////////////// + if (saveUnpacked) + return resourceFileName[i] + else + ////////////////////////////////////////////////////////////////// Scrapyard // + return "data:" + resourceMimeType[i] + ";base64," + asciistring; /* binary data encoded as Base64 ASCII string */ + } + } + } + } + } + } + + return unsavedURL(url,baseuri,documenturi); /* unsaved url */ +} + +function replaceURL(url,baseuri,documenturi) +{ + var i,location,fragment,count,asciistring; + + if (pageType > 0) return url; /* saved page - ignore new resources when re-saving */ + + if (baseuri != null) + { + location = resolveURL(url,baseuri); + + if (location != null) + { + i = location.indexOf("#"); + + fragment = (i >= 0) ? location.substr(i) : ""; + + location = removeFragment(location); + + for (i = 0; i < resourceLocation.length; i++) + if (resourceLocation[i] == location && resourceStatus[i] == "success") break; + + if (i < resourceLocation.length) + { + if (resourceCharSet[i] == "") /* charset not defined - binary data */ + { + count = resourceRemembered[i]; + + if (resourceContent[i].length*count <= maxResourceSize*1024*1024) /* skip large and/or repeated resource */ + { + resourceReplaced[i]++; + + try { asciistring = btoa(resourceContent[i]); } + catch (e) { asciistring = ""; } /* resource content not a binary string */ + + // Scrapyard ////////////////////////////////////////////////////////////////// + if (saveUnpacked) + return resourceFileName[i]; + else + ////////////////////////////////////////////////////////////////// Scrapyard // + return "data:" + resourceMimeType[i] + ";base64," + asciistring + fragment; /* binary data encoded as Base64 ASCII string */ + } + } + else /* charset defined - character data */ + { + resourceReplaced[i]++; + // Scrapyard ////////////////////////////////////////////////////////////////// + if (saveUnpacked) + return resourceFileName[i]; + else + ////////////////////////////////////////////////////////////////// Scrapyard // + return "data:" + resourceMimeType[i] + ";charset=utf-8," + encodeURIComponent(resourceContent[i]) + fragment; /* characters encoded as UTF-8 %escaped string */ + } + } + } + } + + return unsavedURL(url,baseuri,documenturi); /* unsaved url */ +} + +function retrieveContent(url,baseuri) +{ + var i,location; + + if (pageType > 0) return ""; /* saved page - ignore new resources when re-saving */ + + if (baseuri != null) + { + location = resolveURL(url,baseuri); + + if (location != null) + { + location = removeFragment(location); + + for (i = 0; i < resourceLocation.length; i++) + if (resourceLocation[i] == location && resourceStatus[i] == "success") break; + + if (i < resourceLocation.length) + { + if (resourceCharSet[i] != "") /* charset defined - character data */ + { + resourceReplaced[i]++; + + return resourceContent[i]; + } + } + } + } + + return ""; /* empty string */ +} + +function adjustURL(url,baseuri,documenturi) +{ + var i,location; + + if (baseuri != null) + { + location = resolveURL(url,baseuri); + + if (location != null) + { + i = location.indexOf("#"); + + if (i < 0) /* without fragment */ + { + return location; /* same or different page - make absolute */ + } + else /* with fragment */ + { + if (location.substr(0,i) == documenturi) return location.substr(i); /* same page - make fragment only */ + else return location; /* different page - make absolute */ + } + } + } + + return url; +} + +function unsavedURL(url,baseuri,documenturi) +{ + if (removeUnsavedURLs) return ""; /* empty string */ + else return adjustURL(url,baseuri,documenturi); /* original or adjusted url */ +} + +function createCanvasDataURL(url,baseuri,documenturi,element) +{ + var canvas,context; + + canvas = document.createElement("canvas"); + canvas.width = element.clientWidth; + canvas.height = element.clientHeight; + + try + { + context = canvas.getContext("2d"); + context.drawImage(element,0,0,canvas.width,canvas.height); + return canvas.toDataURL("image/png",""); + } + catch (e) {} + + return unsavedURL(url,baseuri,documenturi); /* unsaved url */ +} + +function swapScreenAndPrintDevices(csstext) +{ + var regex; + + regex = /@media[^{]*{/gi; /* @media rule */ + + csstext = csstext.replace(regex,_replaceDevice); + + return csstext; + + function _replaceDevice(match,offset,string) + { + match = match.replace(/screen/gi,"######"); + match = match.replace(/print/gi,"screen"); + match = match.replace(/######/gi,"print"); + + return match; + } +} + +function newlineIndent(indent) +{ + var i,str; + + str = "\n"; + + for (i = 0; i < indent; i++) str += " "; + + return str; +} + +/************************************************************************/ + +/* View saved page information function */ + +function viewSavedPageInfo() +{ + var i,xhr,parser,pageinfodoc,container,metaurl,metatitle,metapubdate,metafrom,metadate,metastate,metaversion,metacomments; + + /* Load page info panel */ + + xhr = new XMLHttpRequest(); + xhr.open("GET",chrome.runtime.getURL("pageinfo-panel.html"),true); + xhr.onload = complete; + xhr.send(); + + function complete() + { + if (xhr.status == 200) + { + /* Parse page info document */ + + parser = new DOMParser(); + pageinfodoc = parser.parseFromString(xhr.responseText,"text/html"); + + /* Create container element */ + + container = document.createElement("div"); + container.setAttribute("id","savepage-pageinfo-panel-container"); + document.documentElement.appendChild(container); + + /* Append page info elements */ + + container.appendChild(pageinfodoc.getElementById("savepage-pageinfo-panel-overlay")); + + /* Add listeners for buttons */ + + document.getElementById("savepage-pageinfo-panel-open").addEventListener("click",clickOpenURL,false); + document.getElementById("savepage-pageinfo-panel-okay").addEventListener("click",clickOkay,false); + + /* Focus okay button */ + + document.getElementById("savepage-pageinfo-panel-okay").focus(); + + /* Populate page info contents */ + + metaurl = document.querySelector("meta[name='savepage-url']").content; + metatitle = document.querySelector("meta[name='savepage-title']").content; + metapubdate = document.querySelector("meta[name='savepage-pubdate']").content; + metafrom = document.querySelector("meta[name='savepage-from']").content; + metadate = document.querySelector("meta[name='savepage-date']").content; + metastate = document.querySelector("meta[name='savepage-state']").content; + metaversion = document.querySelector("meta[name='savepage-version']").content; + metacomments = ""; + + if (metaversion > +"8.0") metacomments = document.querySelector("meta[name='savepage-comments']").content; /* decodes HTML entities */ + + if (metaversion < +"6.0") metastate = metastate.replace(/(.*) (Max frame depth = \d+; Max resource size = \d+MB;) (.*)/,"$1 $3 $2"); + if (metaversion < +"7.0") metastate = metastate.replace(/CSS fonts used;/,"\n - " + "$&"); + + metastate = metastate.replace(/; /g,";\n"); + metastate = metastate.replace(/;/g,""); + metastate = metastate.replace(/Custom Items/,"$&:"); + metastate = metastate.replace(/HTML image files \(all\)/," - " + "$&"); + metastate = metastate.replace(/HTML audio & video files/," - " + "$&"); + metastate = metastate.replace(/HTML object & embed files/," - " + "$&"); + metastate = metastate.replace(/CSS image files \(all\)/," - " + "$&"); + metastate = metastate.replace(/CSS font files \(all\)/," - " + "$&"); + metastate = metastate.replace(/CSS font files \(woff for any browser\)/," - " + "$&"); + metastate = metastate.replace(/Scripts \(in same-origin frames\)/," - " + "$&"); + + if (document.querySelector("script[id='savepage-pageloader']") == null && /* Version 7.0-14.0 */ + document.querySelector("meta[name='savepage-resourceloader']") == null) /* Version 15.0-15.1 */ + { + metastate = metastate.replace(/Used page loader/,"$&" + " (Removed)"); + metastate = metastate.replace(/Used resource loader/,"$&" + " (Removed)"); + } + + metaversion = "Save Page WE " + metaversion; + + document.getElementById("savepage-pageinfo-panel-url").textContent = metaurl; + document.getElementById("savepage-pageinfo-panel-title").textContent = metatitle; + document.getElementById("savepage-pageinfo-panel-pubdate").textContent = metapubdate; + document.getElementById("savepage-pageinfo-panel-from").textContent = metafrom; + document.getElementById("savepage-pageinfo-panel-date").textContent = metadate; + document.getElementById("savepage-pageinfo-panel-state").textContent = metastate; + document.getElementById("savepage-pageinfo-panel-version").textContent = metaversion; + document.getElementById("savepage-pageinfo-panel-comments").children[0].value = metacomments; + } + } + + function clickOpenURL() + { + window.open(metaurl); + + document.documentElement.removeChild(document.getElementById("savepage-pageinfo-panel-container")); + + window.setTimeout(function() { chrome.runtime.sendMessage({ type: "setSaveState", savestate: -2 }); },1000); + } + + function clickOkay() + { + document.documentElement.removeChild(document.getElementById("savepage-pageinfo-panel-container")); + + window.setTimeout(function() { chrome.runtime.sendMessage({ type: "setSaveState", savestate: -2 }); },1000); + } +} + +/************************************************************************/ + +/* Remove Resource Loader function */ + +/* For pages saved using Version 7.0-15.1 */ + +function removeResourceLoader() +{ + var resourceBlobURL = new Array(); + var resourceMimeType = new Array(); + var resourceCharSet = new Array(); + var resourceContent = new Array(); + var resourceStatus = new Array(); + var resourceRemembered = new Array(); + + var resourceCount; + + gatherBlobResources(); + + /* First Pass - to gather blob resources */ + + function gatherBlobResources() + { + chrome.runtime.sendMessage({ type: "setSaveState", savestate: 4 }); + + findBlobResources(0,window,document.documentElement); + + loadBlobResources(); + } + + function findBlobResources(depth,frame,element) + { + var i,csstext,regex,shadowroot; + var matches = new Array(); + + if (element.hasAttribute("style")) + { + csstext = element.style.cssText; + + regex = /url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; + + while ((matches = regex.exec(csstext)) != null) + { + matches[1] = removeQuotes(matches[1]); + + if (matches[1].substr(0,5).toLowerCase() == "blob:") /* blob url */ + { + rememberBlobURL(matches[1],"image/png",""); + } + } + } + + if (element.localName == "script") + { + /* src will be data uri - not replaced by blob url */ + } + else if (element.localName == "style") + { + csstext = element.textContent; + + regex = /url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; + + while ((matches = regex.exec(csstext)) != null) + { + matches[1] = removeQuotes(matches[1]); + + if (matches[1].substr(0,5).toLowerCase() == "blob:") /* blob url */ + { + rememberBlobURL(matches[1],"image/png",""); + } + } + } + else if (element.localName == "link" && (element.rel.toLowerCase() == "icon" || element.rel.toLowerCase() == "shortcut icon")) + { + if (element.href.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.href,"image/vnd.microsoft.icon",""); + } + else if (element.localName == "body") + { + if (element.background.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.background,"image/png",""); + } + else if (element.localName == "img") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"image/png",""); + } + else if (element.localName == "input" && element.type.toLowerCase() == "image") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"image/png",""); + } + else if (element.localName == "audio") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"audio/mpeg",""); + } + else if (element.localName == "video") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"video/mp4",""); + if (element.poster.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.poster,"image/png",""); + } + else if (element.localName == "source") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") + { + if (element.parentElement) + { + if (element.parentElement.localName == "audio") rememberBlobURL(element.src,"audio/mpeg",""); + else if (element.parentElement.localName == "video") rememberBlobURL(element.src,"video/mp4",""); + } + } + } + else if (element.localName == "track") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"text/vtt","utf-8"); + } + else if (element.localName == "object") + { + if (element.data.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.data,"application/octet-stream",""); + } + else if (element.localName == "embed") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"application/octet-stream",""); + } + + /* Handle nested frames and child elements */ + + if (element.localName == "iframe" || element.localName == "frame") /* frame elements */ + { + if (element.src.substr(0,5).toLowerCase() == "blob:") rememberBlobURL(element.src,"text/html","utf-8"); + + if (depth < maxFrameDepth) + { + try + { + if (element.contentDocument.documentElement != null) /* in case web page not fully loaded before finding */ + { + findBlobResources(depth+1,element.contentWindow,element.contentDocument.documentElement); + } + } + catch (e) {} /* attempting cross-domain web page access */ + } + } + else + { + /* Handle shadow child elements */ + + if (isFirefox) shadowroot = element.shadowRoot || element.openOrClosedShadowRoot; + else shadowroot = element.shadowRoot || ((chrome.dom && element instanceof HTMLElement) ? chrome.dom.openOrClosedShadowRoot(element) : null); + + if (shadowroot != null) + { + if (shadowElements.indexOf(element.localName) < 0) /* ignore elements with built-in Shadow DOM */ + { + for (i = 0; i < shadowroot.children.length; i++) + if (shadowroot.children[i] != null) /* in case web page not fully loaded before finding */ + findBlobResources(depth,frame,shadowroot.children[i]); + } + } + + /* Handle normal child elements */ + + for (i = 0; i < element.children.length; i++) + if (element.children[i] != null) /* in case web page not fully loaded before finding */ + findBlobResources(depth,frame,element.children[i]); + } + } + + function rememberBlobURL(bloburl,mimetype,charset) + { + var i; + + for (i = 0; i < resourceBlobURL.length; i++) + if (resourceBlobURL[i] == bloburl) break; + + if (i == resourceBlobURL.length) /* new blob */ + { + resourceBlobURL[i] = bloburl; + resourceMimeType[i] = mimetype; /* default if load fails */ + resourceCharSet[i] = charset; /* default if load fails */ + resourceContent[i] = ""; /* default if load fails */ + resourceStatus[i] = "pending"; + resourceRemembered[i] = 1; + } + else resourceRemembered[i]++; /* repeated blob */ + } + + /* After first pass - load blob resources */ + + function loadBlobResources() + { + var i,xhr; + + resourceCount = 0; + + for (i = 0; i < resourceBlobURL.length; i++) + { + if (resourceStatus[i] == "pending") + { + resourceCount++; + + try + { + xhr = new XMLHttpRequest(); + + xhr.open("GET",resourceBlobURL[i],true); + xhr.responseType = "arraybuffer"; + xhr.timeout = 1000; + xhr.onload = loadSuccess; + xhr.onerror = loadFailure; + xhr.ontimeout = loadFailure; + xhr._resourceIndex = i; + + xhr.send(); /* throws exception if url is invalid */ + } + catch (e) + { + resourceStatus[i] = "failure"; + + --resourceCount; + } + } + } + + if (resourceCount <= 0) checkDataResources(); + } + + function loadSuccess() + { + var i,binaryString,contenttype,mimetype,charset; + var byteArray = new Uint8Array(this.response); + var matches = new Array(); + + if (this.status == 200) + { + binaryString = ""; + for (i = 0; i < byteArray.byteLength; i++) binaryString += String.fromCharCode(byteArray[i]); + + contenttype = this.getResponseHeader("Content-Type"); + if (contenttype == null) contenttype = ""; + + matches = contenttype.match(/([^;]+)/i); + if (matches != null) mimetype = matches[1].toLowerCase(); + else mimetype = ""; + + // Scrapyard ////////////////////////////////////////////////////////////////// + matches = contenttype.match(/;\s*charset=([^;]+)/i); + ////////////////////////////////////////////////////////////////// Scrapyard // + if (matches != null) charset = matches[1].toLowerCase(); + else charset = ""; + + switch (resourceMimeType[this._resourceIndex].toLowerCase()) /* expected MIME type */ + { + case "image/png": /* image file */ + case "image/vnd.microsoft.icon": /* icon file */ + case "audio/mpeg": /* audio file */ + case "video/mp4": /* video file */ + case "application/octet-stream": /* data file */ + + if (mimetype != "") resourceMimeType[this._resourceIndex] = mimetype; + + resourceContent[this._resourceIndex] = binaryString; + + break; + + case "text/vtt": /* subtitles file */ + case "text/html": /* iframe html file */ + + if (mimetype != "") resourceMimeType[this._resourceIndex] = mimetype; + if (charset != "") resourceCharSet[this._resourceIndex] = charset; + + resourceContent[this._resourceIndex] = binaryString; + + break; + } + + resourceStatus[this._resourceIndex] = "success"; + } + else resourceStatus[this._resourceIndex] = "failure"; + + if (--resourceCount <= 0) checkDataResources(); + } + + function loadFailure() + { + resourceStatus[this._resourceIndex] = "failure"; + + if (--resourceCount <= 0) checkDataResources(); + } + + /* After first pass - check data resources */ + + function checkDataResources() + { + var i,dataurisize,skipcount,count; + + /* Check for large resource sizes */ + + dataurisize = 0; + skipcount = 0; + + for (i = 0; i < resourceBlobURL.length; i++) + { + if (resourceCharSet[i] == "") /* charset not defined - binary data */ + { + count = resourceRemembered[i]; + + if (resourceContent[i].length*count > maxResourceSize*1024*1024) skipcount++; /* skip large and/or repeated resource */ + else dataurisize += resourceContent[i].length*count*(4/3); /* base64 expands by 4/3 */ + } + } + + if (dataurisize > maxTotalSize*1024*1024) + { + showMessage("Cannot remove resource loader","Remove", + "Cannot remove resource loader because the total size of resources exceeds " + maxTotalSize + "MB.\n\n" + + "It may be possible to remove resource loader by trying this suggestion:\n\n" + + " • Reduce the 'Maximum size allowed for a resource' option value.", + null, + function savecancel() + { + chrome.runtime.sendMessage({ type: "setSaveState", savestate: -2 }); + }); + } + else if (showWarning) + { + if (skipcount > 0) + { + showMessage("Some resources exceed maximum size","Remove", + skipcount + " resources exceed maximum size and will be discarded.\n\n" + + "It may be possible to retain these resources by trying this suggestion:\n\n" + + " • Increase the 'Maximum size allowed for a resource' option value.", + function removecontinue() + { + substituteBlobResources(); + }, + function removecancel() + { + chrome.runtime.sendMessage({ type: "setSaveState", savestate: -2 }); + }); + } + else substituteBlobResources(); + } + else substituteBlobResources(); + } + + /* Second Pass - to substitute blob URLs with data URI's */ + + function substituteBlobResources() + { + var i,script,meta; + + /* Remove page loader script */ /* Version 7.0-14.0 */ + + script = document.getElementById("savepage-pageloader"); + if (script != null) script.parentElement.removeChild(script); + + /* Remove resource loader meta element */ /* Version 15.0+ */ + + meta = document.getElementsByName("savepage-resourceloader")[0]; + if (meta != null) meta.parentElement.removeChild(meta); + + /* Release blob memory allocation */ + + for (i = 0; i < resourceBlobURL.length; i++) + window.URL.revokeObjectURL(resourceBlobURL[i]); + + /* Replace blob URLs with data URI's */ + + replaceBlobResources(0,window,document.documentElement); /* replace blob urls with data uri's */ + + pageType = 1; /* saved page */ + + chrome.runtime.sendMessage({ type: "setPageType", pagetype: pageType }); + + window.setTimeout(function() { chrome.runtime.sendMessage({ type: "setSaveState", savestate: 7 }); },1000); + } + + function replaceBlobResources(depth,frame,element) + { + var i,csstext,regex,shadowroot; + + if (element.hasAttribute("style")) + { + csstext = element.style.cssText; + + regex = /url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; + + element.style.cssText = csstext.replace(regex,replaceCSSBlobURL); + } + + if (element.localName == "script") + { + /* src will be data uri - not replaced by blob url */ + } + else if (element.localName == "style") + { + csstext = element.textContent; + + regex = /url\(\s*((?:"[^"]+")|(?:'[^']+')|(?:[^\s)]+))\s*\)/gi; + + element.textContent = csstext.replace(regex,replaceCSSBlobURL); + } + else if (element.localName == "link" && (element.rel.toLowerCase() == "icon" || element.rel.toLowerCase() == "shortcut icon")) + { + if (element.href.substr(0,5).toLowerCase() == "blob:") element.href = replaceBlobURL(element.href); + } + else if (element.localName == "body") + { + if (element.background.substr(0,5).toLowerCase() == "blob:") element.background = replaceBlobURL(element.background); + } + else if (element.localName == "img") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") element.src = replaceBlobURL(element.src); + } + else if (element.localName == "input" && element.type.toLowerCase() == "image") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") element.src = replaceBlobURL(element.src); + } + else if (element.localName == "audio") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") + { + element.src = replaceBlobURL(element.src); + element.load(); + } + } + else if (element.localName == "video") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") + { + element.src = replaceBlobURL(element.src); + element.load(); + } + if (element.poster.substr(0,5).toLowerCase() == "blob:") element.poster = replaceBlobURL(element.poster); + } + else if (element.localName == "source") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") + { + element.src = replaceBlobURL(element.src); + if (element.parentElement) element.parentElement.load(); + } + } + else if (element.localName == "track") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") element.src = replaceBlobURL(element.src); + } + else if (element.localName == "object") + { + if (element.data.substr(0,5).toLowerCase() == "blob:") element.data = replaceBlobURL(element.data); + } + else if (element.localName == "embed") + { + if (element.src.substr(0,5).toLowerCase() == "blob:") element.src = replaceBlobURL(element.src); + } + + /* Handle nested frames and child elements */ + + if (element.localName == "iframe" || element.localName == "frame") /* frame elements */ + { + if (element.src.substr(0,5).toLowerCase() == "blob:") element.src = replaceBlobURL(element.src); + + if (depth < maxFrameDepth) + { + try + { + if (element.contentDocument.documentElement != null) /* in case web page not fully loaded before replacing */ + { + replaceBlobResources(depth+1,element.contentWindow,element.contentDocument.documentElement); + } + } + catch (e) {} /* attempting cross-domain web page access */ + } + } + else + { + /* Handle shadow child elements */ + + if (isFirefox) shadowroot = element.shadowRoot || element.openOrClosedShadowRoot; + else shadowroot = element.shadowRoot || ((chrome.dom && element instanceof HTMLElement) ? chrome.dom.openOrClosedShadowRoot(element) : null); + + if (shadowroot != null) + { + if (shadowElements.indexOf(element.localName) < 0) /* ignore elements with built-in Shadow DOM */ + { + for (i = 0; i < shadowroot.children.length; i++) + if (shadowroot.children[i] != null) /* in case web page not fully loaded before replacing */ + replaceBlobResources(depth,frame,shadowroot.children[i]); + } + } + + /* Handle normal child elements */ + + for (i = 0; i < element.children.length; i++) + if (element.children[i] != null) /* in case web page not fully loaded before replacing */ + replaceBlobResources(depth,frame,element.children[i]); + } + } + + function replaceCSSBlobURL(match,p1,offset,string) + { + p1 = removeQuotes(p1); + + if (p1.substr(0,5).toLowerCase() == "blob:") /* blob url */ + { + return "url(" + replaceBlobURL(p1) + ")"; + } + else return match; + } + + function replaceBlobURL(bloburl) + { + var i,count,asciistring; + + for (i = 0; i < resourceBlobURL.length; i++) + if (resourceBlobURL[i] == bloburl && resourceStatus[i] == "success") break; + + if (i < resourceBlobURL.length) + { + if (resourceCharSet[i] == "") /* charset not defined - binary data */ + { + count = resourceRemembered[i]; + + if (resourceContent[i].length*count <= maxResourceSize*1024*1024) /* skip large and/or repeated resource */ + { + try { asciistring = btoa(resourceContent[i]); } + catch (e) { asciistring = ""; } /* resource content not a binary string */ + + return "data:" + resourceMimeType[i] + ";base64," + asciistring; /* binary data encoded as Base64 ASCII string */ + } + } + else /* charset defined - character data */ + { + return "data:" + resourceMimeType[i] + ";charset=utf-8," + encodeURIComponent(resourceContent[i]); /* characters encoded as UTF-8 %escaped string */ + } + } + + return bloburl; + } +} + +/************************************************************************/ + +/* Extract saved page media (image/audio/video) function */ + +function extractSavedPageMedia() +{ + chrome.runtime.sendMessage({ type: "setSaveState", savestate: 5 }); + + if (!extract(0,window,document.documentElement)) + { + showMessage("Extract Image/Audio/Video failed","Extract","Image/Audio/Video element not found.",null,null); + } + + window.setTimeout(function() { chrome.runtime.sendMessage({ type: "setSaveState", savestate: 8 }); },1000); + + function extract(depth,frame,element) + { + var i,baseuri,location,filename,link,shadowroot; + + if (element.localName == "img" || element.localName == "audio" || element.localName == "video" || element.localName == "source") + { + if (element.src == extractSrcUrl) /* image/audio/video found */ + { + baseuri = element.ownerDocument.baseURI; + + if (baseuri != null) + { + location = resolveURL(element.getAttribute("data-savepage-src"),baseuri); + + if (location != null) + { + filename = getSavedFileName(location,"",true); + + link = document.createElement("a"); + link.download = filename; + link.href = extractSrcUrl; + + link.addEventListener("click",handleClick,true); + + link.dispatchEvent(new MouseEvent("click")); /* save image/audio/video as file */ + + link.removeEventListener("click",handleClick,true); + + function handleClick(event) + { + event.stopPropagation(); + } + + return true; + } + } + } + } + + /* Handle nested frames and child elements */ + + if (element.localName == "iframe" || element.localName == "frame") /* frame elements */ + { + if (depth < maxFrameDepth) + { + try + { + if (element.contentDocument.documentElement != null) /* in case web page not fully loaded before extracting */ + { + if (extract(depth+1,element.contentWindow,element.contentDocument.documentElement)) return true; + } + } + catch (e) {} /* attempting cross-domain web page access */ + } + } + else + { + /* Handle shadow child elements */ + + if (isFirefox) shadowroot = element.shadowRoot || element.openOrClosedShadowRoot; + else shadowroot = element.shadowRoot || ((chrome.dom && element instanceof HTMLElement) ? chrome.dom.openOrClosedShadowRoot(element) : null); + + if (shadowroot != null) + { + if (shadowElements.indexOf(element.localName) < 0) /* ignore elements with built-in Shadow DOM */ + { + for (i = 0; i < shadowroot.children.length; i++) + if (shadowroot.children[i] != null) /* in case web page not fully loaded before extracting */ + if (extract(depth,frame,shadowroot.children[i])) return true; + } + } + + /* Handle normal child elements */ + + for (i = 0; i < element.children.length; i++) + if (element.children[i] != null) /* in case web page not fully loaded before extracting */ + if (extract(depth,frame,element.children[i])) return true; + } + + return false; + } +} + +/************************************************************************/ + +/* Save utility functions */ + +function showMessage(messagetitle,buttonsuffix,messagetext,continuefunction,cancelfunction) +{ + // Scrapyard ////////////////////////////////////////////////////////////////// + console.log(messagetext); + ////////////////////////////////////////////////////////////////// Scrapyard // + + var xhr,parser,messagedoc,container; + + xhr = new XMLHttpRequest(); + xhr.open("GET",chrome.runtime.getURL("message-panel.html"),true); + xhr.onload = complete; + xhr.send(); + + function complete() + { + if (xhr.status == 200) + { + /* Parse message document */ + + parser = new DOMParser(); + messagedoc = parser.parseFromString(xhr.responseText,"text/html"); + + /* Create container element */ + + container = document.createElement("div"); + container.setAttribute("id","savepage-message-panel-container"); + document.documentElement.appendChild(container); + + /* Append message elements */ + + container.appendChild(messagedoc.getElementById("savepage-message-panel-overlay")); + + /* Set title, button names and contents */ + + document.getElementById("savepage-message-panel-header").textContent = messagetitle; + document.getElementById("savepage-message-panel-continue").textContent = "Continue " + buttonsuffix; + document.getElementById("savepage-message-panel-cancel").textContent = "Cancel " + buttonsuffix; + document.getElementById("savepage-message-panel-text").textContent = messagetext; + + /* Add listeners for buttons */ + + document.getElementById("savepage-message-panel-cancel").addEventListener("click",clickCancel,false); + document.getElementById("savepage-message-panel-continue").addEventListener("click",clickContinue,false); + + /* Configure for one or two buttons */ + + if (continuefunction != null) + { + /* Focus continue button */ + + document.getElementById("savepage-message-panel-continue").focus(); + } + else + { + /* Hide continue button */ + + document.getElementById("savepage-message-panel-continue").style.setProperty("display","none","important"); + + /* Focus cancel button */ + + document.getElementById("savepage-message-panel-cancel").focus(); + } + + /* Select this tab */ + + chrome.runtime.sendMessage({ type: "selectTab" }); + } + } + + function clickContinue() + { + document.documentElement.removeChild(document.getElementById("savepage-message-panel-container")); + + continuefunction(); + } + + function clickCancel() + { + document.documentElement.removeChild(document.getElementById("savepage-message-panel-container")); + + cancelfunction(); + } +} + +function removeQuotes(url) +{ + if (url.substr(0,1) == "\"" || url.substr(0,1) == "'") url = url.substr(1); + + if (url.substr(-1) == "\"" || url.substr(-1) == "'") url = url.substr(0,url.length-1); + + return url; +} + +function replaceableResourceURL(url) +{ + /* Exclude data: urls, blob: urls, moz-extension: urls, fragment-only urls and empty urls */ + + if (url.substr(0,5).toLowerCase() == "data:" || url.substr(0,5).toLowerCase() == "blob:" || + url.substr(0,14).toLowerCase() == "moz-extension:" || url.substr(0,1) == "#" || url == "") return false; + + return true; +} + +function resolveURL(url,baseuri) +{ + var resolvedURL; + + try + { + resolvedURL = new URL(url,baseuri); + } + catch (e) + { + return null; /* baseuri invalid or null */ + } + + return resolvedURL.href; +} + +function removeFragment(url) +{ + var i; + + i = url.indexOf("#"); + + if (i >= 0) return url.substr(0,i); + + return url; +} + +function getSavedFileName(url,title,extract) +{ + var i,documentURL,host,hostw,path,pathw,file,filew,query,fragment,date,datestr,pubelement,pubstr,pubdate,pubdatestr,filename,regex,minlength; + var pubmatches = new Array(); + var mediaextns = new Array( ".jpe",".jpg",".jpeg",".gif",".png",".bmp",".ico",".svg",".svgz",".tif",".tiff",".ai",".drw",".pct",".psp",".xcf",".psd",".raw",".webp", /* Firefox image extensions */ + ".aac",".aif",".flac",".iff",".m4a",".m4b",".mid",".midi",".mp3",".mpa",".mpc",".oga",".ogg",".ra",".ram",".snd",".wav",".wma", /* Firefox audio extensions */ + ".avi",".divx",".flv",".m4v",".mkv",".mov",".mp4",".mpeg",".mpg",".ogm",".ogv",".ogx",".rm",".rmvb",".smil",".webm",".wmv",".xvid"); /* Firefox video extensions */ + + documentURL = new URL(url); + + host = documentURL.hostname; + host = decodeURIComponent(host); + host = sanitizeString(host); + + hostw = host.replace(/^www\./,""); + + path = documentURL.pathname; + path = decodeURIComponent(path); + path = sanitizeString(path); + path = path.replace(/^\/|\/$/g,""); + + pathw = path.replace(/\.[^.\/]+$/,""); + + file = path.replace(/[^\/]*\//g,""); + + filew = file.replace(/\.[^.]+$/,""); + + query = documentURL.search.substr(1); + + fragment = documentURL.hash.substr(1); + + title = sanitizeString(title); + title = title.trim(); + title = title.replace(/^\./,"_"); + if (title == "") title = file; + + date = new Date(); + datestr = new Date(date.getTime()-(date.getTimezoneOffset()*60000)).toISOString(); + + if ((pubelement = document.querySelector("meta[property='article:published_time'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Open Graph - ISO8601 */ + else if ((pubelement = document.querySelector("meta[property='datePublished'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Generic RDFa - ISO8601 */ + else if ((pubelement = document.querySelector("meta[itemprop='datePublished'][content]")) != null) pubstr = pubelement.getAttribute("content"); /* Microdata - ISO8601 */ + else if ((pubelement = document.querySelector("script[type='application/ld+json']")) != null) /* JSON-LD - ISO8601 */ + { + pubmatches = pubelement.textContent.match(/"datePublished"\s*:\s*"([^"]*)"/); + pubstr = pubmatches ? pubmatches[1] : null; + } + else if ((pubelement = document.querySelector("time[datetime]")) != null) pubstr = pubelement.getAttribute("datetime"); /* HTML5 - ISO8601 and similar formats */ + else pubstr = null; + + try + { + if (!pubstr) throw false; + pubstr = pubstr.replace(/(Z|(-|\+)\d\d:?\d\d)$/,""); /* remove timezone */ + pubdate = new Date(pubstr); + pubdatestr = new Date(pubdate.getTime()-(pubdate.getTimezoneOffset()*60000)).toISOString(); + } + catch (e) { pubdatestr = ""; } + + filename = savedFileName; + + regex = /(%TITLE%|%DATE\((.?)\)%|%TIME\((.?)\)%|%DATEP\((.?)\)%|%TIMEP\((.?)\)%|%DATEPF\((.?)\)%|%TIMEPF\((.?)\)%|%HOST%|%HOSTW%|%PATH%|%PATHW%|%FILE%|%FILEW%|%QUERY\(([^)]*)\)%|%FRAGMENT%)/g; + + minlength = filename.replace(regex,"").length; + + filename = filename.replace(regex,_replacePredefinedFields); + + function _replacePredefinedFields(match,p1,p2,p3,p4,p5,p6,p7,p8,offset,string) + { + var date,time,value; + var params = new Object(); + + if (p1 == "%TITLE%") return _truncateField(p1,title); + else if (p1.substr(0,6) == "%DATE(" && p1.substr(-2) == ")%") + { + date = datestr.substr(0,10).replace(/-/g,p2); + return _truncateField(p1,date); + } + else if (p1.substr(0,6) == "%TIME(" && p1.substr(-2) == ")%") + { + time = datestr.substr(11,8).replace(/:/g,p3); + return _truncateField(p1,time); + } + else if (p1.substr(0,7) == "%DATEP(" && p1.substr(-2) == ")%") + { + date = pubdatestr.substr(0,10).replace(/-/g,p4); + return _truncateField(p1,date); + } + else if (p1.substr(0,7) == "%TIMEP(" && p1.substr(-2) == ")%") + { + time = pubdatestr.substr(11,8).replace(/:/g,p5); + return _truncateField(p1,time); + } + else if (p1.substr(0,8) == "%DATEPF(" && p1.substr(-2) == ")%") + { + date = (pubdatestr != "") ? pubdatestr.substr(0,10).replace(/-/g,p6) : datestr.substr(0,10).replace(/-/g,p6); + return _truncateField(p1,date); + } + else if (p1.substr(0,8) == "%TIMEPF(" && p1.substr(-2) == ")%") + { + time = (pubdatestr != "") ? pubdatestr.substr(11,8).replace(/:/g,p7) : datestr.substr(11,8).replace(/:/g,p7); + return _truncateField(p1,time); + } + else if (p1 == "%HOST%") return _truncateField(p1,host); + else if (p1 == "%HOSTW%") return _truncateField(p1,hostw); + else if (p1 == "%FILE%") return _truncateField(p1,file); + else if (p1 == "%FILEW%") return _truncateField(p1,filew); + else if (p1 == "%PATH%") return _truncateField(p1,path); + else if (p1 == "%PATHW%") return _truncateField(p1,pathw); + else if (p1.substr(0,7) == "%QUERY(" && p1.substr(-2) == ")%") + { + if (p8 == "") return _truncateField(p1,query); + params = new URLSearchParams(query); + value = params.get(p8); + if (value == null) value = ""; + return _truncateField(p1,value); + } + else if (p1 == "%FRAGMENT%") return _truncateField(p1,fragment); + } + + function _truncateField(field,repstr) + { + var maxextnlength = 6; + + if (repstr.length > maxFileNameLength-maxextnlength-minlength) repstr = repstr.substr(0,maxFileNameLength-maxextnlength-minlength); + + minlength += repstr.length; + + return repstr; + } + + if (!extract) + { + if (filename == "") filename = "html"; + + if (filename.substr(-4) != ".htm" && filename.substr(-5) != ".html" && + filename.substr(-6) != ".shtml" && filename.substr(-6) != ".xhtml") filename += ".html"; /* Firefox HTML extensions */ + } + else + { + if (filename == "") filename = "media"; + + for (i = 0; i < mediaextns.length; i++) + { + if (file.substr(-mediaextns[i].length) == mediaextns[i] && + filename.substr(-mediaextns[i].length) != mediaextns[i]) filename += mediaextns[i]; + } + } + + filename = filename.replace(/(\\|\/|:|\*|\?|"|<|>|\|)/g,"_"); + + if (replaceSpaces) filename = filename.replace(/\s/g,replaceChar); + + filename = filename.trim(); + + return filename; +} + +function sanitizeString(string) +{ + var i,charcode; + + /* Remove control characters: 0-31 and 255 */ + /* Remove other line break characters: 133, 8232, 8233 */ + /* Remove zero-width characters: 6158, 8203, 8204, 8205, 8288, 65279 */ + /* Change all space characters to normal spaces: 160, 5760, 8192-8202, 8239, 8287, 12288 */ + /* Change all hyphen characters to normal hyphens: 173, 1470, 6150, 8208-8213, 8315, 8331, 8722, 11834, 11835, 65112, 65123, 65293 */ + + for (i = 0; i < string.length; i++) + { + charcode = string.charCodeAt(i); + + if (charcode <= 31 || charcode == 255 || + charcode == 133 || charcode == 8232 || charcode == 8233 || + charcode == 6158 || charcode == 8203 || charcode == 8204 || charcode == 8205 || charcode == 8288 || charcode == 65279) + { + string = string.substr(0,i) + string.substr(i+1); + } + + if (charcode == 160 || charcode == 5760 || (charcode >= 8192 && charcode <= 8202) || charcode == 8239 || charcode == 8287 || charcode == 12288) + { + string = string.substr(0,i) + " " + string.substr(i+1); + } + + if (charcode == 173 || charcode == 1470 || charcode == 6150 || (charcode >= 8208 && charcode <= 8213) || + charcode == 8315 || charcode == 8331 || charcode == 8722 || charcode == 11834 || charcode == 11835 || + charcode == 65112 || charcode == 65123 || charcode == 65293) + { + string = string.substr(0,i) + "-" + string.substr(i+1); + } + } + + return string; +} + +/************************************************************************/ diff --git a/addon/savepage/readme.txt b/addon/savepage/readme.txt new file mode 100644 index 00000000..3502f1d6 --- /dev/null +++ b/addon/savepage/readme.txt @@ -0,0 +1 @@ +Derived from: https://addons.mozilla.org/en-US/firefox/addon/save-page-we diff --git a/addon/savepage/shadowloader.js b/addon/savepage/shadowloader.js new file mode 100644 index 00000000..c3ad7f65 --- /dev/null +++ b/addon/savepage/shadowloader.js @@ -0,0 +1,41 @@ +function savepage_ShadowLoader(maxframedepth) +{ + createShadowDOMs(0,document.documentElement); + + function createShadowDOMs(depth,element) + { + var i; + + if (element.localName == "iframe" || element.localName == "frame") + { + if (depth < maxframedepth) + { + try + { + if (element.contentDocument.documentElement != null) + { + createShadowDOMs(depth+1,element.contentDocument.documentElement); + } + } + catch (e) {} + } + } + else + { + if (element.children.length >= 1 && element.children[0].localName == "template" && element.children[0].hasAttribute("data-savepage-shadowroot")) + { + var shadowRoot = element.shadowRoot || element.attachShadow({ mode: "open" }); + shadowRoot.appendChild(element.children[0].content); + element.removeChild(element.children[0]); + + for (i = 0; i < element.shadowRoot.children.length; i++) + if (element.shadowRoot.children[i] != null) + createShadowDOMs(depth,element.shadowRoot.children[i]); + } + + for (i = 0; i < element.children.length; i++) + if (element.children[i] != null) + createShadowDOMs(depth,element.children[i]); + } + } +} diff --git a/addon/search.js b/addon/search.js new file mode 100644 index 00000000..94ac0365 --- /dev/null +++ b/addon/search.js @@ -0,0 +1,377 @@ +import {TREE_STATE_PREFIX} from "./ui/tree.js"; +import {CONTENT_NODE_TYPES, EVERYTHING_SHELF_NAME, NODE_TYPE_FOLDER, NODE_TYPE_BOOKMARK} from "./storage.js"; +import {getActiveTab, openContainerTab, makeReferenceURL} from "./utils_browser.js"; +import {Bookmark} from "./bookmarks_bookmark.js"; +import {escapeHtml} from "./utils_html.js"; +import {settings} from "./settings.js"; +import {Node} from "./storage_entities.js"; + +export const SEARCH_MODE_UNIVERSAL = 0; +export const SEARCH_MODE_TITLE = 1; +export const SEARCH_MODE_TAGS = 2; +export const SEARCH_MODE_CONTENT = 3; +export const SEARCH_MODE_NOTES = 4; +export const SEARCH_MODE_COMMENTS = 5; +export const SEARCH_MODE_DATE = 6; +export const SEARCH_MODE_FOLDER = 7; + +const UUID_SEARCH_PREFIX = "uuid:"; + +class SearchProvider { + constructor(shelf) { + this.shelf = shelf; + } + + isInputValid(text) { + return text && text.length > 2; + } +} + +export class UniversalSearchProvider extends SearchProvider { + constructor(shelf) { + super(shelf); + + this.titleProvider = new TitleSearchProvider(shelf); + this.folderProvider = new FolderSearchProvider(shelf); + this.tagProvider = new TagSearchProvider(shelf); + this.contentProvider = new ContentSearchProvider(shelf, "content"); + this.notesProvider = new ContentSearchProvider(shelf, "notes"); + this.commentsProvider = new ContentSearchProvider(shelf, "comments"); + this.dateProvider = new DateSearchProvider(shelf); + + this.providers = [ + this.titleProvider, + this.folderProvider, + this.tagProvider, + this.contentProvider, + this.notesProvider, + this.commentsProvider, + this.dateProvider + ]; + } + + search(text) { + if (text?.startsWith(UUID_SEARCH_PREFIX)) + return this._searchByUUID(text); + else { + const availableProviders = this.providers.filter(p => p.isInputValid(text)); + const results = availableProviders.map(p => p.search(text)); + + return Promise.all(results).then(results => { + results = results.reduce((acc, arr) => [...acc, ...arr], []); + results = results.filter((n, i, a) => this._indexByUUID(a, n.uuid) === i); // distinct + return results; + }); + } + } + + _indexByUUID(nodes, uuid) { + for (let i = 0; i < nodes.length; ++i) { + if (nodes[i].uuid === uuid) + return i; + } + } + + async _searchByUUID(text) { + let result = []; + text = text.replace(UUID_SEARCH_PREFIX, "").trim(); + + const node = await Node.getByUUID(text); + + if (node) + result = [node]; + + return result; + } +} + +export class TitleSearchProvider extends SearchProvider { + constructor(shelf) { + super(shelf) + } + + search(text, limit) { + if (text) { + return Bookmark.list({ + search: text, + depth: "subtree", + path: this.shelf, + limit: limit, + types: CONTENT_NODE_TYPES + }); + } + + return []; + } +} + +export class FolderSearchProvider extends SearchProvider { + constructor(shelf) { + super(shelf) + } + + search(text, limit) { + if (text) + return Bookmark.list({ + search: text, + depth: "subtree", + path: this.shelf, + limit: limit, + types: [NODE_TYPE_FOLDER] + }); + + return []; + } +} + +export class TagSearchProvider extends SearchProvider { + constructor(shelf) { + super(shelf) + } + + search(text) { + if (text) { + return Bookmark.list({ + depth: "subtree", + path: this.shelf, + tags: text, + types: CONTENT_NODE_TYPES + }); + } + return []; + } +} + +export class ContentSearchProvider extends SearchProvider { + constructor(shelf, index) { + super(shelf); + this.index = index; + } + + search(text) { + if (text) { + return Bookmark.list({ + search: text, + content: true, + index: this.index, + partial: settings.sidebar_filter_partial_match(), + depth: "subtree", + path: this.shelf + }); + } + return []; + } +} + +export class DateSearchProvider extends SearchProvider { + constructor(shelf) { + super(shelf) + } + + search(text) { + if (text) { + const dates = []; + for (const m of text.matchAll(/(\d{4}-\d{2}-\d{2})/g)) + dates.push(m[1]); + + if (dates.length === 1) + dates.push(undefined); + + const period = /(.*?)\d{4}-\d{2}-\d{2}/.exec(text.trim().toLowerCase())[1]; + + return Bookmark.list({ + depth: "subtree", + path: this.shelf, + date: dates[0], + date2: dates[1], + period: period.trim(), + types: CONTENT_NODE_TYPES + }); + } + return []; + } + + isInputValid(text) { + const daterx = /^\s*(?:\d{4}-\d{2}-\d{2})|(?:before\s+\d{4}-\d{2}-\d{2})|(?:after\s+\d{4}-\d{2}-\d{2})|(?:between\s+\d{4}-\d{2}-\d{2}\s+and\s+\d{4}-\d{2}-\d{2})\s*$/i; + + if (super.isInputValid(text)) { + text = text.trim() + if (daterx.test(text)) { + for (const m of text.matchAll(/(\d{4}-\d{2}-\d{2})/g)) { + if (isNaN(new Date(m[1]))) + return false + } + + return true; + } + } + + return false; + } +} + +export class SearchContext { + constructor(tree) { + this.tree = tree; + this._previousInput = ""; + this.searchMode = SEARCH_MODE_UNIVERSAL; + this.provider = new UniversalSearchProvider(EVERYTHING_SHELF_NAME); + } + + inSearch() { + this.isInSearch = true; + this.tree.stateKey = TREE_STATE_PREFIX + "search-" + this.searchMode; + } + + outOfSearch() { + this.isInSearch = false; + } + + get shelfName() { + return this.shelf; + } + + set shelfName(shelf) { + this.shelf = shelf; + this.setMode(this.searchMode, shelf); + } + + setMode(search_mode, shelf) { + this.searchMode = search_mode; + this.shelf = shelf; + + switch (search_mode) { + case SEARCH_MODE_UNIVERSAL: + this.provider = new UniversalSearchProvider(shelf); + break; + case SEARCH_MODE_TITLE: + this.provider = new TitleSearchProvider(shelf); + break; + case SEARCH_MODE_FOLDER: + this.provider = new FolderSearchProvider(shelf); + break; + case SEARCH_MODE_TAGS: + this.provider = new TagSearchProvider(shelf); + break; + case SEARCH_MODE_CONTENT: + this.provider = new ContentSearchProvider(shelf, "content"); + break; + case SEARCH_MODE_NOTES: + this.provider = new ContentSearchProvider(shelf, "notes"); + break; + case SEARCH_MODE_COMMENTS: + this.provider = new ContentSearchProvider(shelf, "comments"); + break; + case SEARCH_MODE_DATE: + this.provider = new DateSearchProvider(shelf); + break; + } + } + + search(text) { + return this.provider.search(text); + } + + isInputValid(text) { + return this.provider.isInputValid(text); + } +} + +// omnibox //////////////////////////////////////////////////////////////////// + +export function initializeOmnibox() { + browser.omnibox.setDefaultSuggestion({ + description: `Search Scrapyard bookmarks` + }); + + const SEARCH_LIMIT = 6; + let suggestions; + + const makeSuggestion = function(node) { + let suggText = node.name; + if (suggText && settings.platform.chrome) + suggText = escapeHtml(suggText); + + const suggestion = {description: suggText || ""}; + if (node.type === NODE_TYPE_BOOKMARK) + suggestion.content = node.uri; + else + suggestion.content = makeReferenceURL(node.uuid); + + if (settings.platform.firefox) + suggestion.__node = node; + + return suggestion; + } + + const findSuggestion = text => { + if (suggestions) { + if (text.startsWith("ext+scrapyard://")) { + let uuid = text.replace("ext+scrapyard://", ""); + let match = suggestions.filter(s => s.__node.uuid === uuid); + if (match.length) + return match[0] + } + else { + let match = suggestions.filter(s => s.__node.uri === text); + if (match.length) + return match[0]; + } + } + } + + browser.omnibox.onInputChanged.addListener(async (text, suggest) => { + if (!text.startsWith("+") && text?.length < 3) + return; + else if (text.startsWith("+") && text?.length < 4) + return; + + let provider = text.startsWith("+") + ? new TagSearchProvider(EVERYTHING_SHELF_NAME) + : new TitleSearchProvider(EVERYTHING_SHELF_NAME); + + if (text.startsWith("+")) + text = text.replace(/^\+(?:\s+)?/, ""); + + let nodes = await provider.search(text, SEARCH_LIMIT); + + suggestions = nodes.map(makeSuggestion); + + suggest(suggestions); + }); + + browser.omnibox.onInputEntered.addListener(async (text, disposition) => { + let url = text; + + if (settings.platform.firefox) { + let suggestion = findSuggestion(text); + suggestions = null; + + if (suggestion) { + let activeTab = await getActiveTab(); + + let node = suggestion.__node; + if (node.type === NODE_TYPE_BOOKMARK && node.container) { + await openContainerTab(url, node.container); + + if (activeTab && activeTab.url === "about:newtab") + browser.tabs.remove(activeTab.id); + return; + } + } + } + + switch (disposition) { + case "currentTab": + browser.tabs.update({url}); + break; + case "newForegroundTab": + browser.tabs.create({url}); + break; + case "newBackgroundTab": + browser.tabs.create({url, active: false}); + break; + } + }); +} + diff --git a/addon/settings.js b/addon/settings.js new file mode 100644 index 00000000..c8fd0713 --- /dev/null +++ b/addon/settings.js @@ -0,0 +1,124 @@ +import {merge} from "./utils.js"; + +export const SCRAPYARD_SYNC_METADATA = "scrapyard-sync-metadata"; + +const SCRAPYARD_SETTINGS_KEY = "scrapyard-settings"; + +class ScrapyardSettings { + constructor() { + this._default = { + export_format: "json", + shelf_list_height: 600, + helper_port_number: 20202, + show_firefox_bookmarks: true, + switch_to_new_bookmark: true, + visual_archive_icon: true, + visual_archive_color: true, + sort_shelves_in_popup: true, + show_firefox_toolbar: !_BACKGROUND_PAGE, + sidebar_filter_partial_match: true, + number_of_bookmarks_toolbar_references: 25 + }; + + this._bin = {}; + this._key = SCRAPYARD_SETTINGS_KEY; + } + + async _loadPlatform() { + if (!this._platform) { + this._platform = {}; + + if (browser.runtime.getPlatformInfo) { + const platformInfo = await browser.runtime.getPlatformInfo(); + + this._platform[platformInfo.os] = true; + } + + if (navigator.userAgent.indexOf("Firefox") >= 0) + this._platform.firefox = true; + + if (navigator.userAgent.indexOf("Chrome") >= 0) + this._platform.chrome = true; + } + } + + async _loadSettings() { + const object = await browser.storage.local.get(this._key); + this._bin = merge(object?.[this._key] || {}, this._default); + } + + async _load() { + await this._loadPlatform(); + await this._loadSettings(); + } + + async _save() { + return browser.storage.local.set({[this._key]: this._bin}); + } + + async _get(k) { + const v = await browser.storage.local.get(k); + if (v) + return v[k]; + return null; + } + + async _set(k, v) { return browser.storage.local.set({[k]: v}) } + + _processSetSetting(key, val) { + // in Firefox, synchronous access to this setting is required + if (this._platform.firefox && key === "open_sidebar_from_shortcut") + localStorage.setItem("option-open-sidebar-from-shortcut", val? "open": ""); + } + + get(target, key, receiver) { + if (key === "load") + return v => this._load(); // sic ! + else if (key === "default") + return this._default; + else if (key === "platform") + return this._platform; + else if (key === "get") + return this._get; + else if (key === "set") + return this._set; + + return (val, save = true) => { + let bin = this._bin; + + if (val === undefined) + return bin[key]; + + let deleted; + if (val === null) { + deleted = bin[key]; + delete bin[key] + } + else + bin[key] = val; + + let result = key in bin? bin[key]: deleted; + this._processSetSetting(key, val); + + if (save) + return this._save().then(() => result); + else + return result; + } + } + + has(target, key) { + return key in this._bin; + } + + * enumerate() { + for (let key in this._bin) yield key; + } +} + +export let settings = new Proxy({}, new ScrapyardSettings()); + +chrome.storage.onChanged.addListener(function (changes, areaName) { + if (changes[SCRAPYARD_SETTINGS_KEY]) + settings.load(); +}); diff --git a/addon/storage.js b/addon/storage.js new file mode 100644 index 00000000..c0c5bfbc --- /dev/null +++ b/addon/storage.js @@ -0,0 +1,277 @@ +export const NODE_TYPE_SHELF = 1; +export const NODE_TYPE_FOLDER = 2; +export const NODE_TYPE_BOOKMARK = 3; +export const NODE_TYPE_ARCHIVE = 4; +export const NODE_TYPE_SEPARATOR = 5; +export const NODE_TYPE_NOTES = 6; +export const NODE_TYPE_UNLISTED = 7; +export const NODE_TYPE_FILE = 8; + +export const NODE_TYPE_NAMES = { + [NODE_TYPE_SHELF]: "shelf", + [NODE_TYPE_FOLDER]: "folder", + [NODE_TYPE_BOOKMARK]: "bookmark", + [NODE_TYPE_ARCHIVE]: "archive", + [NODE_TYPE_SEPARATOR]: "separator", + [NODE_TYPE_NOTES]: "notes", + [NODE_TYPE_FILE]: "file" +}; + +export const NODE_TYPES = { + "shelf": NODE_TYPE_SHELF, + "folder": NODE_TYPE_FOLDER, + "bookmark": NODE_TYPE_BOOKMARK, + "archive": NODE_TYPE_ARCHIVE, + "separator": NODE_TYPE_SEPARATOR, + "notes": NODE_TYPE_NOTES, + "file": NODE_TYPE_FILE +}; + +export const CONTAINER_NODE_TYPES = [NODE_TYPE_SHELF, NODE_TYPE_FOLDER, NODE_TYPE_UNLISTED]; +export const CONTENT_NODE_TYPES = [NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK, NODE_TYPE_NOTES, NODE_TYPE_FILE]; + +export const TODO_STATE_TODO = 1; +export const TODO_STATE_DONE = 4; +export const TODO_STATE_WAITING = 2; +export const TODO_STATE_POSTPONED = 3; +export const TODO_STATE_CANCELLED = 5; + +export const TODO_STATE_NAMES = { + [TODO_STATE_TODO]: "TODO", + [TODO_STATE_WAITING]: "WAITING", + [TODO_STATE_POSTPONED]: "POSTPONED", + [TODO_STATE_CANCELLED]: "CANCELLED", + [TODO_STATE_DONE]: "DONE" +}; + +export const TODO_STATES = { + "TODO": TODO_STATE_TODO, + "WAITING": TODO_STATE_WAITING, + "POSTPONED": TODO_STATE_POSTPONED, + "CANCELLED": TODO_STATE_CANCELLED, + "DONE": TODO_STATE_DONE +}; + +export const DEFAULT_SHELF_ID = 1; +export const EVERYTHING_SHELF_ID = -1; +export const DONE_SHELF_ID = -2; +export const TODO_SHELF_ID = -3; +export const BROWSER_SHELF_ID = -4; +export const CLOUD_SHELF_ID = -5; +export const FILES_SHELF_ID = -6; + +export const TODO_SHELF_NAME = "TODO"; +export const TODO_SHELF_UUID = TODO_SHELF_NAME; +export const DONE_SHELF_NAME = "DONE"; +export const DONE_SHELF_UUID = DONE_SHELF_NAME; + +export const EVERYTHING_SHELF_NAME = "everything"; +export const EVERYTHING_SHELF_UUID = "everything"; + +export const DEFAULT_SHELF_NAME = "default"; +export const DEFAULT_SHELF_UUID = "1"; + +export const BROWSER_SHELF_NAME = "browser"; +export const BROWSER_SHELF_UUID = "browser_bookmarks"; +export const BROWSER_EXTERNAL_TYPE = "browser"; + +export const FIREFOX_BOOKMARK_MENU = "menu________"; +export const FIREFOX_BOOKMARK_UNFILED = "unfiled_____"; +export const FIREFOX_BOOKMARK_TOOLBAR = "toolbar_____"; +export const FIREFOX_BOOKMARK_MOBILE = "mobile______" + +export const FIREFOX_SPECIAL_FOLDERS = [FIREFOX_BOOKMARK_MENU, FIREFOX_BOOKMARK_UNFILED, FIREFOX_BOOKMARK_TOOLBAR]; + +export const CHROME_BOOKMARK_TOOLBAR = "1"; +export const CHROME_BOOKMARK_UNFILED = "2"; + +export const CLOUD_SHELF_NAME = "cloud"; +export const CLOUD_SHELF_UUID = "cloud"; +export const CLOUD_EXTERNAL_TYPE = "cloud"; + +export const RDF_EXTERNAL_TYPE = "rdf"; + +export const FILES_SHELF_NAME = "files"; +export const FILES_SHELF_UUID = "files"; +export const FILES_EXTERNAL_TYPE = "files"; +export const FILES_EXTERNAL_ROOT_PREFIX = "files-root-"; + +export const NON_IMPORTABLE_SHELVES = [BROWSER_SHELF_UUID, CLOUD_SHELF_UUID, FILES_SHELF_UUID]; + +export const NON_SYNCHRONIZED_EXTERNALS = [BROWSER_EXTERNAL_TYPE, CLOUD_EXTERNAL_TYPE, RDF_EXTERNAL_TYPE, FILES_EXTERNAL_TYPE]; + +export const DEFAULT_POSITION = 2147483647; + +export const UNDO_DELETE = 1; + +export const NODE_PROPERTIES = + ["id", + "pos", + "uri", + "name", + "type", + "size", + "uuid", + "icon", + "tags", + "tag_list", + "details", + "parent_id", + "todo_date", + "todo_state", + "todo_pos", + "date_added", + "date_modified", + "content_modified", + "stored_icon", + "has_notes", + "has_comments", + "external", + "external_id", + "container", + "content_type", + "contains", + "encoding", + "_unlisted", + "site" + ]; + +export function isContainerNode(node) { + return node && CONTAINER_NODE_TYPES.some(t => t == node.type); +} + +export function isContentNode(node) { + return node && CONTENT_NODE_TYPES.some(t => t == node.type); +} + +export function nodeHasSomeContent(node) { + return node.type === NODE_TYPE_ARCHIVE || node.stored_icon || node.has_notes || node.has_comments; +} + +const VIRTUAL_SHELVES = [ + EVERYTHING_SHELF_NAME, + TODO_SHELF_NAME, + DONE_SHELF_NAME, +].map(s => s.toLocaleLowerCase()); + +export function isVirtualShelf(name) { + name = name?.toLocaleLowerCase(); + + return VIRTUAL_SHELVES.some(s => s === name); +} + +const BUILTIN_SHELVES = [ + EVERYTHING_SHELF_NAME, + TODO_SHELF_NAME, + DONE_SHELF_NAME, + DEFAULT_SHELF_NAME, + BROWSER_SHELF_NAME, + CLOUD_SHELF_NAME, + FILES_SHELF_NAME +].map(s => s.toLocaleLowerCase()); + +export function isBuiltInShelf(name) { + name = name?.toLocaleLowerCase(); + + return BUILTIN_SHELVES.some(s => s === name); +} + +export function getBuiltInShelfName(uuid) { + switch(uuid) { + case EVERYTHING_SHELF_UUID: + return EVERYTHING_SHELF_NAME; + case DEFAULT_SHELF_UUID: + return DEFAULT_SHELF_NAME; + case BROWSER_SHELF_UUID: + return BROWSER_SHELF_NAME; + case CLOUD_SHELF_UUID: + return CLOUD_SHELF_NAME; + case TODO_SHELF_UUID: + return TODO_SHELF_NAME; + case DONE_SHELF_UUID: + return DONE_SHELF_NAME; + case FILES_SHELF_UUID: + return FILES_SHELF_NAME; + } +} + +export function byName(a, b) { + return a.name?.localeCompare(b.name, undefined, {sensitivity: "base"}); +} + +export function byPosition(a, b) { + let a_pos = a.pos === undefined? DEFAULT_POSITION: a.pos; + let b_pos = b.pos === undefined? DEFAULT_POSITION: b.pos; + return a_pos - b_pos; +} + +export function byTODOPosition(a, b) { + let a_pos = a.todo_pos === undefined? 0: a.todo_pos; + let b_pos = b.todo_pos === undefined? 0: b.todo_pos; + return a_pos - b_pos; +} + +export function byDateAddedDesc(a, b) { + if (a.date_added && b.date_added) + return b.date_added - a.date_added; + + return 0; +} + +export function byDateAddedAsc(a, b) { + if (a.date_added && b.date_added) + return a.date_added - b.date_added; + + return 0; +} + +export function byContainer(a, b) { + const ac = isContainerNode(a)? 0: 1; + const bc = isContainerNode(b)? 0: 1; + + return ac - bc; +} + +export const JSON_SCRAPBOOK_FORMAT = "JSON Scrapbook"; +export const JSON_SCRAPBOOK_VERSION = 1; +export const JSON_SCRAPBOOK_SHELVES = "shelves"; +export const JSON_SCRAPBOOK_FOLDERS = "folders"; +export const JSON_SCRAPBOOK_TYPE_INDEX = "index"; + +export function createJSONScrapBookMeta(type, contains = JSON_SCRAPBOOK_SHELVES, title) { + const now = new Date(); + + return { + format: JSON_SCRAPBOOK_FORMAT, + version: JSON_SCRAPBOOK_VERSION, + type: type, + contains: contains, + title: title, + uuid: undefined, + entities: undefined, + timestamp: now.getTime(), + date: now.toISOString() + }; +} + +export function updateJSONScrapBookMeta(meta, entities, uuid, comment) { + if (uuid) + meta.uuid = uuid; + + meta.entities = entities; + + const now = new Date(); + meta.timestamp = now.getTime(); + meta.date = now.toISOString(); + + if (comment) + meta.comment = comment; +} + +export const ARCHIVE_TYPE_BYTES = "bytes"; +export const ARCHIVE_TYPE_TEXT = "text"; +export const ARCHIVE_TYPE_FILES = "files"; + +export const UNPACKED_ARCHIVE_DIRECTORY = "archive"; + +export const STORAGE_POPULATED = "populated"; diff --git a/addon/storage_adapter_cloud.js b/addon/storage_adapter_cloud.js new file mode 100644 index 00000000..f062599d --- /dev/null +++ b/addon/storage_adapter_cloud.js @@ -0,0 +1,200 @@ +import {ARCHIVE_TYPE_FILES, ARCHIVE_TYPE_TEXT, CLOUD_EXTERNAL_TYPE, UNPACKED_ARCHIVE_DIRECTORY} from "./storage.js"; +import {CONTEXT_BACKGROUND, getContextType} from "./utils_browser.js"; +import {unzip} from "./lib/unzipit.js"; +import {send} from "./proxy.js"; + +export class StorageAdapterCloud { + _provider; + _batchCloudDB; + + setProvider(provider) { + this._provider = provider; + } + + async withCloudDB(f, fe) { + if (this._batchCloudDB) { + try { + await f(this._batchCloudDB); + } catch (e) { + console.error(e); + if (fe) fe(e); + } + } + else { + try { + let db = await this._provider.downloadDB(); + await f(db); + await this._provider.persistDB(db); + } catch (e) { + console.error(e); + if (fe) fe(e); + } + } + } + + async #openBatchSession() { + this._batchCloudDB = await this._provider.downloadDB(); + } + + async openBatchSession() { + if (getContextType() === CONTEXT_BACKGROUND) + return this.#openBatchSession(); + else + return send.openCloudBatchSession(); + } + + async #closeBatchSession() { + try { + await this._provider.persistDB(this._batchCloudDB); + } + finally { + this._batchCloudDB = undefined; + } + } + + async closeBatchSession() { + if (getContextType() === CONTEXT_BACKGROUND) + return this.#closeBatchSession(); + else + return send.closeCloudBatchSession(); + } + + accepts(node) { + return node && node.external === CLOUD_EXTERNAL_TYPE; + } + + async getParams(node) { + return {}; + } + + async _persistNodeObject(node) { + const nodeJSON = JSON.stringify(node); + await this._provider.assets.storeNode(node.uuid, nodeJSON); + } + + async persistNode(params) { + await this._persistNodeObject(params.node); + return this.withCloudDB(db => db.addNode(params.node)); + } + + async updateNode(params) { + return this.withCloudDB(db => { + db.updateNode(params.node); + const node = db.getNode(params.node.uuid); + return this._persistNodeObject(node); + }); + } + + async updateNodes(params) { + return this.withCloudDB(async db => { + for (const node of params.nodes) { + db.updateNode(node); + const updatedNode = db.getNode(node.uuid); + await this._persistNodeObject(updatedNode); + } + }); + } + + async deleteNodes(params) { + await this.deleteNodesShallow(params); + await this.deleteNodeContent(params); + } + + async deleteNodesShallow(params) { + return this.withCloudDB(db => { + const nodes = params.node_uuids.map(uuid => ({uuid})); + db.deleteNodes(nodes); + }); + } + + async deleteNodeContent(params) { + return this._provider.deleteAssets(params.node_uuids); + } + + async persistIcon(params) { + return this._provider.assets.storeIcon(params.uuid, params.icon_json); + } + + async persistArchiveIndex(params) { + return this._provider.assets.storeArchiveIndex(params.uuid, params.index_json); + } + + async persistArchive(params) { + //await this._provider.assets.storeArchiveObject(params.uuid, params.archive_json); + + if (params.contains === ARCHIVE_TYPE_FILES) { + const {entries} = await unzip(params.content); + + for (const [name, entry] of Object.entries(entries)) { + const filePath = `${UNPACKED_ARCHIVE_DIRECTORY}/${name.startsWith("/")? name.substring(1): name}`; + const bytes = await entry.arrayBuffer(); + await this._provider.assets.storeArchiveFile(params.uuid, bytes, filePath); + } + + return this._provider.assets.storeArchiveContent(params.uuid, params.content); + } + else + return this._provider.assets.storeArchiveContent(params.uuid, params.content); + } + + async getArchiveSize(params) { + + } + + async fetchArchiveContent(params) { + const node = params.node; + //archive = archive || await this._provider.assets.fetchArchiveObject(params.uuid); + + let content = await this._provider.assets.fetchArchiveContent(params.uuid); + + //archive = JSON.parse(archive); + + if (!node.contains || node.contains === ARCHIVE_TYPE_TEXT) { + const decoder = new TextDecoder(); + content = decoder.decode(content); + } + + return content; + } + + async fetchArchiveFile(params) { + const fileName = `${UNPACKED_ARCHIVE_DIRECTORY}/${params.file}`; + let content = await this._provider.assets.fetchArchiveFile(params.uuid, fileName); + + if (content) { + const decoder = new TextDecoder(); + content = decoder.decode(content); + } + + return content; + } + + async persistNotesIndex(params) { + return this._provider.assets.storeNotesIndex(params.uuid, params.index_json); + } + + async persistNotes(params) { + return this._provider.assets.storeNotes(params.uuid, params.notes_json); + } + + async fetchNotes(params) { + const json = await this._provider.assets.fetchNotes(params.uuid); + if (json) + return JSON.parse(json); + } + + async persistCommentsIndex(params) { + return this._provider.assets.storeCommentsIndex(params.uuid, params.index_json); + } + + async persistComments(params) { + return this._provider.assets.storeComments(params.uuid, params.comments_json); + } + + async fetchComments(params) { + const json = await this._provider.assets.fetchComments(params.uuid); + if (json) + return JSON.parse(json); + } +} + diff --git a/addon/storage_adapter_disk.js b/addon/storage_adapter_disk.js new file mode 100644 index 00000000..8ccebe94 --- /dev/null +++ b/addon/storage_adapter_disk.js @@ -0,0 +1,181 @@ +import {helperApp} from "./helper_app.js"; +import {settings} from "./settings.js"; +import {ARCHIVE_TYPE_TEXT} from "./storage.js"; + +export class StorageAdapterDisk { + async _postJSON(path, fields) { + try { + fields.data_path = settings.data_folder_path(); + + if (fields.data_path) + return helperApp.postJSON(path, fields); + } + catch (e) { + console.error(e); + } + } + + async _fetchJSON(path, fields) { + try { + fields.data_path = settings.data_folder_path(); + + if (fields.data_path) { + const response = await helperApp.postJSON(path, fields); + + if (response.ok) + return response.json(); + } + } + catch (e) { + console.error(e); + } + } + + accepts(node) { + return node && !node.external; + } + + async getParams(node) { + return {}; + } + + async persistNode(params) { + return this._postJSON("/storage/persist_node", params); + } + + async updateNode(params) { + return this._postJSON("/storage/update_node", params); + } + + async updateNodes(params) { + return this._postJSON("/storage/update_nodes", params); + } + + async deleteNodes(params) { + return this._postJSON("/storage/delete_nodes", params); + } + + async deleteNodesShallow(params) { + return this._postJSON("/storage/delete_nodes_shallow", params); + } + + async deleteNodeContent(params) { + return this._postJSON("/storage/delete_node_content", params); + } + + async persistIcon(params) { + return this._postJSON("/storage/persist_icon", params); + } + + async persistArchiveIndex(params) { + return this._postJSON("/storage/persist_archive_index", params); + } + + async persistArchive(params) { + const content = params.content; + + //delete params.content; + //await this._postJSON("/storage/persist_archive_object", params); + + const fields = { + data_path: settings.data_folder_path(), + content: new Blob([content]), + contains: params.contains, + uuid: params.uuid + }; + + try { + return helperApp.post(`/storage/persist_archive_content`, fields); + } catch (e) { + console.error(e); + } + } + + async getArchiveSize(params) { + const response = await this._postJSON("/storage/get_archive_size", params); + + if (response.ok) + return response.json(); + } + + async fetchArchiveContent(params) { + const node = params.node; + delete params.node; + //archive = archive || await this._fetchJSON("/storage/fetch_archive_object", params); + + params.data_path = settings.data_folder_path(); + + try { + const response = await helperApp.postJSON(`/storage/fetch_archive_content`, params); + + if (response.ok) { + let content = await response.arrayBuffer(); + + if (!node.contains || node.contains === ARCHIVE_TYPE_TEXT) { + const decoder = new TextDecoder(); + content = decoder.decode(content); + } + + return content; + } + + } catch (e) { + console.error(e); + } + } + + async fetchArchiveFile(params) { + params.data_path = settings.data_folder_path(); + + try { + const response = await helperApp.postJSON(`/storage/fetch_archive_file`, params); + + if (response.ok) { + let content = await response.arrayBuffer(); + const decoder = new TextDecoder(); + return decoder.decode(content); + } + } catch (e) { + console.error(e); + } + } + + async saveArchiveFile(params) { + params.data_path = settings.data_folder_path(); + params.content = new Blob([params.content]); + params.compute_index = true; + + try { + const response = await helperApp.post(`/storage/save_archive_file`, params); + + if (response.ok) + return response.json() + } catch (e) { + console.error(e); + } + } + + async persistNotesIndex(params) { + return this._postJSON("/storage/persist_notes_index", params); + } + + async persistNotes(params) { + return this._postJSON("/storage/persist_notes", params); + } + + async fetchNotes(params) { + return this._fetchJSON("/storage/fetch_notes", params); + } + + async persistCommentsIndex(params) { + return this._postJSON("/storage/persist_comments_index", params); + } + + async persistComments(params) { + return this._postJSON("/storage/persist_comments", params); + } + + async fetchComments(params) { + return this._fetchJSON("/storage/fetch_comments", params); + } +} diff --git a/addon/storage_adapter_files.js b/addon/storage_adapter_files.js new file mode 100644 index 00000000..1ada5196 --- /dev/null +++ b/addon/storage_adapter_files.js @@ -0,0 +1,94 @@ +import {helperApp} from "./helper_app.js"; +import {ARCHIVE_TYPE_TEXT} from "./storage.js"; + +export class StorageAdapterFiles { + async _postJSON(path, fields) { + try { + return helperApp.postJSON(path, fields); + } + catch (e) { + console.error(e); + } + } + + async _fetchJSON(path, fields) { + try { + const response = await helperApp.postJSON(path, fields); + + if (response.ok) + return response.json(); + } + catch (e) { + console.error(e); + } + } + + #getNotesFormat(path) { + path = path.toLowerCase(); + + if (path.endsWith(".org")) + return "org"; + else if (path.endsWith(".md")) + return "markdown"; + else + return "text"; + } + + accepts(node) { + return node && node.external === RDF_EXTERNAL_TYPE; + } + + async getParams(node) { + return { + path: node.external_id + }; + } + + async persistNotes(params) { + try { + return helperApp.postJSON(`/files/save_file_text`, params); + } catch (e) { + console.error(e); + } + } + + async fetchNotes(params) { + try { + const response = await helperApp.postJSON(`/files/fetch_file_text`, params); + + if (response.ok) { + const notes = {__file_as_notes: true}; + + notes.content = await response.text(); + notes.format = this.#getNotesFormat(params.path); + + return notes; + } + } catch (e) { + console.error(e); + } + } + + async fetchArchiveContent(params) { + const node = params.node; + delete params.node; + + try { + const response = await helperApp.postJSON(`/files/fetch_file_bytes`, params); + + if (response.ok) { + let content = await response.arrayBuffer(); + + if (!node.contains || node.contains === ARCHIVE_TYPE_TEXT) { + const decoder = new TextDecoder(); + content = decoder.decode(content); + } + + return content; + } + + } catch (e) { + console.error(e); + } + } +} diff --git a/addon/storage_adapter_rdf.js b/addon/storage_adapter_rdf.js new file mode 100644 index 00000000..dabeb1b2 --- /dev/null +++ b/addon/storage_adapter_rdf.js @@ -0,0 +1,66 @@ +import {helperApp} from "./helper_app.js"; +import {rdfShelf} from "./plugin_rdf_shelf.js"; + +export class StorageAdapterRDF { + async _postJSON(path, fields) { + try { + return helperApp.postJSON(path, fields); + } + catch (e) { + console.error(e); + } + } + + async _fetchJSON(path, fields) { + try { + const response = await helperApp.postJSON(path, fields); + + if (response.ok) + return response.json(); + } + catch (e) { + console.error(e); + } + } + + accepts(node) { + return node && node.external === RDF_EXTERNAL_TYPE; + } + + async getParams(node) { + return { + rdf_archive_path: await rdfShelf.getRDFArchiveDir(node) + }; + } + + async fetchArchiveFile(params) { + try { + const response = await helperApp.postJSON(`/rdf/fetch_archive_file`, params); + + if (response.ok) { + let content = await response.arrayBuffer(); + const decoder = new TextDecoder(); + return decoder.decode(content); + } + } catch (e) { + console.error(e); + } + } + + async saveArchiveFile(params) { + params.content = new Blob([params.content]); + + try { + const response = await helperApp.post(`/rdf/save_archive_file`, params); + + if (response.ok) + return response.json() + } catch (e) { + console.error(e); + } + } + + async persistComments(params) { + return this._postJSON("/rdf/persist_comments", params); + } +} diff --git a/addon/storage_archive.js b/addon/storage_archive.js new file mode 100644 index 00000000..0c915e5e --- /dev/null +++ b/addon/storage_archive.js @@ -0,0 +1,211 @@ +import {EntityIDB} from "./storage_idb.js"; +import {indexHTML} from "./utils_html.js"; +import {arrayToBinaryString, binaryString2Array, readBlob} from "./utils_io.js"; +import {Archive, Node} from "./storage_entities.js"; +import {delegateProxy} from "./proxy.js"; +import {ArchiveProxy} from "./storage_archive_proxy.js"; +import {ARCHIVE_TYPE_BYTES, ARCHIVE_TYPE_FILES, ARCHIVE_TYPE_TEXT} from "./storage.js"; +import {settings} from "./settings.js"; + +// An Archive entity has three fields: +// object - the contents of an archive, may be anything (the reify function deals with this) +// usually a String or ArrayBuffer +// byte_length - the presence of this field indicates that the archive was created from a non-text source; +// if object contains a string, this is a binary string +// type - mime type of the archive content + + +export class ArchiveIDB extends EntityIDB { + static newInstance() { + const instance = new ArchiveIDB(); + + instance.import = delegateProxy(new ArchiveProxy(), new ArchiveIDB()); + instance.import._importer = true; + + instance.idb = new ArchiveIDB(); + instance.idb.import = new ArchiveIDB(); + instance.idb.import._importer = true; + + return delegateProxy(new ArchiveProxy(), instance); + } + + entity(node, data, contentType, byteLength) { + contentType = contentType || "text/html"; + + if (typeof data !== "string" && data?.byteLength) // from ArrayBuffer + byteLength = data.byteLength; + + if (settings.storage_mode_internal()) { + if (typeof data === "string" && byteLength) // from binary string + data = binaryString2Array(data); + + data = data instanceof Blob? data: new Blob([data], {type: contentType}); + } + + const result = { + object: data, + byte_length: byteLength, + type: contentType + }; + + if (node) + result.node_id = node.id; + + return result; + } + + indexEntity(node, words) { + return { + node_id: node.id, + words: words + }; + } + + async storeIndex(node, words) { + const exists = await this._db.index.where("node_id").equals(node.id).count(); + const entity = this.indexEntity(node, words); + + if (exists) + return this._db.index.where("node_id").equals(node.id).modify(entity); + else + return this._db.index.add(entity); + } + + async fetchIndex(node) { + return this._db.index.where("node_id").equals(node.id).first(); + } + + async _add(node, archive) { + const exists = await this._db.blobs.where("node_id").equals(node.id).count(); + + if (exists) + await this._db.blobs.where("node_id").equals(node.id).modify(archive); + else { + archive.node_id = node.id; + await this._db.blobs.add(archive); + } + + return archive; + } + + async add(node, archive, index) { + if (settings.storage_mode_internal() && !(archive.object instanceof Blob)) + archive = Archive.entity(node, archive.object, archive.type, archive.byte_length); + + await this._add(node, archive); + await this.updateContentModified(node, archive); + + if (index?.words) + await this.storeIndex(node, index.words); + else if (typeof archive.object === "string" && !archive.byte_length) + await this.storeIndex(node, indexHTML(archive.object)); + } + + async updateContentModified(node, archive) { + if (!this._importer) { + node.contains = node.contains || (archive.byte_length? ARCHIVE_TYPE_BYTES: undefined) + node.content_type = archive.type; + node.size = settings.storage_mode_internal() + ? archive.object.size + : (await this.getSize(node))?.size; + + await Node.updateContentModified(node); + } + } + + async updateHTML(node, data) { + const entity = this.entity(node, data); + + if (node.contains === ARCHIVE_TYPE_FILES) { + const index = await this.saveFile(node, "index.html", data); + + if (index) + await this.idb.import.storeIndex(node, index); + } + else { + await this._add(node, entity); + await this.storeIndex(node, indexHTML(data)); + } + + return this.updateContentModified(node, entity); + } + + async get(node) { + return this._db.blobs.where("node_id").equals(node.id).first(); + } + + // get size of an archive, not including size of metadata and indexes + async getSize(node) { + // NOP, implemented in proxy + } + + // get file of an unpacked archive + async getFile(node, file) { + // NOP, implemented in proxy + } + + // save file of an unpacked archive + async saveFile(node, file, content) { + // NOP, implemented in proxy + } + + async delete(node) { + if (this._db.tables.some(t => t.name === "blobs")) + await this._db.blobs.where("node_id").equals(node.id).delete(); + + if (this._db.tables.some(t => t.name === "index")) + await this._db.index.where("node_id").equals(node.id).delete(); + } + + isUnpacked(node) { + return node.contains === ARCHIVE_TYPE_FILES; + } + + // reifying for JSON storage, leaves string (no byte_length) as is even if binarystring is specified + async reify(archive, binarystring = false) { + let result; + + if (!archive) + return null; + + if (archive.byte_length) { + if (archive.data) { // archive.data contains textual representation, may present in legacy databases + if (binarystring) + result = archive.data; + else + result = binaryString2Array(archive.data); + } + else if (archive.object instanceof Blob) { + if (binarystring) + result = await readBlob(archive.object, "binarystring"); + else + result = await readBlob(archive.object, "binary") + } + else if (typeof archive.object === "string") { + if (binarystring) + result = archive.object; + else + result = binaryString2Array(archive.object); + } + else { + if (binarystring) + result = arrayToBinaryString(archive.object); + else + result = archive.object; + } + } + else { + if (archive.data) + result = archive.data; + else if (archive.object instanceof Blob) + result = await readBlob(archive.object, "text"); + else if (typeof archive.object === "string") + result = archive.object; + else + result = arrayToBinaryString(archive.object); + } + + return result; + } +} + diff --git a/addon/storage_archive_proxy.js b/addon/storage_archive_proxy.js new file mode 100644 index 00000000..61f64db5 --- /dev/null +++ b/addon/storage_archive_proxy.js @@ -0,0 +1,116 @@ +import {MarshallerJSONScrapbook, UnmarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {Archive} from "./storage_entities.js"; +import {StorageProxy} from "./storage_proxy.js"; +import {settings} from "./settings.js"; + +export class ArchiveProxy extends StorageProxy { + #marshaller = new MarshallerJSONScrapbook(); + #unmarshaller = new UnmarshallerJSONScrapbook(); + + async storeIndex(node, words) { + const result = await this.wrapped.storeIndex(node, words); + + await this.#persistArchiveIndex(node, words); + + return result; + } + + async _add(node, archive) { + return this.#persistArchive(node, archive); + } + + async get(node) { + return this.#fetchArchive(node); + } + + async getSize(node) { + return this.#getArchiveSize(node); + } + + async getFile(node, file) { + return this.#fetchArchiveFile(node, file); + } + + async saveFile(node, file, content) { + return this.#saveArchiveFile(node, file, content); + } + + async #persistArchiveIndex(node, words) { + const adapter = this.adapter(node); + + if (adapter) { + let index = this.wrapped.indexEntity(node, words); + index = await this.#marshaller.convertIndex(index); + + const params = { + uuid: node.uuid, + index_json: JSON.stringify(index) + }; + + return adapter.persistArchiveIndex(params); + } + } + + async #persistArchive(node, archive) { + const adapter = this.adapter(node); + + if (adapter) { + const content = await Archive.reify(archive); + archive = await this.#marshaller.convertArchive(archive); + + delete archive.content; + + const params = { + uuid: node.uuid, + archive_json: JSON.stringify(archive), + content: content, + contains: node.contains + }; + + await adapter.persistArchive(params); + } + else if (settings.storage_mode_internal()) + return Archive.idb.add(node, archive); + + return archive; + } + + async #saveArchiveFile(node, file, content) { + const adapter = this.adapter(node); + + if (adapter) + return adapter.saveArchiveFile({uuid: node.uuid, file, content, ...await adapter.getParams(node)}); + } + + async #fetchArchive(node) { + const adapter = this.adapter(node); + + if (adapter) { + const content = await adapter.fetchArchiveContent({ + node, + uuid: node.uuid, + ...await adapter.getParams(node) + }); + + if (content) + return Archive.entity(node, content, node.content_type); + } + else if (settings.storage_mode_internal()) + return Archive.idb.get(node); + } + + async #fetchArchiveFile(node, file) { + const adapter = this.adapter(node); + + if (adapter) + return adapter.fetchArchiveFile({uuid: node.uuid, file, ...await adapter.getParams(node)}); + } + + async #getArchiveSize(node) { + const adapter = this.adapter(node); + + if (adapter) + return adapter.getArchiveSize({uuid: node.uuid, ...await adapter.getParams(node)}); + } +} + diff --git a/addon/storage_comments.js b/addon/storage_comments.js new file mode 100644 index 00000000..cdd715bf --- /dev/null +++ b/addon/storage_comments.js @@ -0,0 +1,77 @@ +import {EntityIDB} from "./storage_idb.js"; +import {indexString} from "./utils_html.js"; +import {Node} from "./storage_entities.js"; +import {delegateProxy} from "./proxy.js"; +import {CommentsProxy} from "./storage_comments_proxy.js"; + +export class CommentsIDB extends EntityIDB { + static newInstance() { + const instance = new CommentsIDB(); + + instance.import = delegateProxy(new CommentsProxy(), new CommentsIDB()); + instance.import._importer = true; + + instance.idb = {import: new CommentsIDB()}; + instance.idb.import._importer = true; + + return delegateProxy(new CommentsProxy(), instance); + } + + indexEntity(node, words) { + return { + node_id: node.id, + words: words + }; + } + + async storeIndex(node, words) { + const exists = await this._db.index_comments.where("node_id").equals(node.id).count(); + const entity = this.indexEntity(node, words); + + if (exists) + return this._db.index_comments.where("node_id").equals(node.id).modify(entity); + else + return this._db.index_comments.add(entity); + } + + async _add(node, text) { + const exists = await this._db.comments.where("node_id").equals(node.id).count(); + + if (!text) + text = undefined; + + if (exists) { + await this._db.comments.where("node_id").equals(node.id).modify({ + comments: text + }); + } + else { + await this._db.comments.add({ + node_id: node.id, + comments: text + }); + } + } + + async add(node, comments) { + await this._add(node, comments); + + if (!this._importer) { + node.has_comments = !!comments; + await Node.updateContentModified(node); + } + + if (comments) { + let words = indexString(comments); + await this.storeIndex(node, words); + } + else + await this.storeIndex(node, []); + } + + async get(node) { + let record = await this._db.comments.where("node_id").equals(node.id).first(); + return record?.comments; + } +} + diff --git a/addon/storage_comments_proxy.js b/addon/storage_comments_proxy.js new file mode 100644 index 00000000..86e2f041 --- /dev/null +++ b/addon/storage_comments_proxy.js @@ -0,0 +1,55 @@ +import {MarshallerJSONScrapbook, UnmarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {StorageProxy} from "./storage_proxy.js"; + +export class CommentsProxy extends StorageProxy { + #marshaller = new MarshallerJSONScrapbook(); + //#unmarshaller = new UnmarshallerJSONScrapbook(); + + async storeIndex(node, words) { + const result = await this.wrapped.storeIndex(node, words); + + await this.#persistCommentsIndex(node, words); + + return result; + } + + async _add(node, text) { + const result = await this.wrapped._add(node, text); + + await this.#persistComments(node, text); + + return result; + } + + async #persistCommentsIndex(node, words) { + const adapter = this.adapter(node); + + if (adapter) { + let index = this.wrapped.indexEntity(node, words); + index = await this.#marshaller.convertIndex(index); + + const params = { + uuid: node.uuid, + index_json: JSON.stringify(index) + }; + + return adapter.persistCommentsIndex(params); + } + } + + async #persistComments(node, text) { + const adapter = this.adapter(node); + + if (adapter) { + const comments = await this.#marshaller.convertComments(text); + + const params = { + uuid: node.uuid, + comments_json: JSON.stringify(comments), + ...await adapter.getParams(node) + }; + + await adapter.persistComments(params); + } + } +} diff --git a/addon/storage_database.js b/addon/storage_database.js new file mode 100644 index 00000000..abf44d36 --- /dev/null +++ b/addon/storage_database.js @@ -0,0 +1,62 @@ +import {EntityIDB} from "./storage_idb.js"; +import { + BROWSER_SHELF_ID, + DEFAULT_SHELF_ID, + CLOUD_SHELF_ID, + RDF_EXTERNAL_TYPE, + FILES_SHELF_ID +} from "./storage.js"; +import {Query} from "./storage_query.js"; + +class StorageDatabase extends EntityIDB { + async #wipe(retain) { + if (this._db.tables.some(t => t.name === "blobs")) + await this._db.blobs.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "index")) + await this._db.index.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "notes")) + await this._db.notes.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "icons")) + await this._db.icons.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "comments")) + await this._db.comments.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "index_notes")) + await this._db.index_notes.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "index_comments")) + await this._db.index_comments.where("node_id").noneOf(retain).delete(); + + if (this._db.tables.some(t => t.name === "tags")) + await this._db.tags.clear(); + + if (this._db.tables.some(t => t.name === "metadata")) + await this._db.metadata.clear(); + + return this._db.nodes.where("id").noneOf(retain).delete(); + } + + async wipeEverything() { + return this.#wipe([DEFAULT_SHELF_ID]); + } + + async wipeImportable() { + let retain = [DEFAULT_SHELF_ID, BROWSER_SHELF_ID, CLOUD_SHELF_ID, + ...(await Query.fullSubtreeOfIDs(FILES_SHELF_ID)), + ...(await Query.fullSubtreeOfIDs(BROWSER_SHELF_ID)), + ...(await Query.fullSubtreeOfIDs(CLOUD_SHELF_ID))]; + + const shelves = await Query.allShelves(); + const openRDFShelves = shelves.filter(n => n.external === RDF_EXTERNAL_TYPE); + for (const node of openRDFShelves) + retain = [...retain, ...await Query.fullSubtreeOfIDs(node.id)] + + return this.#wipe(retain); + } +} + +export const Database = new StorageDatabase(); diff --git a/addon/storage_entities.js b/addon/storage_entities.js new file mode 100644 index 00000000..9a338462 --- /dev/null +++ b/addon/storage_entities.js @@ -0,0 +1,11 @@ +import {IconIDB} from "./storage_icon.js"; +import {ArchiveIDB} from "./storage_archive.js"; +import {NodeIDB} from "./storage_node.js"; +import {CommentsIDB} from "./storage_comments.js"; +import {NotesIDB} from "./storage_notes.js"; + +export let Node = NodeIDB.newInstance(); +export let Archive = ArchiveIDB.newInstance(); +export let Comments = CommentsIDB.newInstance(); +export let Notes = NotesIDB.newInstance(); +export let Icon = IconIDB.newInstance(); diff --git a/addon/storage_export.js b/addon/storage_export.js new file mode 100644 index 00000000..2a7314f0 --- /dev/null +++ b/addon/storage_export.js @@ -0,0 +1,25 @@ +import {EntityIDB} from "./storage_idb.js"; + +class ExportAreaIDB extends EntityIDB { + addBlob(exportId, blob) { + return this._db.export_storage.add({ + process_id: exportId, + blob + }); + } + + async getBlobs(exportId) { + const blobs = await this._db.export_storage.where("process_id").equals(exportId).sortBy("id") + return blobs.map(b => b.blob); + } + + removeBlobs(exportId) { + return this._db.export_storage.where("process_id").equals(exportId).delete(); + } + + wipe() { + return this._db.export_storage.clear(); + } +} + +export let ExportArea = new ExportAreaIDB(); diff --git a/addon/storage_external.js b/addon/storage_external.js new file mode 100644 index 00000000..5ec58db5 --- /dev/null +++ b/addon/storage_external.js @@ -0,0 +1,85 @@ +import {StorageAdapterDisk} from "./storage_adapter_disk.js"; +import {settings} from "./settings.js"; +import {StorageProxy} from "./storage_proxy.js"; +import {CLOUD_EXTERNAL_TYPE} from "./storage.js"; +import {CONTEXT_BACKGROUND, getContextType} from "./utils_browser.js"; +import {receive} from "./proxy.js"; + +class StorageDisk extends StorageAdapterDisk { + wipeStorage() { + if (!settings.storage_mode_internal()) + return this._postJSON("/storage/wipe", {}); + } + + openBatchSession() { + if (!settings.storage_mode_internal()) + return this._postJSON("/storage/open_batch_session", {}); + } + + closeBatchSession() { + if (!settings.storage_mode_internal()) + return this._postJSON("/storage/close_batch_session", {}); + } + + async isBatchSessionOpen() { + if (!settings.storage_mode_internal()) { + const response = await this._postJSON("/storage/is_batch_session_open", {}); + + if (response.ok) { + const json = await response.json(); + return json.result; + } + } + } + + async deleteOrphanedItems(orphanedItems) { + if (!settings.storage_mode_internal()) + return this._postJSON("/storage/delete_orphaned_items", {node_uuids: orphanedItems}); + } +} + +export const DiskStorage = new StorageDisk(); + +class StorageCloud { + async openBatchSession() { + return StorageProxy.cloudAdapter.openBatchSession(); + } + + async closeBatchSession() { + return StorageProxy.cloudAdapter.closeBatchSession(); + } +} + +export const CloudStorage = new StorageCloud(); + +class StorageExternal { + async openBatchSession(referenceNode) { + await DiskStorage.openBatchSession(); + + if (referenceNode.external === CLOUD_EXTERNAL_TYPE) + await CloudStorage.openBatchSession(); + } + + async closeBatchSession(referenceNode) { + await DiskStorage.closeBatchSession(); + + if (referenceNode.external === CLOUD_EXTERNAL_TYPE) + await CloudStorage.closeBatchSession(); + } + + async isBatchSessionOpen() { + return DiskStorage.isBatchSessionOpen(); + } +} + +export const ExternalStorage = new StorageExternal(); + +if (getContextType() === CONTEXT_BACKGROUND) { + receive.openCloudBatchSession = message => { + return CloudStorage.openBatchSession(); + }; + + receive.closeCloudBatchSession = message => { + return CloudStorage.closeBatchSession(); + }; +} diff --git a/addon/storage_icon.js b/addon/storage_icon.js new file mode 100644 index 00000000..97c04942 --- /dev/null +++ b/addon/storage_icon.js @@ -0,0 +1,65 @@ +import {EntityIDB} from "./storage_idb.js"; +import {Node} from "./storage_entities.js"; +import {delegateProxy} from "./proxy.js"; +import {IconProxy} from "./storage_icon_proxy.js"; +import {computeSHA1} from "./utils.js"; + +export class IconIDB extends EntityIDB { + static newInstance() { + const instance = new IconIDB(); + + instance.import = delegateProxy(new IconProxy(), new IconIDB()); + instance.import._importer = true; + + instance.idb = new IconIDB(); + instance.idb.import = new IconIDB(); + instance.idb.import._importer = true; + + return delegateProxy(new IconProxy(), instance); + } + + async add(node, dataUrl) { + const exists = node.id? await this._db.icons.where("node_id").equals(node.id).count(): false; + const entity = this.entity(node, dataUrl); + let iconId; + + if (exists) + await this._db.icons.where("node_id").equals(node.id).modify(entity); + else + iconId = await this._db.icons.add(entity); + + if (node.id && !this._importer) + await Node.updateContentModified(node); // new content_modified + + return iconId; + } + + entity(node, dataUrl) { + return { + node_id: node.id, + data_url: dataUrl + }; + } + + persist(node, dataUrl) { + // NOP, implemented in proxy + } + + async update(iconId, options) { + await this._db.icons.update(iconId, options); + } + + async get(node) { + const icon = await this._db.icons.where("node_id").equals(node.id).first(); + + if (icon) + return icon.data_url; + + return null; + } + + async computeHash(iconUrl) { + return "hash:" + (await computeSHA1(iconUrl)); + } +} + diff --git a/addon/storage_icon_proxy.js b/addon/storage_icon_proxy.js new file mode 100644 index 00000000..c9e253c0 --- /dev/null +++ b/addon/storage_icon_proxy.js @@ -0,0 +1,37 @@ +import {MarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {StorageProxy} from "./storage_proxy.js"; +import {Icon} from "./storage_entities.js"; + +export class IconProxy extends StorageProxy { + #marshaller = new MarshallerJSONScrapbook(); + + async add(node, dataUrl) { + const result = await this.wrapped.add(node, dataUrl); + + if (node.uuid) + await this.#persistIcon(node, dataUrl); + + return result; + } + + async persist(node, dataUrl) { + await this.#persistIcon(node, dataUrl); + } + + async #persistIcon(node, dataUrl) { + const adapter = this.adapter(node); + + if (adapter) { + let icon = this.wrapped.entity(node, dataUrl); + icon = this.#marshaller.convertIcon(icon); + + const params = { + uuid: node.uuid, + icon_json: JSON.stringify(icon) + }; + + return adapter.persistIcon(params); + } + } +} + diff --git a/addon/storage_idb.js b/addon/storage_idb.js new file mode 100644 index 00000000..cd7036ad --- /dev/null +++ b/addon/storage_idb.js @@ -0,0 +1,89 @@ +import {DEFAULT_SHELF_NAME, DEFAULT_SHELF_UUID, NODE_TYPE_SHELF,} from "./storage.js"; + +import {Dexie} from "./lib/dexie.js"; + +const dexie = new Dexie("scrapyard"); + +dexie.version(1).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name` +}); +dexie.version(2).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name` +}); +dexie.version(3).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name`, + icons: `++id,&node_id` +}); +dexie.version(4).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name`, + icons: `++id,&node_id`, + comments: `++id,&node_id` +}); +dexie.version(5).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name`, + icons: `++id,&node_id`, + comments: `++id,&node_id`, + index_notes: `++id,&node_id,*words`, + index_comments: `++id,&node_id,*words` +}); +dexie.version(6).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name`, + icons: `++id,&node_id`, + comments: `++id,&node_id`, + index_notes: `++id,&node_id,*words`, + index_comments: `++id,&node_id,*words`, + export_storage: `++id,process_id`, +}); +dexie.version(7).stores({ + nodes: `++id,&uuid,parent_id,type,name,uri,tag_list,date_added,date_modified,todo_state,todo_date,external,external_id`, + blobs: `++id,&node_id,size`, + index: `++id,&node_id,*words`, + notes: `++id,&node_id`, + tags: `++id,name`, + icons: `++id,&node_id`, + comments: `++id,&node_id`, + index_notes: `++id,&node_id,*words`, + index_comments: `++id,&node_id,*words`, + export_storage: `++id,process_id`, + undo: `++id,operation,stack` +}); + +dexie.on('populate', () => { + dexie.nodes.add({name: DEFAULT_SHELF_NAME, type: NODE_TYPE_SHELF, uuid: DEFAULT_SHELF_UUID, date_added: new Date(), + date_modified: new Date(0), pos: 1}); +}); + +export class EntityIDB { + constructor() { + this._db = dexie; + } + + transaction(mode, table, operation) { + return this._db.transaction(mode, this._db[table], operation); + } +} + diff --git a/addon/storage_node.js b/addon/storage_node.js new file mode 100644 index 00000000..88de4ad5 --- /dev/null +++ b/addon/storage_node.js @@ -0,0 +1,209 @@ +import {EntityIDB} from "./storage_idb.js"; +import {NODE_PROPERTIES} from "./storage.js"; +import UUID from "./uuid.js"; +import {delegateProxy} from "./proxy.js"; +import {NodeProxy} from "./storage_node_proxy.js"; +import {settings} from "./settings.js"; + +export class NodeIDB extends EntityIDB { + static newInstance() { + const instance = new NodeIDB(); + + // bypass the proxy + instance.idb = new NodeIDB(); + + return delegateProxy(new NodeProxy(), instance); + } + + resetDates(node) { + node.date_added = new Date(); + node.date_modified = node.date_added; + } + + fixDates(node) { + // Chrome structured cloning turns dates to strings + if (settings.platform.chrome) { + if (typeof node.date_added === "string") + node.date_added = new Date(node.date_added); + + if (typeof node.date_modified === "string") + node.date_modified = new Date(node.date_modified); + + if (typeof node.content_modified === "string") + node.content_modified = new Date(node.content_modified); + } + } + + setUUID(node) { + node.uuid = UUID.numeric(); + } + + strip(node) { + delete node.tag_list; + } + + async getIdFromUUID(uuid) { + let node = await this._db.nodes.where("uuid").equals(uuid).first(); + return node?.id; + } + + async getUUIDFromId(id) { + let node = await this._db.nodes.where("id").equals(id).first(); + return node?.uuid; + } + + sanitize(node) { + for (let key of Object.keys(node)) { + if (!NODE_PROPERTIES.some(k => k === key)) + delete node[key]; + } + + return node; + } + + sanitized(node) { + node = Object.assign({}, node); + return this.sanitize(node); + } + + async add(node) { + node.pos = undefined; + this.setUUID(node); + this.resetDates(node); + + node.id = await this._add(node); + return node; + } + + async import(node) { + node.id = await this._add(node); + return node; + } + + async _add(node) { + return this._db.nodes.add(this.sanitized(node)) + } + + async put(node) { + await this._db.nodes.put(this.sanitized(node)); + return node; + } + + // retains node in IDB, but removes from storage + async unpersist(node) { + // NOP, implemented in proxy + } + + exists(node) { + if (!node.uuid) + return false; + + return this._db.nodes.where("uuid").equals(node.uuid).count(); + } + + async urlExists(url) { + if (!url) + return false; + return !!await this._db.nodes.where("uri").equals(url).count(); + } + + async get(ids) { + if (!ids) + return this._db.nodes.toArray(); + + if (Array.isArray(ids)) + return this._db.nodes.where("id").anyOf(ids).toArray(); + else + return this._db.nodes.where("id").equals(ids).first(); + } + + async getByUUID(uuid) { + return this._db.nodes.where("uuid").equals(uuid).first(); + } + + getChildren(id) { + return this._db.nodes.where("parent_id").equals(id).toArray(); + } + + async update(node, resetDateModified = true) { + if (Array.isArray(node)) { + for (let node_ of node) + await this.update(node_, resetDateModified) + } + else if (node?.id) { + if (resetDateModified) + node.date_modified = new Date(); + + this.fixDates(node); + await this._db.nodes.update(node.id, this.sanitized(node)); + } + else { + console.error("Updating a node without id or a null reference", node); + } + + return node; + } + + async batchUpdate(updater, ids) { + const withPostprocessing = node => { + updater(node) + node.date_modified = new Date(); + this.sanitize(node); + }; + + if (ids) + this._db.nodes.where("id").anyOf(ids).modify(withPostprocessing) + else + await this._db.nodes.toCollection().modify(withPostprocessing); + } + + async updateContentModified(node) { + node.date_modified = new Date(); + node.content_modified = node.date_modified; + + return this.update(node, false); + } + + iterate(iterator, filter) { + if (filter) + return this._db.nodes.filter(filter).each(iterator); + else + return this._db.nodes.each(iterator); + } + + filter(filter) { + return this._db.nodes.filter(filter).toArray(); + } + + async delete(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + await this.deleteDependencies(nodes); + + return this.deleteShallow(nodes); + } + + async deleteShallow(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + const ids = nodes.map(n => n.id); + return this._db.nodes.bulkDelete(ids); + } + + async deleteDependencies(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + const ids = nodes.map(n => n.id); + await this._db.blobs?.where("node_id").anyOf(ids).delete(); + await this._db.notes?.where("node_id").anyOf(ids).delete(); + await this._db.icons?.where("node_id").anyOf(ids).delete(); + await this._db.comments?.where("node_id").anyOf(ids).delete(); + await this._db.index?.where("node_id").anyOf(ids).delete(); + await this._db.index_notes?.where("node_id").anyOf(ids).delete(); + await this._db.index_comments?.where("node_id").anyOf(ids).delete(); + } +} + diff --git a/addon/storage_node_external.js b/addon/storage_node_external.js new file mode 100644 index 00000000..06e5a789 --- /dev/null +++ b/addon/storage_node_external.js @@ -0,0 +1,52 @@ +import {EntityIDB} from "./storage_idb.js"; +import {Node} from "./storage_entities.js"; + +export class ExternalNodeIDB extends EntityIDB { + _Node = Node; + + static newInstance() { + const instance = new ExternalNodeIDB(); + + instance.idb = new ExternalNodeIDB(); + instance.idb._Node = Node.idb; + + return instance; + } + + get(...args) { + let externalId, kind; + + if (args.length === 2) { + externalId = args[0]; + kind = args[1]; + } + else + kind = args[0]; + + if (externalId) + return this._db.nodes.where("external_id").equals(externalId).and(n => n.external === kind).first(); + else + return this._db.nodes.where("external").equals(kind).toArray(); + } + + async exists(externalId, kind) { + return !!(await this._db.nodes.where("external_id").equals(externalId).and(n => n.external === kind).count()); + } + + async delete(kind) { + const nodes = await this._db.nodes.where("external").equals(kind).toArray(); + return this._Node.delete(nodes); + } + + async deleteMissingIn(retainExternalIDs, kind) { + const retain = new Set(retainExternalIDs); + + const nodes = await this._db.nodes.where("external").equals(kind) + .and(n => n.external_id && !retain.has(n.external_id)) + .toArray(); + + return this._Node.delete(nodes); + } +} + +export let ExternalNode = ExternalNodeIDB.newInstance(); diff --git a/addon/storage_node_proxy.js b/addon/storage_node_proxy.js new file mode 100644 index 00000000..14c12cfc --- /dev/null +++ b/addon/storage_node_proxy.js @@ -0,0 +1,158 @@ +import {Node} from "./storage_entities.js"; +import {MarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {StorageProxy} from "./storage_proxy.js"; + +export class NodeProxy extends StorageProxy { + #marshaller = new MarshallerJSONScrapbook(); + + async _add(node) { + const result = await this.wrapped._add(node); + + await this.#persistNode(node); + + return result; + } + + async put(node) { + const result = await this.wrapped.put(node); + + await this.#persistNode(node); + + return result; + } + + async update(node, resetDateModified = true) { + const result = await this.wrapped.update(node, resetDateModified); + + if (Array.isArray(node)) + await this.#updateNodes(node); + else + await this.#updateNode(node); + + return result; + } + + async batchUpdate(updater, ids) { + const result = await this.wrapped.batchUpdate(updater, ids); + const nodes = await Node.get(ids); + + await this.#updateNodes(nodes); + + return result; + } + + async unpersist(node) { + return this.#unpersistNode(node); + } + + async deleteShallow(nodes) { + const result = await this.wrapped.deleteShallow(nodes); + + await this.#deleteNodesShallow(nodes); + + return result; + } + + async deleteDependencies(nodes) { + const result = await this.wrapped.deleteDependencies(nodes); + + await this.#deleteNodeContent(nodes); + + return result; + } + + async #persistNode(node) { + const adapter = this.adapter(node); + + if (adapter) { + node = this.#marshaller.serializeNode(node); + node = await this.#marshaller.convertNode(node); + + const params = { + node: node + }; + + return adapter.persistNode(params); + } + } + + async #updateNode(node) { + const adapter = this.adapter(node); + + if (adapter) { + const params = { + remove_fields: Object.keys(node).filter(k => node.hasOwnProperty(k) && node[k] === undefined) + }; + + if (!node.uuid) + node.uuid = await Node.getUUIDFromId(node.id); + + node = this.#marshaller.serializeNode(node); + params.node = await this.#marshaller.convertNode(node); + + return adapter.updateNode(params); + } + } + + async #updateNodes(nodes) { + const adapter = this.adapter(nodes); + + if (adapter) { + const params = { + remove_fields: nodes.map(node => + Object.keys(node).filter(k => node.hasOwnProperty(k) && node[k] === undefined)) + }; + + params.nodes = await Promise.all(nodes.map(async node => { + node = this.#marshaller.serializeNode(node); + node = await this.#marshaller.convertNode(node); + return node; + })); + + return adapter.updateNodes(params); + } + } + + async #unpersistNode(node) { + const adapter = this.adapter(node); + + if (adapter) { + const params = { + node_uuids: [node.uuid] + }; + + return adapter.deleteNodes(params); + } + } + + async #deleteNodesShallow(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + const adapter = this.adapter(nodes); + + if (adapter) { + const params = { + node_uuids: nodes.map(n => n.uuid) + }; + + return adapter.deleteNodesShallow(params); + } + } + + async #deleteNodeContent(nodes) { + if (!Array.isArray(nodes)) + nodes = [nodes]; + + const adapter = this.adapter(nodes); + + if (adapter) { + const params = { + node_uuids: nodes.map(n => n.uuid) + }; + + return adapter.deleteNodeContent(params); + } + } +} + diff --git a/addon/storage_notes.js b/addon/storage_notes.js new file mode 100644 index 00000000..5f335e63 --- /dev/null +++ b/addon/storage_notes.js @@ -0,0 +1,84 @@ +import {EntityIDB} from "./storage_idb.js"; +import {indexHTML, indexString} from "./utils_html.js"; +import {notes2html} from "./notes_render.js"; +import {Node} from "./storage_entities.js"; +import {delegateProxy} from "./proxy.js"; +import {NotesProxy} from "./storage_notes_proxy.js"; + +export class NotesIDB extends EntityIDB { + static newInstance() { + const instance = new NotesIDB(); + + instance.import = delegateProxy(new NotesProxy(), new NotesIDB()); + instance.import._importer = true; + + instance.idb = new NotesIDB(); + instance.idb.import = new NotesIDB(); + instance.idb.import._importer = true; + + return delegateProxy(new NotesProxy(), instance); + } + + indexEntity(node, words) { + return { + node_id: node.id, + words: words + }; + } + + async storeIndex(node, words) { + const exists = await this._db.index_notes.where("node_id").equals(node.id).count(); + const entity = this.indexEntity(node, words); + + if (exists) + return this._db.index_notes.where("node_id").equals(node.id).modify(entity); + else + return this._db.index_notes.add(entity); + } + + async _add(node, options) { + const exists = await this._db.notes.where("node_id").equals(options.node_id).count(); + + if (exists) + await this._db.notes.where("node_id").equals(options.node_id).modify(options); + else + await this._db.notes.add(options); + } + + async add(node, options, propertyChange) { + await this._add(node, options, propertyChange); + + if (!this._importer) { + node.has_notes = propertyChange || !!options.content; + await Node.updateContentModified(node); + } + + if (options.content) { + let words; + + if (options.format === "delta" && options.html) + words = indexHTML(options.html); + else { + if (options.format === "text") + words = indexString(options.content); + else { + let html = notes2html(options); + if (html) + words = indexHTML(html); + } + } + + if (words) + await this.storeIndex(node, words); + else + await this.storeIndex(node, []); + } + else + await this.storeIndex(node, []); + } + + async get(node) { + return this._db.notes.where("node_id").equals(node.id).first(); + } +} + diff --git a/addon/storage_notes_proxy.js b/addon/storage_notes_proxy.js new file mode 100644 index 00000000..b18981fe --- /dev/null +++ b/addon/storage_notes_proxy.js @@ -0,0 +1,72 @@ +import {MarshallerJSONScrapbook, UnmarshallerJSONScrapbook} from "./marshaller_json_scrapbook.js"; +import {StorageProxy} from "./storage_proxy.js"; +import {Notes} from "./storage_entities.js"; +import {settings} from "./settings.js"; + +export class NotesProxy extends StorageProxy { + #marshaller = new MarshallerJSONScrapbook(); + #unmarshaller = new UnmarshallerJSONScrapbook(); + + async storeIndex(node, words) { + const result = await this.wrapped.storeIndex(node, words); + + await this.#persistNotesIndex(node, words); + + return result; + } + + async _add(node, options) { + return this.#persistNotes(node, options); + } + + async get(node) { + return this.#fetchNotes(node); + } + + async #persistNotesIndex(node, words) { + const adapter = this.adapter(node); + + if (adapter) { + let index = this.wrapped.indexEntity(node, words); + index = await this.#marshaller.convertIndex(index); + + const params = { + uuid: node.uuid, + index_json: JSON.stringify(index) + }; + + return adapter.persistNotesIndex(params); + } + } + + async #persistNotes(node, options) { + const adapter = this.adapter(node); + + if (adapter) { + const notes = await this.#marshaller.convertNotes(options); + + const params = { + uuid: node.uuid, + notes_json: JSON.stringify(notes), + ...await adapter.getParams(node) + }; + + await adapter.persistNotes(params); + } + else if (settings.storage_mode_internal()) + return Notes.idb.add(node, options); + } + + async #fetchNotes(node) { + const adapter = this.adapter(node); + + if (adapter) { + const notes = await adapter.fetchNotes({uuid: node.uuid, ...await adapter.getParams(node)}); + + if (notes) + return this.#unmarshaller.unconvertNotes(notes); + } + else if (settings.storage_mode_internal()) + return Notes.idb.get(node); + } +} diff --git a/addon/storage_proxy.js b/addon/storage_proxy.js new file mode 100644 index 00000000..cf957d55 --- /dev/null +++ b/addon/storage_proxy.js @@ -0,0 +1,53 @@ +import {CLOUD_EXTERNAL_TYPE, FILES_EXTERNAL_TYPE, RDF_EXTERNAL_TYPE} from "./storage.js"; +import {StorageAdapterDisk} from "./storage_adapter_disk.js"; +import {StorageAdapterCloud} from "./storage_adapter_cloud.js"; +import {StorageAdapterFiles} from "./storage_adapter_files.js"; +import {StorageAdapterRDF} from "./storage_adapter_rdf.js"; +import {settings} from "./settings.js"; +import {nullDelegatingProxy} from "./proxy.js"; + + +export class StorageProxy { + static _adapterDisk = new StorageAdapterDisk(); + static _adapterCloud = new StorageAdapterCloud(); + static _adapterRDF = nullDelegatingProxy(new StorageAdapterRDF()); + static _adapterFiles = nullDelegatingProxy(new StorageAdapterFiles()); + + static setCloudProvider(provider) { + this._adapterCloud.setProvider(provider); + } + + static get cloudAdapter() { + return this._adapterCloud; + } + + adapter(node) { + if (Array.isArray(node)) { + const distinctExternals = node + .map(n => n.external) + .filter((v, i, a) => a.indexOf(v) === i); + + if (distinctExternals.length > 1) + throw new Error("Operation on nodes from shelves with heterogeneous storage."); + + node = node?.[0]; + } + + if (!node) + return; + + if (node.external === CLOUD_EXTERNAL_TYPE) + return StorageProxy._adapterCloud; + else if (node.external === RDF_EXTERNAL_TYPE) + return StorageProxy._adapterRDF; + else if (node.external === FILES_EXTERNAL_TYPE) + return StorageProxy._adapterFiles; + else if (!node.external) { + if (settings.storage_mode_internal()) + return null; + else + return StorageProxy._adapterDisk; + } + } + +} diff --git a/addon/storage_query.js b/addon/storage_query.js new file mode 100644 index 00000000..67c1fbac --- /dev/null +++ b/addon/storage_query.js @@ -0,0 +1,331 @@ +import { + byPosition, + DONE_SHELF_NAME, EVERYTHING_SHELF_NAME, + isContainerNode, isVirtualShelf, + NODE_TYPE_FOLDER, + NODE_TYPE_SHELF, + NODE_TYPE_UNLISTED, + TODO_SHELF_NAME, + TODO_STATE_DONE +} from "./storage.js"; + +import {EntityIDB} from "./storage_idb.js"; +import {Node} from "./storage_entities.js"; + +class QueryIDB extends EntityIDB { + + allNodeIDs() { + return this._db.nodes.orderBy("id").keys(); + } + + async selectDirectChildrenIdsOf(id, children) { + await this._db.nodes.where("parent_id").equals(id).each(n => children.push(n.id)); + } + + async ascendantIdsOf(id) { + let node = id; + if (typeof id === "number") + node = await Node.get(id); + + const ascendantIds = []; + let parentId = node.parent_id; + while (parentId) { + ascendantIds.push(parentId); + const parentNode = await Node.get(parentId); + parentId = parentNode.parent_id; + } + + return ascendantIds; + } + + async rootOf(id) { + const ascendants = await this.ascendantIdsOf(id); + return Node.get(ascendants[0]); + } + + async selectAllChildrenIdsOf(id, children) { + let directChildren = []; + await this._db.nodes.where("parent_id").equals(id) + .each(n => directChildren.push([n.id, isContainerNode(n)])); + + if (directChildren.length) { + for (let child of directChildren) { + children.push(child[0]); + if (child[1]) + await this.selectAllChildrenIdsOf(child[0], children); + } + } + } + + async fullSubtreeOfIDs(nodeIDs) { + if (!Array.isArray(nodeIDs)) + nodeIDs = [nodeIDs]; + + let children = []; + + for (let id of nodeIDs) { + children.push(id); + await this.selectAllChildrenIdsOf(id, children); + } + + return children; + } + + async _selectAllChildrenOf(node, children, preorder, level) { + let directChildren = await this._db.nodes.where("parent_id").equals(node.id).toArray(); + + if (directChildren.length) { + if (preorder) + directChildren.sort(byPosition); + + for (let child of directChildren) { + if (level !== undefined) + child.__level = level; + + children.push(child); + + if (isContainerNode(child)) + await this._selectAllChildrenOf(child, children, preorder, level !== undefined? level + 1: undefined); + } + } + } + + async fullSubtree(nodeIDs, preorder, level) { + if (!Array.isArray(nodeIDs)) + nodeIDs = [nodeIDs]; + + let nodes = await Node.get(nodeIDs); + let children = []; + + if (preorder) + nodes.sort(byPosition); + + for (let node of nodes) { + if (node) { + if (level !== undefined) + node.__level = level; + + children.push(node); + + if (isContainerNode(node)) + await this._selectAllChildrenOf(node, children, preorder, level !== undefined? level + 1: undefined); + } + } + + return children; + } + + async nodes(folder, options) { + let {search, tags, date, date2, period, types, path, limit, depth, order} = options; + let searchrx = search? new RegExp(search, "i"): null; + let query = this._db.nodes; + + path = path || EVERYTHING_SHELF_NAME; + + const todoShelf = path?.toUpperCase() === TODO_SHELF_NAME; + const doneShelf = path?.toUpperCase() === DONE_SHELF_NAME; + const virtualShelf = isVirtualShelf(path); + + if (folder) { + let subtree = []; + + if (depth === "group") + await this.selectDirectChildrenIdsOf(folder.id, subtree); + else if (depth === "root+subtree") { + await this.selectAllChildrenIdsOf(folder.id, subtree); + subtree.push(folder.id); + } + else // "subtree" + await this.selectAllChildrenIdsOf(folder.id, subtree); + + query = query.where("id").anyOf(subtree); + } + + if (date) { + date = (new Date(date)).getTime(); + date2 = (new Date(date2)).getTime(); + if (isNaN(date)) + date = null; + if (isNaN(date2)) + date2 = null; + if (date && (period === "before" || period === "after")) + period = period === "after" ? 1 : -1; + else if (date && date2 && period === "between") + period = 2; + else + period = 0; + } + + let byOptions = node => { + let result = virtualShelf? true: !!folder; + + if (types) + result = result && types.some(t => t == node.type); + + if (todoShelf) + result = result && node.todo_state && node.todo_state < TODO_STATE_DONE; + else if (doneShelf) + result = result && node.todo_state && node.todo_state >= TODO_STATE_DONE; + + if (search) + result = result && (searchrx.test(node.name) || searchrx.test(node.uri)); + else if (tags) { + if (node.tag_list) { + let intersection = tags.filter(value => node.tag_list.some(t => t.startsWith(value))); + result = result && intersection.length > 0; + } + else + result = false; + } + else if (date) { + const nodeMillis = node.date_added?.getTime? node.date_added.getTime(): undefined; + + if (nodeMillis) { + let nodeDate = new Date(nodeMillis); + nodeDate.setUTCHours(0, 0, 0, 0); + nodeDate = nodeDate.getTime(); + + if (period === 0) + result = result && date === nodeDate; + else if (period === 1) + result = result && date < nodeDate; + else if (period === -1) + result = result && date > nodeDate; + else if (period === 2) + result = result && nodeDate >= date && nodeDate <= date2; + } + else + result = false; + } + + return result; + }; + + query = query.filter(byOptions); + + if (limit) + query = query.limit(limit); + + return await query.toArray(); + } + + // returns nodes containing only the all given words + async nodesByIndex(ids, words, entityName, partialMatching) { + let matchingNodeIds; + + if (partialMatching) + matchingNodeIds = await this.partiallyMatchWords(ids, words, entityName); + else + matchingNodeIds = await this.prefixMatchWords(ids, words, entityName); + + return Node.get(matchingNodeIds); + } + + async partiallyMatchWords(ids, words, entityName) { + const matchingNodeIds = []; + let indexItems = this.selectIndex(entityName); + + if (ids) + indexItems = indexItems.where("node_id").anyOf(ids); + + await indexItems.each(idx => { + const foundWords = words.map(_ => false); + + for (let i = 0; i < idx.words.length; ++i) + for (let w = 0; w < words.length; ++w) + if (idx.words[i].indexOf(words[w]) !== -1) { + foundWords[w] = true; + break; + } + + if (foundWords.every(w => w)) + matchingNodeIds.push(idx.node_id); + }); + + return matchingNodeIds; + } + + async prefixMatchWords(ids, words, entityName) { + const matchingNodeIds = []; + + const indexItems = ids + ? this.selectIndex(entityName).where("words").startsWithAnyOf(words) + .and(i => ids.some(id => id === i.node_id)) + : this.selectIndex(entityName).where("words").startsWithAnyOf(words); + + await indexItems.each(idx => { + const foundWords = words.map(_ => false); + + for (let i = 0; i < idx.words.length; ++i) + for (let w = 0; w < words.length; ++w) + if (idx.words[i].startsWith(words[w])) { + foundWords[w] = true; + break; + } + + if (foundWords.every(w => w)) + matchingNodeIds.push(idx.node_id); + }); + + return matchingNodeIds; + } + + selectIndex(index) { + switch (index) { + case "notes": + return this._db.index_notes; + case "comments": + return this._db.index_comments; + default: + return this._db.index; + } + }; + + async unlisted(name) { + let where = this._db.nodes.where("type").equals(NODE_TYPE_UNLISTED); + + if (name) { + name = name.toLocaleUpperCase(); + return await where.and(n => name === n.name.toLocaleUpperCase()).first(); + } + else + return await where.toArray(); + } + + async shelf(name) { + let where = this._db.nodes.where("type").equals(NODE_TYPE_SHELF).and(n => !n.parent_id); + + if (name) { + name = name.toLocaleUpperCase(); + return await where.and(n => name === n.name.toLocaleUpperCase()).first(); + } + else + return await where.toArray(); + } + + async allShelves() { + return this.shelf(); + } + + async allFolders() { + const nodes = await this._db.nodes.where("type").anyOf([NODE_TYPE_SHELF, NODE_TYPE_FOLDER]).toArray(); + return nodes.sort(byPosition); + } + + subfolder(parentId, name) { + name = name.toLocaleUpperCase(); + return this._db.nodes.where("parent_id").equals(parentId) + .and(n => n.type === NODE_TYPE_FOLDER && name === n.name.toLocaleUpperCase()) + .first(); + } + + todo() { + return this._db.nodes.where("todo_state").below(TODO_STATE_DONE).toArray(); + } + + done() { + return this._db.nodes.where("todo_state").aboveOrEqual(TODO_STATE_DONE).toArray(); + } +} + +export let Query = new QueryIDB(); diff --git a/addon/storage_undo.js b/addon/storage_undo.js new file mode 100644 index 00000000..2209a9d0 --- /dev/null +++ b/addon/storage_undo.js @@ -0,0 +1,30 @@ +import {EntityIDB} from "./storage_idb.js"; + +export class UndoIDB extends EntityIDB { + + async add(undo) { + undo.id = await this._db.undo.add(undo); + return undo; + } + + async get(id) { + return this._db.undo.where("id").equals(id).first(); + } + + async peek() { + const lastItem = await this._db.undo.orderBy("id").reverse().first(); + return lastItem || {stack: -1}; + } + + async pop() { + const lastItem = await this._db.undo.orderBy("id").reverse().first(); + + if (lastItem) { + const stack = await this._db.undo.where("stack").equals(lastItem.stack).toArray(); + await this._db.undo.where("stack").equals(lastItem.stack).delete(); + return stack.sort((a, b) => a.id - b.id); + } + } +} + +export const Undo = new UndoIDB(); diff --git a/addon/ui/dialog.js b/addon/ui/dialog.js new file mode 100644 index 00000000..ea587ce5 --- /dev/null +++ b/addon/ui/dialog.js @@ -0,0 +1,251 @@ +import {BROWSER_EXTERNAL_TYPE, NODE_TYPE_FILE, NODE_TYPE_NOTES, RDF_EXTERNAL_TYPE} from "../storage.js"; +import {formatBytes} from "../utils.js"; + +const DEFAULT_CONTAINER = "--default-container"; + +function showDlg(name, data, init = () => {}) { + if ($(".dlg-dim:visible").length) + return + + let $dlg = $(".dlg-dim.dlg-" + name).clone().prependTo(document.body); + + init($dlg); + + if (data.width) + $dlg.find(".dlg").css("width", data.width); + + if (data.wrap) + $dlg.find(".dlg-table .row").css("white-space", "normal"); + + $dlg.show(); + + if (name === "prompt") + setTimeout(() => $("input.dialog-input", $dlg).focus()); + + data = data || {} + $dlg.html($dlg.html().replace(/\[([^\[\]]+?)\]/g, function (a, b) { + return data[b] || "" + })); + $dlg.find("input").each(function () { + if (this.name) { + if (this.type === "radio") { + if (this.value == data[this.name]) + this.checked = true; + } else { + if (typeof data[this.name] != "undefined" && !(this.name === "icon" && data["icon"]?.startsWith("var("))) + this.value = data[this.name]; + } + } + }); + $dlg.find("textarea").each(function () { + if (typeof data[this.name] != "undefined") + this.value = data[this.name]; + }); + + // fill in object size + if (data.size) { + let size = $dlg.find("#prop-size"); + size.text(formatBytes(data.size)); + } + + $dlg.find("input.button-ok").unbind(".dlg"); + $dlg.find("input.button-cancel").unbind(".dlg"); + //$dlg.find("input.dialog-input").first().focus(); + + $(".more-properties", $dlg).hide(); + + if (data.external === RDF_EXTERNAL_TYPE) + $("#copy-reference-url").hide(); + + // handle bookmark comments + const commentsIcon = $dlg.find("#prop-dlg-comments-icon").first(); + if (commentsIcon.length) { + if (data.external === BROWSER_EXTERNAL_TYPE) + commentsIcon.hide(); + + let comments_container = $dlg.find(" #dlg-comments-container").first(); + let dlg_title = $dlg.find(" #prop-dlg-title-text").first(); + + if (data.comments) { + commentsIcon.css("background-image", "var(--themed-comments-filled-icon)"); + } + else + commentsIcon.css("background-image", "var(--themed-comments-icon)"); + + let old_icon = commentsIcon.css("background-image"); + + commentsIcon.click(e => { + comments_container.toggle(); + if (comments_container.is(":visible")) { + commentsIcon.css("background-image", "var(--themed-properties-icon)"); + commentsIcon.attr("title", "Properties"); + dlg_title.text("Comments"); + } + else { + commentsIcon.css("background-image", old_icon); + commentsIcon.attr("title", "Comments"); + dlg_title.text("Properties"); + } + }); + } + + // handle bookmark containers + const containersIcon = $dlg.find("#prop-dlg-containers-icon").first(); + if (browser.contextualIdentities && containersIcon.length && data.type !== NODE_TYPE_NOTES) { + containersIcon.click(() => { + $("#containers-menu", $dlg).toggle(); + }); + + let containers_menu = $dlg.find("#containers-menu").first(); + let icon_style = `background-image: var(--themed-containers-icon); background-size: 15px 15px;` + + `background-repeat: no-repeat; background-position: center; background-color: transparent !important`; + let container_item = `
` + + `Default
`; + + containers_menu.append(container_item); + + if (data.containers) + for (let container of data.containers) { + icon_style = `mask-image: url("${container.iconUrl}"); mask-size: contain; ` + + `mask-repeat: no-repeat; mask-position: center; background-color: ${container.colorCode} !important;` + + container_item = `
` + +`${container.name}
`; + + containers_menu.append(container_item); + } + + let makeButtonStyle = (elt) => { + let style = $("i", elt).attr("style") + "background-image: none; "; + + if (elt.id !== DEFAULT_CONTAINER) + style += " margin-top: 0;" + + return style; + } + + $(".container-item").click(e => { + containersIcon.attr("style", makeButtonStyle(e.target)); + data.container = e.target.id; + + if (data.container === DEFAULT_CONTAINER) + data.container = null; + }); + + if (data.container && data.container !== DEFAULT_CONTAINER) { + let elt = $(`#${data.container}`, containers_menu).get(0); + if (elt) + containersIcon.attr("style", makeButtonStyle(elt)); + } + } + else { + containersIcon.remove(); + } + + const bookmarkIconDiv = $dlg.find("#prop-title-icon-image").first(); + if (bookmarkIconDiv.length) { + if (data.type === NODE_TYPE_NOTES || data.type === NODE_TYPE_FILE) + bookmarkIconDiv.hide(); + else if (data.displayed_icon) + bookmarkIconDiv.css("background-image", `url("${data.displayed_icon}")`); + else + bookmarkIconDiv.css("background-image", `var(--themed-globe-icon)`); + + bookmarkIconDiv.on("click.dlg", () => { + $dlg.find("#prop-row-user_icon").show(); + }); + } + + const doNotAssign = ["date_added"]; + + /** return promise object */ + let promise = new Promise(function (resolve, reject) { + function proceed() { + var data = {}; + $dlg.find("input").each(function () { + if (this.name) { + if (this.type == "radio") { + if (this.checked) + data[this.name] = $(this).val(); + } else { + if (!doNotAssign.some(p => p === this.name)) + data[this.name] = $(this).val(); + } + } + }) + $dlg.find("select").each(function () { + if (this.name) + data[this.name] = $(this).val(); + }) + $dlg.find("textarea").each(function () { + if (this.name) + data[this.name] = $(this).val(); + }) + $dlg.remove(); + resolve(data); + // callback && callback(data); + } + + $dlg.find("input.button-ok").bind("click.dlg", proceed); + $dlg.find("input.dialog-input").bind("keydown.dlg", ev => { + if (ev.key == "Enter") + proceed() + if (ev.key == "Escape") + $dlg.remove(); + }); + $dlg.find("input.button-cancel").bind("click.dlg", function () { + $dlg.remove(); + resolve(null); + }); + + let morePropertiesLink = $dlg.find("#more-properties"); + + morePropertiesLink.bind("click.dlg", function () { + if (morePropertiesLink.text() === "More") { + let fields = $(".more-properties"); + + fields = fields.filter(function() { + if (!["prop-row-size"].some(id => this.id === id)) + return true; + + if (this.id === "prop-row-size" && data.size) + return true; + + return false; + }); + + fields.show(); + morePropertiesLink.text("Less"); + } + else { + $(".more-properties").hide(); + morePropertiesLink.text("More"); + } + }); + + let setDefaultIconLink = $dlg.find("#set-default-icon"); + + setDefaultIconLink.bind("click.dlg", function () { + $dlg.find("#prop-user_icon").val(""); + }); + + let copyReferenceLink = $dlg.find("#copy-reference-url"); + + copyReferenceLink.bind("click.dlg", function () { + let url = "ext+scrapyard://" + $dlg.find("#prop-uuid").val(); + navigator.clipboard.writeText(url); + }); + }); + return promise; +} + +function alert(title, message) { + return showDlg("alert", {title, message}); +} + +function confirm(title, message) { + return showDlg("confirm", {title, message}); +} + + +export {showDlg, alert, confirm} diff --git a/addon/ui/edit_toolbar.css b/addon/ui/edit_toolbar.css new file mode 100644 index 00000000..2b96d117 --- /dev/null +++ b/addon/ui/edit_toolbar.css @@ -0,0 +1,280 @@ +#scrapyard-edit-bar-container { + display: none; + position: fixed !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 2147483647 !important; + width: 100% !important; + height: 42px !important; + margin: 0 !important; + padding: 0 !important; +} + +#scrapyard-edit-bar { + background: #aaa; + border-top: 1px solid #999; + width: 100%; + height: 26px; + white-space: nowrap; + text-align: left; + display: flex; + padding-top: 8px; + padding-bottom: 8px; + line-height: 26px; + font-weight: normal; + letter-spacing: normal; + align-items: center; +} + +#scrapyard-edit-bar * { + flex: 0 1 auto; +} + +#scrapyard-original-url-label { + margin-left: 8px; + display: inline-block; + color: black; +} + +#scrapyard-original-url-text { + margin: 0; + margin-left: 4px; + margin-right: 4px; + height: 16px; + /*margin-top: 2px;*/ + padding: 0; + letter-spacing: normal; + border: 1px solid #777; + flex-grow: 1; + flex-shrink: 0; +} + +#scrapyard-icon { + vertical-Align: middle; + margin-Left: 10px; + margin-top: 4px; + width: 20px; + height: 20px; +} + +#scrapyard-brand { + margin-top: 2px; + margin-left: 5px; + margin-right: 5px; + color: black; + font-weight: bold; + letter-spacing: normal; +} + +#scrapyard-edit-doc-button { + width: 81px; +} + +#scrapyard-edit-bar, #scrapyard-edit-bar *, #scrapyard-edit-bar input[type=button].blue-button, +#scrapyard-edit-bar input[type=button].yellow-button, #scrapyard-edit-bar input[type=text].original-url-text { + font-family: "Arial", "Helvetica", sans-serif; + font-size: 12px; + box-sizing: content-box; +} + +#scrapyard-edit-bar input[type=button].blue-button, +#scrapyard-edit-bar input[type=button].yellow-button, +#scrapyard-edit-bar input[type=button].black-button { + background: #09c; + color: #fff; + cursor: pointer; + border-radius: 5px; + padding: 3px 15px; + border: none; + line-height: 20px; + margin-left: 5px; + text-transform: none; + font-weight: normal; + letter-spacing: normal; +} + +#scrapyard-edit-bar input[type=button].yellow-button { + background: #f70; +} + +#scrapyard-edit-bar input[type=button].black-button { + background: #000; +} + +#scrapyard-edit-bar input[type=button]:disabled { + background: #555; +} + +.scrapyard-menu-item-wrapper { + height: 14px; + color: #333; + cursor: pointer; + border-bottom: 1px solid #999; + padding: 8px 20px; + vertical-align: middle; +} + +.scrapyard-menu-item { + text-align: center; + height: 14px; + line-height: 14px; + min-width: 200px; +} + +#scrapyard-hide-button { + margin-right: 8px; +} + +#scrapyard-go-button { + margin-left: 0; +} + +#scrapyard-marker-menu { + position: absolute; + z-index: 2147483646; + border: 1px solid #999; + background: #fff; + display: none; + box-shadow: 5px 5px 5px #888888; + border-width: 1px 1px 0px 1px; +} + +#scrapyard-marker-cleaner { + +} + +#scrapyard-auto-open-check { + margin-left: 5px; +} + +#scrapyard-auto-open-check ~ label { + margin: 0; + padding: 0; + padding-left: 2px; + padding-top: 2px; + color: black; + font-weight: normal; + letter-spacing: normal; +} + +.scrapyard-marker-1 { + background: #f2cfff;; +} + +.scrapyard-marker-2 { + background: #b2e8f6; +} + +.scrapyard-marker-3 { + background: #bcffc1; +} + +.scrapyard-marker-4 { + background: #fff2a1; +} + +.scrapyard-marker-5 { + border-bottom: 2px solid #28c628; +} + +.scrapyard-marker-6 { + border-bottom: 2px solid #f00; +} + +.scrapyard-marker-7 { + border-bottom: 2px dotted #28c628; +} + +.scrapyard-marker-8 { + border-bottom: 2px dotted #f00; +} + +.scrapyard-no-overflow { + overflow: hidden; +} + +#scrapyard-notes-dim { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #777; + opacity: 0.5; + z-index: 2000000; +} + +#scrapyard-notes-frame { + position: fixed; + top: 10%; + left: 10%; + width: 80%; + height: 80%; + z-index: 2147483600; + border: none; +} + +@keyframes flash { + 0% { + opacity: 1.0; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1.0; + } +} + +.scrapyard-flash-button { + animation-name: flash; + animation-duration: 1s; + animation-timing-function: linear; + animation-iteration-count: 1; +} + +.scrapyard-icon-container { + position: relative; + height: 16px; + display: flex; + align-content: center; + align-items: center; +} + +.scrapyard-help-mark, .scrapyard-i-mark { + width: 16px; + height: 16px; + margin-left: 4px; + /*background-image: url(../icons/help-mark.svg);*/ + background-size: 16px 16px; + background-repeat: no-repeat; + vertical-align: middle; + display: inline-block; +} + +.scrapyard-i-mark { + /*background-image: url(../icons/page-info.svg);*/ + width: 18px; + height: 18px; + background-size: 18px 18px; + margin-left: 16px; + margin-right: 10px; + margin-top: -2px; +} + +.scrapyard-tips { + border-radius: 5px; + border: 1px solid #555; + background: #d2d2d2; + padding: 5px; + margin-bottom: 5px; + text-align: left; + z-index: 2147483647; + color: black; +} + +.scrapyard-tips.scrapyard-hide { + display: none; + position: absolute; +} diff --git a/addon/ui/edit_toolbar.js b/addon/ui/edit_toolbar.js new file mode 100644 index 00000000..b032af0e --- /dev/null +++ b/addon/ui/edit_toolbar.js @@ -0,0 +1,545 @@ +$(document).ready(async function () { + const scrapyardNotFound = !!$("meta[name='scrapyard-not-found']").length; + + if (scrapyardNotFound) { + await configureNotFoundTransition(); + } + else { + const toolbar = new EditToolbar(); + + $(window).on("beforeunload", e => { + if (toolbar._unsavedChanges) + e.preventDefault(); + }) + } +}); + +class EditToolbar { + #tabId; + + constructor() { + this.targetBorder = $("
").appendTo(document.body); + this.buildTools() + + window.addEventListener("mousedown", e => { + if (e.button === 0) { + /** remove dom node by cleaner */ + if (!isDescendant(this.rootContainer, e.target) && this.last && this.erasing) { + e.preventDefault(); + this.last.parentNode.removeChild(this.last); + this.last = null; + /** check next hover target after current target removed */ + var em = new Event('mousemove'); + em.pageX = e.pageX; + em.pageY = e.pageY; + window.dispatchEvent(em); + } + /** hide marker-pen menu when click somewhere */ + if (!$(e.target).hasClass("scrapyard-marker-button")) { + if ($(this.menu).is(":visible")) { + e.preventDefault(); + $(this.menu).hide(); + } + } + } + }); + + window.addEventListener("mousemove", e => { + /** hover dom node by cleaner */ + if (this.erasing) { + + var dom = document.elementFromPoint(e.pageX, e.pageY - window.scrollY); + if (dom && !isDescendant(this.rootContainer, dom)) { + if (dom !== document.body && $(document.body).closest(dom).length === 0) { + this.last = dom; + var r = dom.getBoundingClientRect(); + this.targetBorder.css("pointer-events", "none"); + this.targetBorder.css("box-sizing", "border-box"); + this.targetBorder.css({ + border: "2px solid #f00", + position: "absolute", + left: parseInt(r.left) + "px", + top: parseInt(r.top + window.scrollY) + "px", + width: r.width + "px", + height: r.height + "px", + zIndex: 2147483646 + }); + this.targetBorder.show(); + } + else { + this.targetBorder.hide(); + // document.body or ancestors + } + } + } + }); + } + + isSelectionPresent() { + let selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + return !selection.getRangeAt(0).collapsed; + } + return false; + } + + deselect() { + let selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + selection.collapseToStart(); + } + } + + toggleDomEraser(on) { + this.last = null; + this.erasing = on; + this.targetBorder.hide(); + $(this.editBar).find("input[type=button]").prop("disabled", on); + document.body.style.cursor = this.erasing? "crosshair": ""; + } + + formatPageInfo(node) { + let html = ""; + + if (node?.__formatted_date) + html += `Added on: ${node?.__formatted_date}`; + + if (node?.__formatted_date && node?.__formatted_size) + html += ", "; + + if (node?.__formatted_size) + html += `Size: ${node?.__formatted_size}`; + + if (!html) + html = "<no data>"; + + return html; + } + + _fixDocumentEncoding(doc) { + let meta = doc.querySelector("meta[http-equiv='content-type' i]") + || doc.querySelector("meta[charset]"); + + if (meta) + meta.parentNode.removeChild(meta); + + $(doc.getElementsByTagName("head")[0]).prepend(``); + } + + async saveDoc() { + let saveButton = $("#scrapyard-save-doc-button", this.shadowRoot); + saveButton.addClass("scrapyard-flash-button"); + + setTimeout(() => saveButton.removeClass("scrapyard-flash-button"),1000); + + let doc = document.documentElement.cloneNode(true); + $(`#scrapyard-edit-bar-container, #scrapyard-dom-eraser-border`, doc).remove(); + + this._fixDocumentEncoding(doc); + + const uuid = location.href.split("/").at(-2); + const html = getDocType(document) + doc.outerHTML; + + await browser.runtime.sendMessage({type: "updateArchive", uuid, data: html}); + const node = await browser.runtime.sendMessage({type: "getBookmarkInfo", uuid}); + $("#scrapyard-page-info", this.editBar).html(this.formatPageInfo(node)) + } + + buildTools() { + const CONTAINER_HEIGHT = 42; + const documentMarginBottom = document.body.style.marginBottom; + + let erasing = false; + let contentEditing = false; + let scrapyardHideToolbar = false; + + /** toolbar */ + const rootContainer = this.rootContainer = $(`
`) + .appendTo(document.body)[0]; + + const shadowRoot = this.shadowRoot = rootContainer.attachShadow({mode: 'open'}); + shadowRoot.innerHTML = ` + +
`; + + const editBar = this.editBar = $("#scrapyard-edit-bar", shadowRoot)[0]; + const append = html => $(html).appendTo(editBar); + + append(`Scrapyard`); + + append(``) + .on("click", e => { + this._unsavedChanges = false; + this.saveDoc(); + }); + + append(``) + .on("click", e => { + contentEditing = !contentEditing; + e.target.className = contentEditing? "yellow-button": "blue-button"; + e.target.value = contentEditing ? "Finish editing" : "Edit document"; + + document.designMode = document.designMode === "on"? "off": "on"; + + $("#scrapyard-dom-eraser-button", editBar).prop("disabled", contentEditing); + }); + + append(`
+ + + Press F7 to turn on caret browsing. + +
`); + + append(``) + .on("click", e => { + erasing = !erasing; + this.toggleDomEraser(erasing) + e.target.className = erasing? "yellow-button": "blue-button"; + $(e.target).prop("disabled", false); + }); + + append(``) + .on("click", e => { + $(this.menu).toggle(); + let rect_div = editBar.getBoundingClientRect(); + let rect_btn = e.target.getBoundingClientRect(); + $(this.menu).css("bottom", (rect_div.bottom - rect_btn.top) + "px"); + $(this.menu).css("left", rect_btn.left + "px"); + }); + + const menu = append(`
`); + const appendMenu = html => $(html).appendTo(menu); + this.menu = menu[0]; + + appendMenu(`
+
Clear Markers
+
`) + .on("mousedown", e => { + e.preventDefault() + + $(menu).hide(); + if (this.isSelectionPresent()) { + clearMarkPen(); + this.deselect(); + } + else + alert("No active selection."); + }); + + + for (let i = 1; i <= 8; ++i) { + appendMenu(`
+
Example Text
+
`) + .on("mousedown", e => { + e.preventDefault() + + $(menu).hide(); + if (this.isSelectionPresent()) { + mark(`scrapyard-marker-${i}`); + this._unsavedChanges = true; + this.deselect(); + } + else + alert("No active selection."); + }); + } + + const autoOpenCheck = append(``)[0]; + append(""); + + $(document).on('mouseup', e => { + if (autoOpenCheck.checked && this.isSelectionPresent()) + $("#scrapyard-marker-button", shadowRoot).click(); + }) + $(document).on('mousedown', e => { + if (autoOpenCheck.checked && this.isSelectionPresent() && $(menu).is(":visible") && e.target !== menu) + $(menu).hide(); + }); + + const uuid = location.href.split("/").at(-2); + + append(``) + .on("click", async e => { + const iframe = $("#scrapyard-notes-frame"); + + if (iframe.length) { + iframe.remove(); + $("#scrapyard-notes-dim").remove(); + $(document.body).removeClass("scrapyard-no-overflow"); + } + else { + const notesPageURL = browser.runtime.getURL("/ui/notes_iframe.html") + + "#" + uuid + ":" + this.#tabId; + + $(document.body).prepend(``); +} + +async function markDoc(query, doc) { + const mark = new Mark(doc); + + let resolveResult; + const promise = new Promise(resolve => resolveResult = resolve); + + mark.mark(query, { + iframes: true, + acrossElements: true, + separateWordSearch: false, + ignorePunctuation: IGNORE_PUNCTUATION, + done: () => resolveResult() + }); + + return promise; +} + +async function appendSearchResult(query, node, occurrences) { + const foundItems = $("#found-items"); + const fallbackIcon = "/icons/globe.svg"; + + let icon = node.icon; + if (node.__notes_search) + icon = "/icons/notes.svg" + else if (node.stored_icon) + icon = await Icon.get(node); + + if (!icon) + icon = fallbackIcon; + + let html = ` + +
+ + +   +
+ + +
+ + ${node.name} +
+ + +
${occurrences} ${occurrences === 1 ? " occurrence" : " occurrences"}
+ + `; + + foundItems.append(html); + + if (!node.stored_icon && node.icon) { + let image = new Image(); + image.onerror = e => { + $(`#icon_${node.id}`).prop("src", fallbackIcon); + }; + image.src = icon; + } + + $(`#item_${node.id}`).click(e => previewResult(query, node)); + $(`#occurrences_${node.id}`).click(e => previewResult(query, node)); + $(`#select_${node.id}`).click(e => send.selectNode({node})); + $(`#open_this_tab_${node.id}`).click(async e => send.browseNode({node, tab: await getActiveTab(), preserveHistory: true})); + $(`#open_${node.id}`).click(e => send.browseNode({node})); + + $("#search-result-count").text(`${++resultsFound} ${resultsFound === 1? "result": "results"} found`); +} + +async function markSearch(query, nodes, acrossElements) { + const progressCounter = new ProgressCounter(nodes.length, "fullTextSearchProgress"); + + for (const node of nodes) { + if (!searching) + break; + + const docs = await getArchiveFrames(node) || []; + + let total = 0; + for (const doc of docs) { + if (!searching) + break; + + const count = await markSearchDoc(query, doc, acrossElements); + + if (count) + total += count; + + progressCounter.incrementAndNotify(); + } + + if (total > 0) + await appendSearchResult(query, node, total); + } + + stopSearch(progressCounter); +} + +async function getArchiveFrames(node) { + if (node.__notes_search) { + const notes = await Notes.get(node); + + if (notes) { + const html = notes2html(notes); + return [parseHtml(html)]; + } + else + return []; + } + else if (Archive.isUnpacked(node)) + return await assembleUnpackedIndex(node); + else { + const archive = await Archive.get(node); + const content = await Archive.reify(archive); + const rootDoc = parseHtml(content); + const [iframeDocs] = instantiateIFramesRecursive(rootDoc); + return [rootDoc, ...iframeDocs]; + } +} + +async function markSearchDoc(query, doc, across) { + const mark = new Mark(doc); + let found = true; + + let resolveResult; + const promise = new Promise(resolve => resolveResult = resolve); + + mark.mark(query, { + iframes: true, + acrossElements: across, + //firstMatchOnly: true, + separateWordSearch: false, + ignorePunctuation: IGNORE_PUNCTUATION, + //filter: (n, t, c) => {return c === 0}, + noMatch: () => { + found = false; + }, + done: c => { + if (found) + resolveResult(c); + else + resolveResult(0); + } + }); + + return promise; +} + +async function performSearch() { + if (!searching) { + searchQuery = $("#search-query").val().trim(); + + if (!searchQuery) + return; + + searching = true; + $("#search-button").val("Cancel"); + + resultsFound = 0; + $("#search-result-count").text(""); + + $("title").text("Full Text Search: " + searchQuery); + + send.startProcessingIndication({noWait: true}); + + let nodes = await Bookmark.list({ + search: searchQuery, + content: true, + index: "content", + partial: true, + order: "date_desc", + path: shelfList.selectedShelfName + }); + + const noteNodes = await Bookmark.list({ + search: searchQuery, + content: true, + index: "notes", + partial: true, + order: "date_desc", + path: shelfList.selectedShelfName + }); + + noteNodes.forEach(n => n.__notes_search = true); + + nodes = [...noteNodes, ...nodes]; + + $("#found-items").empty(); + $("#search-preview").empty(); + + markSearch(searchQuery, nodes, searchQuery.indexOf(" ") > 0); + } + else + searching = false; +} + +function stopSearch(progressCounter) { + searching = false; + $("#search-button").val("Search"); + send.stopProcessingIndication(); + progressCounter.finish(); + + if (resultsFound === 0) + $("#search-result-count").text(`not found`); + + if (searchQuery !== $("#search-query").val()) + performSearch(); +} diff --git a/addon/ui/locales/en/help.html b/addon/ui/locales/en/help.html new file mode 100644 index 00000000..4d2b0527 --- /dev/null +++ b/addon/ui/locales/en/help.html @@ -0,0 +1,727 @@ + + + + +
+
+
+

+ +

+ + + +

What is Scrapyard?

+

Scrapyard is a bookmarking extension where you can store and organize bookmarks, page + fragments, complete HTML pages and PDF documents, or take notes. All archived + content is accessible on the Android platform if shared through a + cloud service. It is possible to export and import bookmarks in JSON or org-mode formats. Scrapyard also supports + the import of RDF archives of the legacy ScrapBook addon. +

+ +

Where to Find Scrapyard

+ +

Scrapyard is accessible through the dropdown menu of the Firefox sidebar as shown in the image below.

+ +

+ +

+ +

In Chrome, Scrapyard could be opened in a separate window through the button in the popup dialog.

+ +

+ The Alt+Y keyboard shortcut allows to open Scrapyard directly. + Keyboard shortcuts provided by any extension could be + customized in the browser settings. +

+ +

Getting Started

+ +

1. Installing the Backend Application

+

+ Scrapyard requires the installation of the backend application to access the filesystem. + Please install the application using one of the download links provided in + the Backend options tab. +

+ +

A limited set of functionality is available without the backend application if the "Content location" option + in the main settings page is set to "Browser internal storage".

+ +

2. Specifying the Content Directory Path

+

+ Specify the content directory path at the Settings tab. + Use the path <your cloud folder>/Apps/Scrapyard/Sync to make Scrapyard content available on mobile devices. + It is necessary to specify the absolute full path to your cloud folder. Please consult settings or manual + of your cloud client to find the exact path. + Only content shared through Dropbox or OneDrive could be browsed with the Scrapyard application for Android. +

+ +

Main Features

+ +

Capturing Pages

+

+ To capture a web page or its fragment, select the part of the page you want to archive, open the Scrapyard popup dialog + by clicking its icon on the browser toolbar, choose the destination shelf and folder, then press the "Bookmark" + or "Archive" button. To capture multiple tabs, highlight them with Ctrl+click. + The "Bookmark" button stores only web-page URLs as bookmarks. The "Archive" button captures + the current selection, the whole page, if there is no active selection, or the whole document, if the opened + link is not a web-page (for example, a PDF file).

+ +

In the bookmark tree, archived pages are marked with italic font, while ordinary bookmarks have the regular one. + It is possible to additionally emphasize archives by enabling the corresponding add-on settings.

+ +

+ +

+ +

+ The following keyboard shortcuts allow to quickly capture the + active tab into the default bookmark shelf: +

+ +
    +
  • Alt+Q - bookmark the current tab to the default shelf.
  • +
  • Alt+W - archive the current tab or selection to the default shelf.
  • +
+ +

If these shortcuts are already overridden by some other add-on, they could be customized in Firefox settings. There is an option to open the sidebar + automatically when bookmarking from keyboard.

+ +

Capturing Sites

+

To create an offline copy of a website, enable the site capture mode by clicking on the + white circle icon inside the "Archive" button. +

+ +

+ +

+ +

A click on the "Archive Site" button will display the site capture options dialog: +

+ +

+ +

+ +

+ Read more about site capture options +

+ + + +

The captured pages are placed in a dedicated folder with the spider web icon. A click on the + folder name opens the first page stored inside it. All linked pages in this folder refer + to each other through the special ext+scrapyard:// URLs.

+ +

+ +

+ +

To abort the ongoing capture process, use the corresponding item from the sidebar + shelf menu:

+ +

+ +

+ +

Archive Toolbar

+

Every archived page contains a toolbar that offers several types of text markers to + highlight sections of interest. The "Auto open" check makes the marker menu to open + automatically when some text is selected. The "Edit document" button allows to clean the + document of unnecessary elements by direct editing of its content. It is also possible to + type in something, press F7 to display the caret. The DOM Eraser tool is able to remove + any element on the page with a single click. The HTML code of a page may also be + manually modified through the browser developer tools accessible through the F12 keyboard key. + Press the "Save" button after you have finished document editing.

+ +

The size of a saved page in bytes and its creation date are available through the page info icon at the right-most toolbar side.

+ +

+ +

+ +

There is an option to not display the editing toolbar automatically. + The toolbar always could be toggled by the Ctrl+Alt+T key combination.

+ +

Bookmark Shelves

+

+ It is possible to create an unlimited amount of bookmark shelves to structure your bookmarks. + The shelf named "everything" allows to browse and search through all existing bookmarks. Scrapyard built-in + shelves are listed in bold font and cannot be deleted or renamed.

+ +

+ +

+ +

If you mistakenly deleted a bookmark or even an entire shelf, it is possible to revert the + deletion using the "Undo" item of the shelf menu . + The undo operation is available until the browser is restarted or some other operation is performed.

+ +

TODO

+

It may be convenient to prioritize your bookmarks for processing with TODO states. Each bookmark may have one of the following + TODO priorities: TODO, WAITING, or POSTPONED. You may find all your prioritized bookmarks + at the built-in shelf named TODO. All bookmarks marked as DONE or CANCELLED + are displayed on the DONE shelf.

+ +

+ +

+ +

If the path of the bookmark folder above its title provides not enough context, it is + possible to fill the "Details" and "Date" fields at the bookmark property dialog. Only ISO + (YYYY-MM-DD) date format is supported. Expired TODO items will be displayed first regardless + of the assigned state.

+ +

Browser Bookmarks

+

+ Scrapyard seamlessly integrates with the browser bookmarks, so there is no need to switch to the + built-in bookmark manager. Browser bookmarks are not included in the export or backups. +

+ +

+ +

+ +

Cloud Bookmarking

+

+ Adding or copying/pasting bookmarks into the special shelf named Cloud makes them available + across different browser instances that use a cloud provider with the same credentials. + Cloud providers are configurable in the addon settings. It is possible to authorize in several + cloud providers and quickly switch between them (currently only Dropbox and OneDrive are supported).

+

Scrapyard application for + Android allows to save links and text into the Cloud shelf from mobile devices, + and to browse contents of this shelf. See the corresponding section below for more details. +

+ +

It is not necessary to install native cloud clients on any OS to use the cloud shelf. + Just a Dropbox or a personal Microsoft account is required.

+ +

+ +

+ +

Browsing Local Files

+

+ The Files shelf allows browsing Org-mode, + Markdown, and plain text files as notes in + Scrapyard. Select "Add directory" in the context menu of the shelf to add a directory + from the local filesystem. It is possible to open files from this shelf in an external text editor + through the context menu of an item. Scrapyard will create a full-text search index for all + added plain text files. +

+ +

Add #+OPTIONS: toc:t or #+OPTIONS: toc:t num:t directive to + your org-mode files to generate a table of contents.

+ +

Files other types could be included to the shelf by altering the file mask used during its + creation. Non-textual files are opened with the associated application.

+ +

+ +

+ +

Sharing Bookmarks

+

+ A bookmark or archive could be shared to either Pocket or a + cloud service through the "Share" context menu. + Files that are shared to Dropbox or OneDrive appear at the "Apps/Scrapyard" folder. Only links + could be shared to the Pocket application. + The "Cloud" menu item copies any selected items to the Scrapyard Cloud shelf. +

+ +

+ +

+ +

Firefox Multi-Account Containers

+ +

Any Scrapyard bookmark could be opened in one of the + Firefox Multi-Account containers, + available through the "Open in Container" context menu of a bookmark, archive, or + folder. Scrapyard will open the original URL of an archive with this menu, + and all the items located inside a folder, if a folder is selected. +

+ +

+ +

+ +


Additionally, it is possible to assign a container to a bookmark in its property dialog, as + it is shown in the image below. Such bookmarks will automatically open in the corresponding + container.

+ +

+ +

+ +

Bookmark Comments

+

While the "Details" field in the bookmark properties is intended for display at the TODO + shelf, the bookmark property dialog also allows entering quick comments by clicking on the + "callout" icon at its top-right corner.

+ +

+ +

+ +

Subsequent clicks on the icon alternate between the comment input field and bookmark properties.

+ +

The comment icon of bookmarks with filled-in comments takes the form of a "filled callout".

+ +

+ +

+ +

Notes

+ +

It is possible to attach more elaborate text notes with hyperlinks and images to every + bookmark, or even create dedicated note-only bookmarks. Click "New" > "(Attached) Notes" in the context + menu of a folder, bookmark, or archive to create a note-only bookmark or attach notes. Items + with non-empty notes are highlighted by the underlined text in the bookmark tree.

+ +

+ +

+ +

Notes could be entered in rich text format (using a visual editor), + Markdown, + Org-mode markup, or plain text. + The format selector dropdown list is located in the bottom left corner of the "Edit" tab. + Notes in Org format may have an automatically generated table of contents. + Insert the example markup into the note editor to explore what options are available.

+ +

The text in the note editor is saved automatically, although you may save it at any time with the Ctrl+S + keyboard shortcut.

+ +

Referencing to a bookmark or archive (Firefox only)

+

To make a reference to a Scrapyard bookmark or archive from the note markup, create a link with the following URL: + ext+scrapyard://<BOOKMARK UUID>, for example:

+

    ext+scrapyard://A4D409A0D1034D9BA0863E9DA8CE8FE7 + +

When necessary, it is possible to add a hash with a link description:

+

    ext+scrapyard://A4D409A0D1034D9BA0863E9DA8CE8FE7#short-bookmark-description + +

To refer the notes of a bookmark add notes: before the UUID:

+

    ext+scrapyard://notes:A4D409A0D1034D9BA0863E9DA8CE8FE7 + +

UUID of a bookmark is available from its property dialog:

+

+ +

+ +

Search

+ +

Sidebar Filtering

+ +

The input field at the Scrapyard sidebar allows filtering bookmarks of the current shelf + by content and various other attributes:

+ +

+ +

+ +

Use the "Everything" search mode (default) to filter bookmarks by all available attributes. + Use the "Everything" shelf to search through all bookmarks. Although the Sidebar filter is + reasonably fast, it matches all items that contain every entered word longer than two + characters in any order and distance. Use the full-text search if you need exact results. +

+ +

To search a bookmark by its UUID, enter the UUID prefixed with uuid: into the input field.

+ +

Filtering by date supports simple queries:

+ +
    +
  • 2020-02-20 - the exact date.
  • +
  • before 2020-02-20 - before the specified date (the specified date is not included).
  • +
  • after 2020-02-20 - after the specified date (the specified date is not included).
  • +
  • between 2020-02-20 and 2020-03-20 - between the specified dates (the specified dates are included).
  • +
+ +

Search from URL-bar

+ +

It is possible to search through all bookmarks by title or URL from the browser + URL-bar after entering the special "scr" keyword followed by a space:

+ +

+ +

+ +

If URL-bar search query begins with a plus sign (+), Scrapyard will interpret the rest of the + text as a tag name and will search by tags instead. It is possible to activate browser URL-bar + with the Ctrl+L keyboard shortcut.

+ +

+ +

+ +

Full-Text Search

+ +

Full-text search is accessible with the corresponding sidebar button: + . + It allows to find and highlight the exact occurrences of a phrase in the content of + archived HTML pages.

+ +

+ +

+ +

Import/Export

+ +

Scrapyard allows importing bookmarks in Netscape HTML format which you may obtain from Firefox, Chrome, or other + web-browsers. Its own collections of bookmarks and archived pages could also be exported or imported in JSON and + ORG formats through the corresponding items of the shelf operations menu:

+ +

+ +

+ +

Importing Legacy ScrapBook RDF Archives

+ +

RDF archives could be imported from the corresponding options tab. + There are two import modes:

+ +
    +
  • Import RDF to Scrapyard. In this mode, the archived files are transferred into the + scrapyard storage directory. +
  • +
  • Open RDF for editing. Import in this mode only recreates the RDF directory + structure in the Scrapyard internal browser storage. + The archived ScrapBook files are served from their original RDF folder. + Shelves imported in the editing mode are marked with a tape reel icon. + This mode is available on Firefox only. +
  • +
+ +

It is possible to edit the path of the imported RDF file through the "RDF Directory..." context + menu item of a reel shelf.

+ +

+ +

+ + + +

Link Checker

+

The automatic link checker allows finding broken links or URL duplicates. Its mode of + operation may be selected through the "Check for" dropdown list. Since different archived page + fragments may share the same original URL, link checker results in the "check for duplicates" + mode may not always mean the duplication of content. Please check the duplicate bookmarks before + deleting them.

+ +

+ +

+ +

Displaying Random Bookmarks

+ +

Random bookmarks from your collection could be displayed at the bottom of the Scrapyard sidebar. + Check the corresponding option in the settings to enable this feature. The displayed + bookmark is changed every 5 minutes, it also could be updated by a user request through + the "Refresh" button. The "Find" button allows locating the bookmark at the Scrapyard sidebar + by opening its shelf and folder.

+ +

+ +

+ +

Saving Links and Content from Mobile Applications

+ +

Scrapyard application for + Android allows saving links and content into the Scrapyard Cloud shelf from mobile devices. + It uses the standard Android sharing functionality, which may be tricky to deal + with, as it is shown in the examples below.

+ +

At first, you need to enable cloud shelf in Scrapyard by checking the corresponding option at + the "Cloud" setting tab and sign into a cloud service. The bookmarks also may be shared into the + default Sync shelf, if synchronization is enabled, although it is recommended to use the + cloud shelf for the sharing from mobile devices.

+ +

Then you need to install the Scrapyard application on your device and sign into the same + cloud service account from its "Cloud Providers" screen.

+ +

To share a link from the application of choice, find and tap the "Share" button or the "Share" menu item in it. + The example below highlights the "Share" button in the mobile Firefox application.

+ +

+ +

+ +

After that, you need to select the destination application to send the URL into. The interface may + differ from app to app, and you may need to scroll or press additional buttons to find the + necessary sharing target. The icon of the Scrapyard Android application is highlighted + in the image below. Tap on it and you are done.

+ +

+ +

+ +

Sharing of text is usually performed from the corresponding selection toolbar:

+ +

+ +

+ +

+ It is only possible to share plain text without links and images from the Android platform. + Generally, selected text is shared to Scrapyard in the form of notes without a source URL. + To save source URL, share text from the Pocket app or the recent versions of Chrome browser. +

+ +

The application also offers basic browsing functionality for the Cloud shelf and synchronized + bookmarks. Bookmarks, archived pages, and notes could be opened directly. Archived PDF + documents and other binary files could be downloaded to the device storage memory. There is + a long-tap context menu which allows to view attached notes and delete bookmarks and + folders.

+ +

+ +

+ + +

Text Command Interface

+

+ It is possible to issue commands to Scrapyard through the text command interface offered by + iShell Extension. + To control Scrapyard with text commands just install iShell. In most cases, the command interface + allows to significantly reduce the number of actions needed to create a bookmark or to archive a page. +

+ +

The "shelf" Command

+ +

With the "shelf" command you can quickly switch to a shelf or folder, or even create a new one + without using a mouse.

+ +

+ +

+ +

The "bookmark" and "archive" Commands

+ +

Use the "bookmark" or "archive" commands with the corresponding arguments to bookmark/archive + a page into the specified destination. The arguments allow specifying bookmark title, path, + details, tags, TODO state, and TODO deadline. Folders in the bookmark path will be + autocompleted by iShell and created in Scrapyard if they do not exist. + Note that due to the quirks of iShell parser spaces are not welcomed in folder names. + It is possible to use dashes instead. +

+

The first folder in the path is + always interpreted as a shelf name. The tilde (~) character may be used in place of the + "default" shelf. Contents of the Firefox "Bookmarks Menu" and "Other Bookmarks" bookmark + folders may be accessed with the "@" and "@@" shortcuts respectively.

+ +

+ +

+ +

The "copy-to" and "move-to" Commands

+ +

Use these commands to quickly copy selected bookmarks to a desired shelf/folder + without using the context menu and navigating through the shelf list and tree. It is + possible to select bookmarks without opening them by holding Ctrl or Shift keys.

+ +

+ +

+ +

The commands accept an additional "by switching" argument that will open the + destination folder after moving or copying.

+ +

The "scrapyard" Command

+ +

The "scrapyard" command allows browsing and search through the collection of bookmarks at + the specified destination. See iShell command help for more details. +

+ +

+ +

+ +

Creating Custom Capture Commands

+ +

If you often save bookmarks with similar properties to the same destination, it is possible to create custom + capture commands with predefined parameters. The "CAPTURE" link at the bottom-right corner of iShell command editor allows + to insert a template of such command. In addition to the predefined parameters, custom capture commands allow + specifying comma-separated CSS selectors for elements that will automatically be retained or deleted. + A custom CSS style could be added to the captured document through the "style" option.

+ +

+ +

+
+ +
+
diff --git a/addon/ui/locales/en/images/abort-menu.png b/addon/ui/locales/en/images/abort-menu.png new file mode 100644 index 00000000..f51d373d Binary files /dev/null and b/addon/ui/locales/en/images/abort-menu.png differ diff --git a/addon/ui/locales/en/images/android-cloud-shelf.png b/addon/ui/locales/en/images/android-cloud-shelf.png new file mode 100644 index 00000000..328ad5be Binary files /dev/null and b/addon/ui/locales/en/images/android-cloud-shelf.png differ diff --git a/addon/ui/locales/en/images/android-share-app.png b/addon/ui/locales/en/images/android-share-app.png new file mode 100644 index 00000000..0c706ea6 Binary files /dev/null and b/addon/ui/locales/en/images/android-share-app.png differ diff --git a/addon/ui/locales/en/images/android-share-link.png b/addon/ui/locales/en/images/android-share-link.png new file mode 100644 index 00000000..5799df31 Binary files /dev/null and b/addon/ui/locales/en/images/android-share-link.png differ diff --git a/addon/ui/locales/en/images/android-share-text.png b/addon/ui/locales/en/images/android-share-text.png new file mode 100644 index 00000000..719b20b4 Binary files /dev/null and b/addon/ui/locales/en/images/android-share-text.png differ diff --git a/addon/ui/locales/en/images/android-sign-in.png b/addon/ui/locales/en/images/android-sign-in.png new file mode 100644 index 00000000..813eb88b Binary files /dev/null and b/addon/ui/locales/en/images/android-sign-in.png differ diff --git a/addon/ui/locales/en/images/archive-command.png b/addon/ui/locales/en/images/archive-command.png new file mode 100644 index 00000000..66ecc9a4 Binary files /dev/null and b/addon/ui/locales/en/images/archive-command.png differ diff --git a/addon/ui/locales/en/images/backup.png b/addon/ui/locales/en/images/backup.png new file mode 100644 index 00000000..0ddeea12 Binary files /dev/null and b/addon/ui/locales/en/images/backup.png differ diff --git a/addon/ui/locales/en/images/capture-popup.png b/addon/ui/locales/en/images/capture-popup.png new file mode 100644 index 00000000..8b4dc63d Binary files /dev/null and b/addon/ui/locales/en/images/capture-popup.png differ diff --git a/addon/ui/locales/en/images/captured-site.png b/addon/ui/locales/en/images/captured-site.png new file mode 100644 index 00000000..dcac8578 Binary files /dev/null and b/addon/ui/locales/en/images/captured-site.png differ diff --git a/addon/ui/locales/en/images/cloud.png b/addon/ui/locales/en/images/cloud.png new file mode 100644 index 00000000..c3f4c79c Binary files /dev/null and b/addon/ui/locales/en/images/cloud.png differ diff --git a/addon/ui/locales/en/images/comments-icon.png b/addon/ui/locales/en/images/comments-icon.png new file mode 100644 index 00000000..92458a49 Binary files /dev/null and b/addon/ui/locales/en/images/comments-icon.png differ diff --git a/addon/ui/locales/en/images/containers-menu.png b/addon/ui/locales/en/images/containers-menu.png new file mode 100644 index 00000000..76ea69eb Binary files /dev/null and b/addon/ui/locales/en/images/containers-menu.png differ diff --git a/addon/ui/locales/en/images/containers-properties.png b/addon/ui/locales/en/images/containers-properties.png new file mode 100644 index 00000000..11e7431e Binary files /dev/null and b/addon/ui/locales/en/images/containers-properties.png differ diff --git a/addon/ui/locales/en/images/custom-command.png b/addon/ui/locales/en/images/custom-command.png new file mode 100644 index 00000000..78ecb811 Binary files /dev/null and b/addon/ui/locales/en/images/custom-command.png differ diff --git a/addon/ui/locales/en/images/edit-markup-button.png b/addon/ui/locales/en/images/edit-markup-button.png new file mode 100644 index 00000000..9b192cfd Binary files /dev/null and b/addon/ui/locales/en/images/edit-markup-button.png differ diff --git a/addon/ui/locales/en/images/files-shelf.png b/addon/ui/locales/en/images/files-shelf.png new file mode 100644 index 00000000..75179f67 Binary files /dev/null and b/addon/ui/locales/en/images/files-shelf.png differ diff --git a/addon/ui/locales/en/images/filled-comments.png b/addon/ui/locales/en/images/filled-comments.png new file mode 100644 index 00000000..48e234e6 Binary files /dev/null and b/addon/ui/locales/en/images/filled-comments.png differ diff --git a/addon/ui/locales/en/images/find-sidebar.png b/addon/ui/locales/en/images/find-sidebar.png new file mode 100644 index 00000000..4b65dce1 Binary files /dev/null and b/addon/ui/locales/en/images/find-sidebar.png differ diff --git a/addon/ui/locales/en/images/firefox.png b/addon/ui/locales/en/images/firefox.png new file mode 100644 index 00000000..5e9d36ea Binary files /dev/null and b/addon/ui/locales/en/images/firefox.png differ diff --git a/addon/ui/locales/en/images/full-text-search.png b/addon/ui/locales/en/images/full-text-search.png new file mode 100644 index 00000000..16d6b43e Binary files /dev/null and b/addon/ui/locales/en/images/full-text-search.png differ diff --git a/addon/ui/locales/en/images/impexp.png b/addon/ui/locales/en/images/impexp.png new file mode 100644 index 00000000..424dbee2 Binary files /dev/null and b/addon/ui/locales/en/images/impexp.png differ diff --git a/addon/ui/locales/en/images/link-checker.png b/addon/ui/locales/en/images/link-checker.png new file mode 100644 index 00000000..db24ff4f Binary files /dev/null and b/addon/ui/locales/en/images/link-checker.png differ diff --git a/addon/ui/locales/en/images/markers.png b/addon/ui/locales/en/images/markers.png new file mode 100644 index 00000000..1c386eac Binary files /dev/null and b/addon/ui/locales/en/images/markers.png differ diff --git a/addon/ui/locales/en/images/move-command.png b/addon/ui/locales/en/images/move-command.png new file mode 100644 index 00000000..8fcd86ed Binary files /dev/null and b/addon/ui/locales/en/images/move-command.png differ diff --git a/addon/ui/locales/en/images/notes.png b/addon/ui/locales/en/images/notes.png new file mode 100644 index 00000000..6e1f0d54 Binary files /dev/null and b/addon/ui/locales/en/images/notes.png differ diff --git a/addon/ui/locales/en/images/quick-imported-rdf.png b/addon/ui/locales/en/images/quick-imported-rdf.png new file mode 100644 index 00000000..ba3e911c Binary files /dev/null and b/addon/ui/locales/en/images/quick-imported-rdf.png differ diff --git a/addon/ui/locales/en/images/random-bookmark.png b/addon/ui/locales/en/images/random-bookmark.png new file mode 100644 index 00000000..bad5a50a Binary files /dev/null and b/addon/ui/locales/en/images/random-bookmark.png differ diff --git a/addon/ui/locales/en/images/references.png b/addon/ui/locales/en/images/references.png new file mode 100644 index 00000000..a460d3eb Binary files /dev/null and b/addon/ui/locales/en/images/references.png differ diff --git a/addon/ui/locales/en/images/scrapyard-command.png b/addon/ui/locales/en/images/scrapyard-command.png new file mode 100644 index 00000000..f0a533d1 Binary files /dev/null and b/addon/ui/locales/en/images/scrapyard-command.png differ diff --git a/addon/ui/locales/en/images/search-sidebar.png b/addon/ui/locales/en/images/search-sidebar.png new file mode 100644 index 00000000..8210a326 Binary files /dev/null and b/addon/ui/locales/en/images/search-sidebar.png differ diff --git a/addon/ui/locales/en/images/search-urlbar-tags.png b/addon/ui/locales/en/images/search-urlbar-tags.png new file mode 100644 index 00000000..4adc2cd9 Binary files /dev/null and b/addon/ui/locales/en/images/search-urlbar-tags.png differ diff --git a/addon/ui/locales/en/images/search-urlbar.png b/addon/ui/locales/en/images/search-urlbar.png new file mode 100644 index 00000000..97d3382b Binary files /dev/null and b/addon/ui/locales/en/images/search-urlbar.png differ diff --git a/addon/ui/locales/en/images/sharing.png b/addon/ui/locales/en/images/sharing.png new file mode 100644 index 00000000..8b5116ab Binary files /dev/null and b/addon/ui/locales/en/images/sharing.png differ diff --git a/addon/ui/locales/en/images/shelf-command.png b/addon/ui/locales/en/images/shelf-command.png new file mode 100644 index 00000000..1b7a815f Binary files /dev/null and b/addon/ui/locales/en/images/shelf-command.png differ diff --git a/addon/ui/locales/en/images/shelves.png b/addon/ui/locales/en/images/shelves.png new file mode 100644 index 00000000..5735a69c Binary files /dev/null and b/addon/ui/locales/en/images/shelves.png differ diff --git a/addon/ui/locales/en/images/sidebar-button.png b/addon/ui/locales/en/images/sidebar-button.png new file mode 100644 index 00000000..4a7df259 Binary files /dev/null and b/addon/ui/locales/en/images/sidebar-button.png differ diff --git a/addon/ui/locales/en/images/site-capture-mode.png b/addon/ui/locales/en/images/site-capture-mode.png new file mode 100644 index 00000000..52475d3a Binary files /dev/null and b/addon/ui/locales/en/images/site-capture-mode.png differ diff --git a/addon/ui/locales/en/images/site-capture-options.png b/addon/ui/locales/en/images/site-capture-options.png new file mode 100644 index 00000000..198cd0a1 Binary files /dev/null and b/addon/ui/locales/en/images/site-capture-options.png differ diff --git a/addon/ui/locales/en/images/todo.png b/addon/ui/locales/en/images/todo.png new file mode 100644 index 00000000..c39d9e2d Binary files /dev/null and b/addon/ui/locales/en/images/todo.png differ diff --git a/addon/ui/notes.css b/addon/ui/notes.css new file mode 100644 index 00000000..9c9aa868 --- /dev/null +++ b/addon/ui/notes.css @@ -0,0 +1,369 @@ +* { + font-family: "Helvetica", "Arial", sans-serif; +} + +input, textarea { + outline: none !important; +} + +textarea:focus { + border: 1px solid #007799; +} + +#root-container { + display: flex; + flex-direction: column; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + font-size: 12pt; + min-height: 0; + background: white; +} + +#full-width-container { + display: none; +} + +#tabbar { + height: 38px; + flex: 0 1 auto; + display: flex; + border-bottom: 1px solid #777; + line-height: 37px; +} + +a#notes-button, a#edit-button { + outline: none; + padding: 0px 15px; + font-size: 14px; + text-align: center; + height: 100%; + width: 50px; + text-decoration: none; + font-weight: bold; + color: black; + flex: 0 1 auto; +} + +a#notes-button.focus, a#edit-button.focus { + border-bottom: 3px solid #079; + background: #ddd; +} + +#notes-for { + display: none; +} + +.source-url { + color: #0074D9 !important; + text-decoration: underline; + cursor: pointer; +} + +#notes-for, span#source-url.notes-title { + font-weight: bold; + color: black !important; +} + +#content { + padding: 16px; + display: flex; + align-items: stretch; + min-height: 0; + flex-grow: 1; + overflow: auto; +} + +#notes { + width: 798px; + font-size: 100%; +} + +#notes *, .ql-editor * { + font-family: "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "Noto Sans", sans-serif; +} + +#notes > *:last-child { + margin-bottom: 16px !important; +} + +#notes code, .ql-editor code { + font-family: "Courier New", monospace; + display: inline-block; +} + +#notes > ul#toc { + padding-left: 0; + margin-top: 0; + list-style: none; +} + +#notes-button-content, #edit-button-content { + flex: 1 1 auto; + height: 100%; + display: flex; +} + +#edit-button-content { + display: none; +} + +#wysiwyg-editor { + height: calc(100% - 24px); +} + +#editor { + width: calc(100% - 4px); + height: calc(100% - 4px); + font-size: 12pt; + font-family: "Lucida Console", "Courier New", monospace; +} + +#bottomline { + flex: 0 1 auto; + display: flex; + border-top: 1px solid #777; + line-height: 24px; + padding: 4px 8px; +} + +#bottomline > * { + flex: 0 1 auto; +} + +#tabbar .spacer, #bottomline .spacer { + flex: 1 1 auto; +} + +#inserts { + display: none; +} + +#close-button { + display: none; + background: #09c; + color: #fff; + cursor: pointer; + border-radius: 5px; + padding: 3px 15px; + border: none; + line-height: 20px; + margin-left: 5px; +} + +span.task-status { + color: white !important; +} + +span.task-status.todo { + color: white !important; +} + +span.task-status.done { + color: white !important; +} + +#format-selector { + display: none; +} + +#notes-width option[value="actual"] { + display: none; +} + +#font-sizes, #editor-font-sizes, #width-controls { + user-select: none; + margin-left: 10px; + display: inline-block; +} + +.font-button, .width-button { + cursor: pointer; + padding: 0 4px; + width: 14px; + height: 14px; + display: inline-block; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.width-button { + background-size: 12px 12px; + margin-bottom: -2px; +} + +#font-size-smaller, #editor-font-size-smaller { + background-image: url("../icons/font-smaller.svg"); +} + +#font-size-default, #editor-font-size-default { + background-image: url("../icons/font-default.svg"); +} + +#font-size-larger, #editor-font-size-larger { + background-image: url("../icons/font-larger.svg"); +} + +#decrease-width, #decrease-width { + background-image: url("../icons/dec.svg"); +} + +#increase-width { + background-image: url("../icons/inc.svg"); +} + +pre { + tab-size: 4; + -moz-tab-size: 4; +} + +pre.plaintext { + padding: 0; + -moz-border-radius: 0; + border-radius: 0; + background-color: transparent; + display: block; + margin: 0; + /*line-height: 18px;*/ + border: none; + -webkit-border-radius: 0; + word-wrap: break-word; + font-size: 94%; +} + +#space-left, #space-right, .content-filler { + flex: 1; +} + +#editor-container { + display: flex; + flex-direction: column; + width: 830px; +} + +#editor { + display: none; +} + +.ql-toolbar.ql-snow .ql-formats { + margin-right: 4px; +} + +.ql-snow .ql-picker.ql-size { + width: 76px; +} + +.ql-snow .ql-picker.ql-header { + width: 90px; +} + +.ql-snow .ql-picker.ql-font { + width: 100px; +} + +.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button { + height: 22px; + padding: 2px 3px; + width: 26px; +} + +.ql-toolbar.ql-snow { + padding: 4px; + min-height: 34px; +} + +.ql-container { + font-size: 115%; +} + +.ql-editor .ql-align-justify { + white-space: normal; +} + +.ql-editor p, .ql-editor ol, .ql-editor ul, .ql-editor pre, .ql-editor blockquote, .ql-editor h1, .ql-editor h2, +.ql-editor h3, .ql-editor h4, .ql-editor h5, .ql-editor h6 { + margin-top: 10px; +} + +.ql-editor *:first-child { + margin-top: 0; +} + +p.linebreak-true { + text-indent: 0px; + margin-top: 0px; +} + +button.ql-showHtml { + background-image: url("../icons/code.svg") !important; + background-size: 16px 16px !important; + background-position: center !important; + background-repeat: no-repeat !important; +} + +button.ql-hr { + background-image: url("../icons/line.svg") !important; + /*background-size: 16px 16px !important;*/ + background-position: center !important; + background-repeat: no-repeat !important; +} + +button.ql-hr:hover { + background-image: url("../icons/line-hover.svg") !important; +} + +.ql-snow .ql-editor pre.ql-syntax { + font-size: 90%; + background-color: #f5f5f5; + color: black; +} + +.quill-html-editor { + width: 100%; + height: 100%; + margin: 0px; + box-sizing: border-box; + font-size: 15px; + outline: none; + padding: 20px; + line-height: 24px; + font-family: Consolas, Menlo, Monaco, "Courier New", monospace; + position: absolute; + top: 0; + bottom: 0; + border: none; + display: none; + resize: none; +} + +#quill { + width: unset; + height: calc(100% - 40px); +} + +.format-html { + line-height: 1.3; + font-size: 115%; + box-sizing: border-box; +} + +.format-html ul > li::before { + content: '\2022'; +} + +.format-html li::before { + margin-left: -1.5em; + margin-right: 0.3em; + text-align: right; + display: inline-block; + white-space: nowrap; + width: 1.2em; +} + +.format-html ol li::before { + content: counter(list-0, decimal) '. '; +} diff --git a/addon/ui/notes.html b/addon/ui/notes.html new file mode 100644 index 00000000..348f4584 --- /dev/null +++ b/addon/ui/notes.html @@ -0,0 +1,86 @@ + + + + + Notes + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Align: + +
+ Width: + + +
+
+ + + +
+
+
+ Format: + + + EXAMPLE MARKUP + +
+ + + +
+
+
 
+ +
+
+ + diff --git a/addon/ui/notes.js b/addon/ui/notes.js new file mode 100644 index 00000000..fbb5ba64 --- /dev/null +++ b/addon/ui/notes.js @@ -0,0 +1,411 @@ +import {fetchText} from "../utils_io.js"; +import {send} from "../proxy.js"; +import * as org from "../lib/org/org.js" +import {NODE_TYPE_NOTES} from "../storage.js"; +import {markdown2html, notes2html, org2html, text2html} from "../notes_render.js"; +import {systemInitialization} from "../bookmarks_init.js"; +import {Node, Notes} from "../storage_entities.js"; +import {PlainTextEditor, WYSIWYGEditor} from "./notes_editor.js"; + +const INPUT_TIMEOUT = 3000; +const DEFAULT_WIDTH = "790px"; +const DEFAULT_FONT_SIZE = 115; + +let examples; +const styles = {"org": `#+CSS: p {text-align: justify;}`, + "markdown": `[//]: # (p {text-align: justify;})`}; + +let NODE_ID; + +let format = "delta"; +let align; +let width; + +let editor; +let editorChanged; +let editorTimeout; + +$(init); + +async function init() { + await systemInitialization; + + const isInline = location.search.startsWith("?i"); + const isEditMode = location.search.startsWith("?edit"); + + if (isInline) + $("#tabbar").html(`Notes + Edit`); + else + $("#tabbar").html(`
+ Notes for: + +
+
 
+ View + Edit`); + + let node; + + try { + const uuid = location.hash.substring(1); + node = await Node.getByUUID(uuid); + const sourceURL = $("#source-url"); + sourceURL.text(node.name); + + NODE_ID = node.id; + + if (node.type === NODE_TYPE_NOTES) { + sourceURL.removeClass("source-url"); + sourceURL.addClass("notes-title"); + $("title").text(node.name); + } + else { + $("title").text("Notes for: " + node.name); + $("#notes-for").show(); + } + + if (node.type !== NODE_TYPE_NOTES) + sourceURL.on("click", e => { + send.browseNode({node: node}); + }); + + let notes = await Notes.get(node); + if (notes) { + format = notes.format || "org"; + $("#notes-format").val(format === "html"? "delta": format); + if (notes.__file_as_notes) + $("#notes-format").prop("disabled", true); + + editor = createEditor(format); + editor.setContent(notes.content); + formatNotes(editor.renderContent(), format, notes); + + if (format === "html") + format = "delta"; + + align = notes.align; + if (align) + $("#notes-align").val(align); + alignNotes(); + + width = notes.width; + if (width) { + //$("#notes").css("width", width); + + let selected; + $("#notes-width option").each(function() { + if (width === this.textContent) + selected = this.value; + }); + + if (selected) { + $("#notes-width").val(selected); + $("#notes").css("width", width); + } + else { + let actualWidthElt = $("#notes-width option[value='actual']"); + actualWidthElt.show(); + actualWidthElt.text(width); + $("#notes-width").val("actual"); + $("#notes").css("width", width); + } + } + + if (format !== "delta" && format !== "text") + $("#inserts").show(); + else + $("#inserts").hide(); + } + else { + editor = createEditor(); + } + } + catch (e) { + console.error(e) + } + + $("#tabbar a").on("click", e => { + e.preventDefault(); + + $("#tabbar a").removeClass("focus"); + $(e.target).addClass("focus"); + + $(`.content`).hide(); + $(`#${e.target.id}-content`).css("display", "flex"); + + if (e.target.id === "notes-button") { + formatNotes(editor.renderContent(), format); + $("#format-selector").hide(); + $("#align-selector").show(); + } + else if (e.target.id === "edit-button") { + $("#format-selector").show(); + $("#align-selector").hide(); + editor.focus(); + } + }); + + $("#insert-example").on("click", async e => { + let edit = jQuery("#editor"); + let caretPos = edit[0].selectionStart; + let textAreaText = edit.val(); + + await initExamples(); + + edit.val(textAreaText.substring(0, caretPos) + examples[format] + textAreaText.substring(caretPos)); + edit.trigger("input"); + }); + + $("#insert-style").on("click", e => { + let edit = jQuery("#editor"); + let caretPos = edit[0].selectionStart; + let textAreaText = edit.val(); + + edit.val(textAreaText.substring(0, caretPos) + styles[format] + textAreaText.substring(caretPos)); + edit.trigger("input"); + }); + + $("#notes-format").on("change", e => { + // old format + if (format === "delta" && !editor.isEmpty()) + $("#editor").val(editor.getContent()); + + format = $("#notes-format").val(); + + editor.uninstall(); + editor = createEditor(format); + + // new format + if (format === "delta") + editor.setContent($("#editor").val()); + + if (format !== "delta" && format !== "text") { + $("#inserts").show(); + $("#editor-font-sizes").hide(); + } + else { + $("#inserts").hide(); + if (format === "delta") + $("#editor-font-sizes").show(); + } + + send.storeNotes({options: {node_id: NODE_ID, format}, property_change: true}); + }); + + $("#notes-align").on("change", e => { + align = $("#notes-align").val(); + alignNotes(); + send.storeNotes({options: {node_id: NODE_ID, align}, property_change: true}); + }); + + $("#notes-width").on("change", e => { + let selectedWidth = $("#notes-width option:selected").text(); + switch ($("#notes-width").val()) { + case "custom": + let customWidth = prompt("Custom width: ", "650px"); + if (customWidth) { + if (/^\d+$/.test(customWidth)) + customWidth = customWidth + "px"; + + let actualWidthElt = $("#notes-width option[value='actual']"); + actualWidthElt.show(); + actualWidthElt.text(customWidth); + width = customWidth; + $("#notes-width").val("actual"); + $("#notes").css("width", width); + } + break; + case "default": + $("#notes").css("width", DEFAULT_WIDTH); + width = null; + break; + default: + $("#notes").css("width", selectedWidth); + width = selectedWidth; + } + + send.storeNotes({options: {node_id: NODE_ID, width}, property_change: true}); + }); + + $("#decrease-width").on("click", e => changeWidth("dec")); + $("#increase-width").on("click", e => changeWidth("inc")); + + $("#font-size-larger").on("click", e => { + changeFontSize("notes-font-size", "#notes", (a, b) => a + b); + }); + + $("#font-size-smaller").on("click", e => { + changeFontSize("notes-font-size", "#notes", (a, b) => a - b); + }); + + $("#font-size-default").on("click", e => { + localStorage.setItem("notes-font-size", DEFAULT_FONT_SIZE); + $("#notes").css("font-size", DEFAULT_FONT_SIZE + "%"); + }); + + $("#editor-font-size-larger").on("click", e => { + changeFontSize("editor-font-size", ".ql-container", (a, b) => a + b); + }); + + $("#editor-font-size-smaller").on("click", e => { + changeFontSize("editor-font-size", ".ql-container", (a, b) => a - b); + }); + + $("#editor-font-size-default").on("click", e => { + localStorage.setItem("editor-font-size", DEFAULT_FONT_SIZE); + $(".ql-container").css("font-size", DEFAULT_FONT_SIZE + "%"); + }); + + let fontSize = parseInt(localStorage.getItem("notes-font-size") || DEFAULT_FONT_SIZE); + $("#notes").css("font-size", fontSize + "%"); + + $("#close-button").on("click", e => { + if (window.parent) + window.parent.postMessage("SCRAPYARD_CLOSE_NOTES"); + }); + + if (isInline) { + $("#close-button").show(); + } + + $("#notes") + .on("click", "a[href^='org-protocol://']", e => { + e.preventDefault(); + send.browseOrgReference({link: e.target.href, node}); + }) + .on("click", "a[href^='file://'], a[href^='wiki:'], a[href^='wiki-asset-sys:']", e => { + e.preventDefault(); + send.browseOrgWikiReference({link: e.target.href, node}); + }); + + if (isEditMode) + $("#tabbar a#edit-button").click(); +} + +window.onbeforeunload = function() { + if (editorChanged) + return true; +}; + +function createEditor(format = "delta") { + let editor; + + if (format === "html" || format === "delta") { + const fontSize = parseInt(localStorage.getItem("editor-font-size") || DEFAULT_FONT_SIZE); + editor = new WYSIWYGEditor(format, fontSize); + } + else + editor = new PlainTextEditor(format); + + editor.setChangeHandler(() => { + editorChanged = true; + editorSaveOnChange(true); + }) + editor.setBlurHandler(() => editorSaveOnBlur(true)); + editor.setSaveHandler(() => saveNotes()); + + return editor; +} + +async function initExamples() { + if (!examples) { + examples = {"org": await fetchText("notes_example_org.txt"), + "markdown": await fetchText("notes_example_md.txt")}; + } +} + +function saveNotes() { + let options = {node_id: NODE_ID, format, align, width}; + + options.content = editor.getContent(); + + if (options.content && format === "delta") + options.html = editor.renderContent(); + + options.html = notes2html(options); + + send.storeNotes({options}); + send.notesChanged({node_id: NODE_ID, removed: !options.content}); + editorChanged = false; +} + +function editorSaveOnChange(e) { + clearTimeout(editorTimeout); + + editorTimeout = setTimeout(() => { + if (e && NODE_ID) { + saveNotes(); + } + }, INPUT_TIMEOUT); +} + +function editorSaveOnBlur(e) { + if (e && NODE_ID && editorChanged) { + clearTimeout(editorTimeout); + saveNotes(); + } +} + +function formatNotes(text, format) { + switch (format) { + case "org": + $("#notes").attr("class", "notes format-org").html(org2html(text)); + break; + case "markdown": + $("#notes").attr("class", "notes format-markdown").html(markdown2html(text)); + break; + case "html": + case "delta": + $("#notes").attr("class", "notes format-html").html(text); + break; + default: + $("#notes").attr("class", "notes format-text").html(text2html(text)); + } +} + +function alignNotes() { + switch (align) { + case "left": + $("#space-left").css("flex", "0"); + $("#space-right").css("flex", "1"); + break; + case "right": + $("#space-right").css("flex", "0"); + $("#space-left").css("flex", "1"); + break; + default: + $("#space-left").css("flex", "1"); + $("#space-right").css("flex", "1"); + } +} + +function changeWidth(op) { + let newWidth; + let selectedWidth = $("#notes-width option:selected").text(); + let actualWidthElt = $("#notes-width option[value='actual']"); + let match = /(\d+)(.*)/.exec(selectedWidth); + + let [_, value, units] = (match || [null, "inc"? "800": "700", "px"]); + + const step = units === "%"? 10: 50; + newWidth = parseInt(value); + newWidth = op === "inc"? newWidth + step: newWidth - step; + let pass = units === "%"? newWidth >= 10 && newWidth <= 100: newWidth >= 100 && newWidth <= 4000; + + if (pass) { + width = newWidth = newWidth + units; + actualWidthElt.text(newWidth); + actualWidthElt.show(); + $("#notes-width").val("actual"); + $("#notes").css("width", newWidth); + send.storeNotes({options: {node_id: NODE_ID, width: newWidth}, property_change: true}); + } +} + +function changeFontSize(setting, target, op) { + let size = parseInt(localStorage.getItem(setting) || DEFAULT_FONT_SIZE); + size = op(size, 5); + localStorage.setItem(setting, size); + $(target).css("font-size", size + "%"); +} diff --git a/addon/ui/notes_editor.js b/addon/ui/notes_editor.js new file mode 100644 index 00000000..bcdb7578 --- /dev/null +++ b/addon/ui/notes_editor.js @@ -0,0 +1,303 @@ +import {applyInlineStyles} from "../utils_html.js"; + +class Editor { + setBlurHandler(handler) { + this.blurHandler = handler; + } + + setSaveHandler(handler) { + this.saveHandler = handler; + } + + setChangeHandler(handler) { + this.changeHandler = handler; + } +} + +export class WYSIWYGEditor extends Editor { + constructor(format, fontSize) { + super(); + this.format = format; + this.install(fontSize); + } + + install(fontSize) { + if (this.editor) + return; + + $(PlainTextEditor.ELEMENT_ID).hide(); + $(WYSIWYGEditor.ELEMENT_ID).show(); + + var toolbarOptions = [ + // ['showHtml'], + [{ 'header': [1, 2, 3, 4, 5, 6, false] }], + [{ 'size': ['small', false, 'large', 'huge'] }], + [{ 'font': [] }], + ['bold', 'italic', 'underline', 'strike'], + [{ 'color': [] }, { 'background': [] }], + [{ 'align': [] }], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + ['blockquote', 'code-block'], + [{ 'script': 'sub'}, { 'script': 'super' }], + [{ 'indent': '-1'}, { 'indent': '+1' }], + ['hr'], + [ 'link', 'image'], + ['clean'] + ]; + + Quill.prototype.getHTML = function() { + if (!this.isEmpty()) { + let root = $(WYSIWYGEditor.ELEMENT_ID)[0].cloneNode(true); + applyInlineStyles(root, true, ["org.css"]); + + return root.firstChild.innerHTML; + } + + return ""; + }; + + Quill.prototype.setHTML = function(html) { + this.pasteHTML(html); + }; + + Quill.prototype.isEmpty = function() { + if (JSON.stringify(this.getContents()) === "\{\"ops\":[\{\"insert\":\"\\n\"\}]\}") + return true; + }; + + var Parchment = Quill.import('parchment'); + + var LineBreakClass = new Parchment.Attributor.Class('linebreak', 'linebreak', { + scope: Parchment.Scope.BLOCK + }); + + Quill.register('formats/linebreak', LineBreakClass); + + this.editor = new Quill(WYSIWYGEditor.ELEMENT_ID, { + modules: { + clipboard: { + matchVisual: false + }, + toolbar: { + container: toolbarOptions, + handlers: { + // showHtml: () => { + // if ($(quill.txtArea).is(":visible")) { + // this.editor.setHTML(this.editor.txtArea.value); + // $(".ql-toolbar .ql-formats").slice(1).toggle(); + // } + // else { + // this.editor.txtArea.value = this.editor.getHTML(true); + // $(".ql-toolbar .ql-formats").slice(1).toggle(); + // } + // + // $(this.editor.txtArea).toggle(); + // }, + hr: () => { + let range = this.editor.getSelection(); + if (range) { + this.editor.insertEmbed(range.index, "hr", "null") + } + } + } + }, + // history: { + // delay: 2000, + // maxStack: 100, + // userOnly: true + // }, + keyboard: { + bindings: { + _save: { + key: 'S', + shortKey: true, + handler: (range, context) => { + this.saveHandler(); + } + }, + smartbreak: { + key: 13, + shiftKey: true, + handler: function (range, context) { + this.quill.setSelection(range.index,'silent'); + this.quill.insertText(range.index, '\n', 'user') + this.quill.setSelection(range.index + 1,'silent'); + this.quill.format('linebreak', true, 'user'); + } + }, + paragraph: { + key: 13, + handler: function (range, context) { + this.quill.setSelection(range.index, 'silent'); + this.quill.insertText(range.index, '\n', 'user') + this.quill.setSelection(range.index + 1, 'silent'); + let f = this.quill.getFormat(range.index + 1); + if (f.hasOwnProperty('linebreak')) { + delete (f.linebreak) + this.quill.removeFormat(range.index + 1) + for (let key in f) { + this.quill.formatText(range.index + 1, key, f[key]) + } + } + } + }, + justifiedTextSpacebarFixForFirefox: { + key: ' ', + format: {'align': 'justify'}, + suffix: /^$/, + handler: function (range, context) { + this.quill.insertText(range.index, ' ', 'user'); + return true; + } + } + } + } + }, + theme: 'snow' + }); + + // text area for source viewing + // this.editor.txtArea = document.createElement("textarea"); + // this.editor.txtArea.className = "quill-html-editor"; + // document.querySelector(WYSIWYGEditor.ELEMENT_ID).appendChild(this.editor.txtArea); + + let Link = window.Quill.import('formats/link'); + class ScrapyardLink extends Link { + static sanitize(url) { + if(url.startsWith("ext+scrapyard")) { + return url + } + else { + return super.sanitize(url); + } + } + } + Quill.register(ScrapyardLink); + + let Embed = Quill.import('blots/block/embed'); + class Hr extends Embed { + static create(value) { + let node = super.create(value); + node.setAttribute('style', "height:0px; margin-top:10px; margin-bottom:10px;"); + return node; + } + } + Hr.blotName = 'hr'; + Hr.tagName = 'hr'; + Quill.register({'formats/hr': Hr}); + + this.editor.on('selection-change', (range, oldRange, source) => { + if (range === null && oldRange !== null) + this.blurHandler(); + }); + + this.editor.on('text-change', (delta, oldDelta, source) => { + this.changeHandler(); + }); + + $(".ql-container").css("font-size", fontSize + "%"); + } + + uninstall() { + if($(WYSIWYGEditor.ELEMENT_ID)[0]) { + const editor = $(WYSIWYGEditor.ELEMENT_ID); + let content = editor.find('.ql-editor').html(); + editor.html(content); + + editor.siblings('.ql-toolbar').remove(); + $(`${WYSIWYGEditor.ELEMENT_ID} *[class*='ql-']`).removeClass(function (index, css) { + return (css.match (/(^|\s)ql-\S+/g) || []).join(' '); + }); + + $(`${WYSIWYGEditor.ELEMENT_ID}[class*='ql-']`).removeClass(function (index, css) { + return (css.match (/(^|\s)ql-\S+/g) || []).join(' '); + }); + + editor.empty(); + } + + this.editor = null; + + $(WYSIWYGEditor.ELEMENT_ID).hide(); + $(PlainTextEditor.ELEMENT_ID).show(); + } + + isEmpty() { + return this.editor.isEmpty(); + } + + setContent(content) { + if (content) { + if (this.format === "html") + this.editor.setHTML(content); + else if (this.format === "delta") + this.editor.setContents(JSON.parse(content)); + } + } + + getContent() { + if (this.editor.isEmpty()) + return ""; + return JSON.stringify(this.editor.getContents()); + } + + renderContent() { + return this.editor.getHTML(); + } + + focus() { + this.editor.focus(); + } +} + +WYSIWYGEditor.ELEMENT_ID = "#wysiwyg-editor"; + +export class PlainTextEditor extends Editor { + constructor(format) { + super(); + this.format = format; + this.install(); + } + + install() { + $(WYSIWYGEditor.ELEMENT_ID).hide(); + $(PlainTextEditor.ELEMENT_ID).show(); + } + + uninstall() { + $(WYSIWYGEditor.ELEMENT_ID).show(); + $(PlainTextEditor.ELEMENT_ID).hide(); + } + + isEmpty() { + return !!this.getContent(); + } + + setContent(content) { + const editor = $(PlainTextEditor.ELEMENT_ID); + editor.val(content); + editor[0].setSelectionRange(0, 0); + } + + getContent() { + return $(PlainTextEditor.ELEMENT_ID).val(); + } + + renderContent() { + return this.getContent(); + } + + setBlurHandler(handler) { + $("#editor").on("blur", handler); + } + + setChangeHandler(handler) { + $("#editor").on("input", handler); + } + + focus() { + $("#editor")[0].focus(); + } +} + +PlainTextEditor.ELEMENT_ID = "#editor" diff --git a/addon/ui/notes_example_md.txt b/addon/ui/notes_example_md.txt new file mode 100644 index 00000000..c65a872d --- /dev/null +++ b/addon/ui/notes_example_md.txt @@ -0,0 +1,83 @@ +[//]: # (p {text-align: justify;}) + +Supported [Markdown](https://daringfireball.net/projects/markdown/syntax#link) markup features: + +# Top Level Heading +## Second Level Heading +### Third Level Heading + +[//]: # (A comment line. This line will not be displayed.) + +Paragraphs are separated by at least one empty line. + + +### Formatting + +**bold** __text__ +*italic* _text_ +~~strikethrough~~ +and `monospaced` + +A horizontal line, fill-width across the page: + +----- + + +### Links + +[Link with a description](http://orgmode.org) + +http://orgmode.org - link without a description. + +### Lists +- First item in a list. +- Second item. +* Third item. + + Sub-item + 1. Numbered item. + 2. Another item. + 1. Third item. + + +### Code + +Inline `code` + +Indented code + + // Some comments + line 1 of code + line 2 of code + line 3 of code + + +Block code "fences" + +``` +To be or not to be, that is the question. +``` + + +### Blockquotes + +> Blockquotes can also be nested... +>> ...by using additional greater-than signs right next to each other... +> > > ...or with spaces between arrows. + +### Table + +| | Symbol | Author | +|-------|--------|------------| +| Emacs | ~M-x~ | _RMS_ | +| Vi | ~:~ | _Bill Joy_ | + + +### Images + +Referenced image: + +![Cat silhouette](https://upload.wikimedia.org/wikipedia/commons/6/60/Cat_silhouette.svg) + +From data URL: + +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoBAMAAAB+0KVeAAAAMHRFWHRDcmVhdGlvbiBUaW1lANCf0L0gMTUg0LDQv9GAIDIwMTkgMTU6MzA6MzMgKzA0MDAnkrt2AAAAB3RJTUUH4wQPCx8oBV08nwAAAAlwSFlzAAALEgAACxIB0t1+/AAAAARnQU1BAACxjwv8YQUAAAAwUExURf///7W1tWJiYtLS0oODg6CgoPf39wAAACkpKefn597e3j09Pe/v7xQUFAgICMHBwUxnnB8AAACdSURBVHjaY2AYpoArAUNEjeFsALogZ1HIdgxBhufl5QIYgtexCZaXl2Nqb8em0r28/P5KLCrLy2/H22TaIMQYy+FgK1yQBSFYrgDki6ILFgH9uwUkyIQkWP5axeUJyOvq5ajgFgNDsh6qUFF8AoPs87om16B95eWblJTKy6uF+sonMDCYuJoBjQiviASSSq4LGBi8D8BcJYTpzREKABwGR4NYnai5AAAAAElFTkSuQmCC) diff --git a/addon/ui/notes_example_org.txt b/addon/ui/notes_example_org.txt new file mode 100644 index 00000000..184a157c --- /dev/null +++ b/addon/ui/notes_example_org.txt @@ -0,0 +1,106 @@ +#+OPTIONS: toc:t num:nil +#+CSS: p {text-align: justify;} + +Supported [[http://orgmode.org/][org-mode]] markup features: + +* Top Level Heading +** Second Level Heading +*** Third Level Heading + +# A comment line. This line will not be displayed. + +Paragraphs are separated by at least one empty line. + + +*** Formatting + +*bold* +/italic/ +_underlined_ ++strikethrough+ +=monospaced= +and ~code~ + +Sub_{script}. a_{1}, a_{2}, and a_{3}. + +A horizontal line, fill-width across the page: +----- + + +*** Links + +[[http://orgmode.org][Link with a description]] + +http://orgmode.org - link without a description. + +*** Lists +- First item in a list. +- Second item. + - Sub-item + 1. Numbered item. + 2. Another item. +- [ ] Item yet to be done. +- [X] Item that has been done. + + +*** Definition List + +- vim :: Vi IMproved, a programmers text editor +- ed :: Line-oriented text editor + + +*** TODO + +**** TODO A todo item. +**** DONE A todo item that has been done. + + +*** Directives + +**** ~BEGIN_QUOTE~ and ~END_QUOTE~ + +#+BEGIN_QUOTE +To be or not to be, that is the question. +#+END_QUOTE + +**** ~BEGIN_EXAMPLE~ and ~END_EXAMPLE~ + +#+BEGIN_EXAMPLE +let o = new Object(); +o.attr = "string"; +#+END_EXAMPLE + +**** ~BEGIN_SRC~ and ~END_SRC~ + +#+BEGIN_SRC javascript +let o = new Object(); +o.attr = "string"; +#+END_SRC + + +*** Verbatim text + +: Text to be displayed verbatim (as-is), without markup +: (*bold* does not change font), e.g., for source code. +: Line breaks are respected. + + +*** Table + +|-------+--------+------------| +| | Symbol | Author | +|-------+--------+------------| +| Emacs | ~M-x~ | _RMS_ | +|-------+--------+------------| +| Vi | ~:~ | _Bill Joy_ | +|-------+--------+------------| + +*** Images + +Referenced image: + +[[https://upload.wikimedia.org/wikipedia/commons/6/60/Cat_silhouette.svg][Cat silhouette]] + +From data URL: + +[[data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoBAMAAAB+0KVeAAAAMHRFWHRDcmVhdGlvbiBUaW1lANCf0L0gMTUg0LDQv9GAIDIwMTkgMTU6MzA6MzMgKzA0MDAnkrt2AAAAB3RJTUUH4wQPCx8oBV08nwAAAAlwSFlzAAALEgAACxIB0t1+/AAAAARnQU1BAACxjwv8YQUAAAAwUExURf///7W1tWJiYtLS0oODg6CgoPf39wAAACkpKefn597e3j09Pe/v7xQUFAgICMHBwUxnnB8AAACdSURBVHjaY2AYpoArAUNEjeFsALogZ1HIdgxBhufl5QIYgtexCZaXl2Nqb8em0r28/P5KLCrLy2/H22TaIMQYy+FgK1yQBSFYrgDki6ILFgH9uwUkyIQkWP5axeUJyOvq5ajgFgNDsh6qUFF8AoPs87om16B95eWblJTKy6uF+sonMDCYuJoBjQiviASSSq4LGBi8D8BcJYTpzREKABwGR4NYnai5AAAAAElFTkSuQmCC][Cat silhouette]] diff --git a/addon/ui/notes_iframe.html b/addon/ui/notes_iframe.html new file mode 100644 index 00000000..3bc5a64a --- /dev/null +++ b/addon/ui/notes_iframe.html @@ -0,0 +1,20 @@ + + + + + Title + + + + + + + diff --git a/addon/ui/notes_iframe.js b/addon/ui/notes_iframe.js new file mode 100644 index 00000000..210513a5 --- /dev/null +++ b/addon/ui/notes_iframe.js @@ -0,0 +1,11 @@ +const hash = location.hash.split(":"); +const uuid = hash[0].substring(1); +const tabId = parseInt(hash[1]); + +const iframe = document.getElementById("notes-iframe"); +iframe.src = `/ui/notes.html?i#${uuid}`; + +window.addEventListener("message", e => { + if (e.data === "SCRAPYARD_CLOSE_NOTES") + /* sic! */ chrome.runtime.sendMessage({type: "closeNotes", tabId}); +}, false); diff --git a/addon/ui/options.css b/addon/ui/options.css new file mode 100644 index 00000000..5d2c614b --- /dev/null +++ b/addon/ui/options.css @@ -0,0 +1,436 @@ +* { + font-family: "Arial", "Helvetica", sans-serif; + font-size: 10pt; /* fsz */ +} + +html { + height: 100%; + width: 100%; +} + +body { + margin: 0; + /*display: table;*/ + min-height: 100%; + max-height: 100%; + height: 100%; + width: 100%; +} + +input[type="number"] { + padding: 1px; + outline: none; + border-radius: 3px; + border: 1px solid #777; +} + +input[type="number"]:focus { + padding: 1px; + outline: none; + border-color: #007799; +} + +input[type="text"]:focus { + outline: none; + border: 1px solid #007799; +} + +input[type="checkbox"] { + outline: none; +} + +.dlg-title { + font-weight: bold; + border-bottom: 1px solid #999; + padding: 0 10px 5px 10px; +} + +.dlg-dim { + position: fixed; + z-index: 999999; + width: 100%; + height: 100%; + background: #fff5; + display: flex; + justify-content: center; + align-items: center; +} + +.dlg { + margin: 30px; + position: absolute; + background: #ccc; + width: max-content; + padding: 15px 20px 20px 20px; + border-radius: 5px; + border: 1px solid #777; + overflow: hidden; +} + +.dlg * { + color: #000; +} + +.dlg-table { + display: table; + overflow: hidden; + width: 100%; + position: relative; +} + +.dlg-table .row { + display: table-row; + padding: 12px 5px 6px 5px; + white-space: nowrap; + overflow: hidden; +} + +.dlg-table .row .cell { + display: table-cell; + line-height: 20px; + vertical-align: middle; + padding: 15px 5px 0 5px; + vertical-align: middle; + overflow: hidden; +} + +.dlg .dlg-box { + width: 100%; + overflow: hidden; +} + +input[type='text'] { + border: 1px solid #777; + border-radius: 3px !important; + appearance: none !important; +} + +#settings-container { + display: none; + background: white; + height: 100%; +} + +#settings-menu { + background: white; + padding: 0; + width: 150px; + border-right: 1px solid #777; + flex-shrink: 0; + flex-grow: 0 +} + +.item-aligner { + display: flex; + align-items: center; + align-content: center; +} + +.form-table { +} + +.settings-content { + padding: 15px; + /*overflow: scroll;*/ + height: 100%; + flex-grow: 1; + box-sizing: border-box; +} + +input[type=button] { + padding: 0 12px; + appearance: none; + border-radius: 5px; + border: none; + background: #079; + color: #fff; + cursor: pointer; + height: 22px; + box-sizing: content-box; +} + +input[type=button].yellow-button { + background: #f70; +} + +#txtBackendPath { + border-radius: 5px; + border: 1px solid; + background: #ccc; + padding: 5px; + display: none; +} + +.form-table > tbody > tr > td:first-child { + vertical-align: top; + text-align: right; + font-weight: bold; + padding-top: 4px; + font-size: 15px; + white-space: nowrap; +} + +div.panel, fieldset.panel { + border-radius: 5px; + border: 1px solid #777; + padding: 10px; + white-space: nowrap; +} + +.form-table.inner > tbody > tr > td:first-child { + vertical-align: middle; + text-align: right; + font-weight: normal; + text-decoration: none; + font-size: 10pt; /* fsz */ +} + +.form-table > tbody > tr > td { + padding: 5px 5px; +} + +.form-table.inner > tbody > tr > td { + padding: 0px 0px; +} + +.form-table > tbody > tr { + border-bottom: 1px solid; +} + +.settings-menu-item { + outline: none; + padding: 15px 0px; + color: #000; + font-size: 14px; + display: block; + text-align: center; + width: 100%; + white-space: nowrap; +} + +.settings-menu-item.focus { + border-right: 3px solid #079; + background: #ddd; +} + +a.settings-menu-item, a:visited.settings-menu-item { + text-decoration: none; + font-weight: bold; + color: black; +} + +.tips { + border-radius: 5px; + border: 1px solid #555; + background: #d2d2d2; + padding: 5px; + margin-bottom: 5px; + text-align: left; +} + +.tips.hide { + display: none; + position: absolute; +} + +.console { + border: 1px solid #000; + overflow: auto; + padding: 10px; + position: absolute; + top: 50px; + left: 50px; + right: 50px; + bottom: 50px; + background: #333; + color: #eee; +} + +h2 { + font-size: 18px; +} + +.embeded-log-text { + background: #333; + color: #eee; + padding: 10px; + width: fit-content; + margin: 5px 0; +} + +.help-mark { + width: 16px; + height: 16px; + margin-left: 4px; + background-image: url(../icons/help-mark.svg); + background-size: 16px 16px; + background-repeat: no-repeat; + vertical-align: middle; + display: inline-block; +} + +form { + margin-bottom: 0; +} + +fieldset { + border-radius: 5px; + border: 1px solid #777; + vertical-align: middle; + line-height: 20px; +} + +fieldset > div { + margin-bottom: 5px; +} + +fieldset > div:last-child { + margin-bottom: 0px; +} + +.selectric, .selectric-focus .selectric, .selectric:hover, .selectric-items, .divide { + border-color: #777; +} + +input[type=checkbox] { + padding: 0; + margin-right: 4px; + border: 0; + height: 15px; + width: 15px; + appearance: none; + display: inline-block; + background-repeat: no-repeat; + background-position: 0 0; + cursor: pointer; + background-image: url("../images/checkbox.png"); +} + +input[type=checkbox]:checked { + background-position: 0 -15px; +} + +input[type=checkbox][disabled] { + opacity: 60%; +} + +.align-checkbox { + display: flex; + align-content: center; + align-items: center; + margin-bottom: 0; +} + +input[type=radio] { + padding: 0; + margin-right: 5px; + margin-top: 0; + border: 0; + height: 12px; + width: 12px; + appearance: none; + display: inline-block; + background-repeat: no-repeat; + background-position: 0 0; + cursor: pointer; + background-image: url("../images/radiobutton.png"); +} + +input[type=radio]:checked { + background-position: 0 -12px; +} + +fieldset input[type="radio"] { + margin-left: 4px; + margin-right: 7px; + /*vertical-align: 0px;*/ +} + +fieldset input[type="text"] { + margin-left: 10px; + padding: 1px 3px 1px 3px; + /*vertical-align: 2px;*/ +} + +fieldset input[type="number"] { + width: 4em; + margin-left: 4px; + margin-right: 8px; + /*padding: 1px 0px 1px 0px;*/ + text-align: center; + /*vertical-align: 2px;*/ +} + +body:not([windows]) fieldset input[type="number"] { + width: 5em; +} + +input[type="checkbox"], fieldset label { + display: inline-block; + vertical-align: middle; +} + +fieldset label:first-child { + margin-left: 3px; +} + +a, a:visited { + color: #0074D9; +} + +.shelves-icon { + width: 16px; + height: 16px; + display: inline-block; + margin-right: 4px; +} + +.settings-content > div > h2:first-child { + margin-top: 0; +} + +.double-container { + display: flex; +} + +.double-first, .double-last { + flex: 0 0 320px; +} + +.double-last { + margin-left: 10px; +} + +@keyframes flash { + 0% { + opacity: 1.0; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1.0; + } +} + +.flash-button { + animation-name: flash; + animation-duration: 1s; + animation-timing-function: linear; + animation-iteration-count: 1; +} + +#option-shelf-list-max-height { + appearance: textfield; + width: 52px; +} + +#div-help { + padding-right: 0; + height: 100%; + overflow-y: scroll; +} + +.transition-help-warning { + display: none; +} diff --git a/addon/ui/options.html b/addon/ui/options.html new file mode 100644 index 00000000..b835d9a0 --- /dev/null +++ b/addon/ui/options.html @@ -0,0 +1,94 @@ + + + + + Scrapyard Settings + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + +
+ + diff --git a/addon/ui/options.js b/addon/ui/options.js new file mode 100644 index 00000000..3f40e32f --- /dev/null +++ b/addon/ui/options.js @@ -0,0 +1,86 @@ +import {settings} from "../settings.js" +import {fetchText} from "../utils_io.js"; +import {injectCSS} from "../utils_html.js"; +import {systemInitialization} from "../bookmarks_init.js"; + +$(init); + +async function init() { + await systemInitialization; + + window.onhashchange = switchPane; + switchPane(); + + // show settings + $("#settings-container").css("display", "flex"); + + if (settings.debug_mode()) + $("a.settings-menu-item[href='#debug']").show(); + + if (settings.transition_to_disk()) + $("a.settings-menu-item[href='#transition']").show(); +} + +async function switchPane() { + $(".settings-content").hide(); + $("a.settings-menu-item").removeClass("focus") + + let hash = location.hash?.substr(1) || "settings"; + let [moduleName, subsection] = hash.split(":"); + let module = await loadOptionsModule(moduleName); + + $("#div-" + moduleName).show(); + $("a.settings-menu-item[href='#" + moduleName + "']").addClass("focus"); + + if (subsection) + module.navigate(subsection); +} + +async function loadOptionsModule(moduleName) { + const moduleDiv = $(`#div-${moduleName}`); + + let module = moduleDiv.data("module"); + + if (!module) { + injectCSS(`options/options_${moduleName}.css`); + + try { + let html = await fetchText(`options/options_${moduleName}.html`); + moduleDiv.html(html); + } + catch (e) { + console.info(e) + } + + module = await import(`./options/options_${moduleName}.js`); + moduleDiv.data("module", module); + initHelpMarks(`#div-${moduleName}`); + await module.load(); + } + + return module; +} + +function initHelpMarks(container = "") { + $(`${container} .help-mark`).hover(function(e){ + $(this).next(".tips.hide").show().css({left: (e.pageX )+"px", top: (e.pageY) +"px"}) + }, function(){ + $(this).next(".tips.hide").hide(); + }); +} + +export function setSaveCheckHandler(id, setting, callback) { + $(`#${id}`).on("click", async e => { + await settings.load(); + await settings[setting](e.target.checked); + if (callback) + return callback(e); + }); +} + +export function setSaveSelectHandler(id, setting) { + $(`#${id}`).on("change", async e => { + await settings.load(); + settings[setting](e.target.value); + }); +} diff --git a/addon/ui/options/options_about.css b/addon/ui/options/options_about.css new file mode 100644 index 00000000..528d90d1 --- /dev/null +++ b/addon/ui/options/options_about.css @@ -0,0 +1,50 @@ +#div-about { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100vh; + box-sizing: border-box; +} + +#about-version-panel { + width: 100%; + flex: 0; +} + +#about-changes-header { + flex: 0; + width: 100%; + margin-top: 40px; + margin-bottom: 10px; + text-align: center; +} + +#about-changes { + margin: auto; + flex: 1; + width: 60%; + overflow-y: scroll; + font-size: 12pt; +} + +#about-changes * { + font-size: 12pt; +} + +.about-links { + height: 16px; + font-size: 12pt; +} + +.about-link { + font-size: 12pt; +} + +.change-date { + display: inline-block; + margin-left: 10px; +} + +.change-info { + padding-left: 20px; +} diff --git a/addon/ui/options/options_about.html b/addon/ui/options/options_about.html new file mode 100644 index 00000000..a094af3d --- /dev/null +++ b/addon/ui/options/options_about.html @@ -0,0 +1,32 @@ +
+
+ +

Scrapyard

+

Version:

+ + +
+
+
+

Changes

+
+
diff --git a/addon/ui/options/options_about.js b/addon/ui/options/options_about.js new file mode 100644 index 00000000..39a0a6a3 --- /dev/null +++ b/addon/ui/options/options_about.js @@ -0,0 +1,13 @@ +import {fetchText} from "../../utils_io.js"; +import {settings} from "../../settings.js"; + +export async function load() { + $("#about-changes").html(await fetchText("options/options_changes.html")); + $("#about-version").text(`Version: ${browser.runtime.getManifest().version}`); + + $(".donation-link").on("mouseenter", e => $("#scrapyard-logo").prop("src", "../images/donation_kitty.png")); + $(".donation-link").on("mouseleave", e => $("#scrapyard-logo").prop("src", "../icons/scrapyard.svg")); + + if (settings.transition_to_disk()) + $("#transition-manual-wrapper").css("display", "inline"); +} diff --git a/addon/ui/options/options_advanced.css b/addon/ui/options/options_advanced.css new file mode 100644 index 00000000..948f4b28 --- /dev/null +++ b/addon/ui/options/options_advanced.css @@ -0,0 +1,152 @@ +.dlg-title { + font-weight: bold; + border-bottom: 1px solid #999; + padding: 0 10px 5px 10px; +} + +.dlg-dim { + position: fixed; + z-index: 999999; + width: 100%; + height: 100%; + background: #fff5; + display: flex; + justify-content: center; + align-items: center; +} + +.dlg { + margin: 30px; + position: absolute; + background: #ccc; + width: max-content; + padding: 15px 20px 20px 20px; + border-radius: 5px; + border: 1px solid #777; + overflow: hidden; +} + +.dlg * { + color: #000; +} + +.dlg-table { + display: table; + overflow: hidden; + width: 100%; + position: relative; +} + +.dlg-table .row { + display: table-row; + padding: 12px 5px 6px 5px; + white-space: nowrap; + overflow: hidden; +} + +.dlg-table .row .cell { + display: table-cell; + line-height: 20px; + vertical-align: middle; + padding: 15px 5px 0 5px; + vertical-align: middle; + overflow: hidden; +} + +.dlg .dlg-box { + width: 100%; + overflow: hidden; +} + +#advanced-title { + text-align: center; + width: 100%; +} + +#advanced-title h1 { + font-size: 18pt; + margin-bottom: 10px; +} + +.panel-title { + text-align: left; + font-weight: bold; + font-size: 15px; + margin-top: 0; + margin-bottom: 5px; +} + +.double-container { + display: flex; +} + +.double-first, .double-last { + flex: 0 0 320px; +} + +.double-last { + margin-left: 20px; +} + +#automation-panel-title { + width: 100%; +} + +#automation-api-docs { + font-weight: normal; + display: inline-block; + float: right; +} + +#addon-db-path-title { + margin-bottom: 10px; +} + +#addon-db-path-input { + margin-top: 10px; + margin-bottom: 10px; + width: 100%; +} + +#db-path-copy-button { + margin-left: -20px; + margin-bottom: -4px; + cursor: pointer; + background-color: white; +} + +.action-link { + padding-top: 4px; + margin-bottom: 0; +} + +#import-settings-file-picker { + display: none; +} + +.stats-table td:nth-child(2) { + padding-left: 10px; +} + +.item-aligner { + display: flex; + align-items: center; + align-content: center; +} + +#option-compression-method, #option-compression-level { + visibility: hidden; +} + +#enable-automation-wrapper { + width: max-content; + float: left; +} + +#option-enable-automation { + float: right; +} + +#automation-id-list-wrapper { + clear: both; +} diff --git a/addon/ui/options/options_advanced.html b/addon/ui/options/options_advanced.html new file mode 100644 index 00000000..a1f8e592 --- /dev/null +++ b/addon/ui/options/options_advanced.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + + +
Maintenance +
+
+
+ +
+
+ +
+
+ +    Open debug settings +
+
+
+
+
+
Automation +
+
+
+
+ +
+ API documentation +
+ + A comma-separated ID list of extensions that can use automation API. Everything is allowed, if empty. + + + +
+
+
+
+
Settings + +
+ + diff --git a/addon/ui/options/options_advanced.js b/addon/ui/options/options_advanced.js new file mode 100644 index 00000000..898cd738 --- /dev/null +++ b/addon/ui/options/options_advanced.js @@ -0,0 +1,254 @@ +import {send} from "../../proxy.js"; +import {settings} from "../../settings.js"; +import {showNotification} from "../../utils_browser.js"; +import {alert, confirm, showDlg} from "../dialog.js"; +import {formatBytes} from "../../utils.js"; +import {DiskStorage} from "../../storage_external.js"; + +function configureAutomationPanel() { + $("#option-enable-automation").prop("checked", settings.enable_automation()); + $("#option-extension-whitelist").val(settings.extension_whitelist()?.join(", ")); + + $("#option-enable-automation").on("change", async e => { + await settings.load(); + settings.enable_automation(e.target.checked); + }); + + $("#option-extension-whitelist").on("input", async e => { + await settings.load(); + + if (e.target.value) { + let ids = e.target.value.split(",").map(s => s.trim()).filter(s => !!s); + if (ids.length) + settings.extension_whitelist(ids); + else + settings.extension_whitelist(null); + } + else + settings.extension_whitelist(null); + }); +} + +function configureMaintenancePanel() { + $("#option-repair-icons").prop("checked", settings.repair_icons()); + $("#option-repair-icons").on("change", async e => { + await settings.load(); + settings.repair_icons(e.target.checked); + }); + + $("#option-enable-debug").prop("checked", settings.debug_mode()); + $("#option-enable-debug").on("change", async e => { + await settings.load(); + settings.debug_mode(e.target.checked); + }); + + $("#statistics-link").on("click", async e => { + e.preventDefault(); + + const statistics = await send.computeStatistics(); + + let html = ` + + + + + +
Items:${statistics.items}
Bookmarks:${statistics.bookmarks}
Archives:${statistics.archives}
Notes:${statistics.notes}
Archived content:${formatBytes(statistics.size)}
` + + alert("Statistics", html); + }); + + // $("#reset-cloud-link").on("click", async e => { + // e.preventDefault(); + // + // if (await confirm("Warning", "This will remove all contents of the Cloud shelf. Continue?")) { + // let success = await send.resetCloud(); + // + // if (!success) + // showNotification("Error accessing cloud.") + // } + // }); + + $("#reset-scrapyard-link").on("click", async e => { + e.preventDefault(); + + if (await confirm("Warning", + "This will reset the Scrapyard browser internal storage. Continue?")) + await send.resetScrapyard(); + }); + + $("#remove-orphaned-link").on("click", async e => { + e.preventDefault(); + + if (settings.storage_mode_internal()) + return; + + const orphanedItems = await send.getOrphanedItems(); + + if (orphanedItems?.length) { + const message = `${orphanedItems.length} orphaned items found. Remove?` + if (await showDlg("confirm", {title: "Orphaned Items", message})) { + await send.startProcessingIndication(); + + try { + await DiskStorage.deleteOrphanedItems(orphanedItems); + } + finally { + await send.stopProcessingIndication(); + } + } + } + else + return showDlg("alert", {title: "Orphaned Items", message: "No orphaned items found."}); + }); + + $("#rebuild-item-index-link").on("click", async e => { + e.preventDefault(); + + if (settings.storage_mode_internal()) + return; + + const message = `This will restore orphaned items. Continue?` + if (await showDlg("confirm", {title: "Rebuild Item Index", message})) { + await send.startProcessingIndication(); + + try { + await send.rebuildItemIndex(); + } + finally { + await send.stopProcessingIndication(); + send.performSync(); + } + } + + }); + + if (settings.transition_to_disk()) + $("#backup-link-wrapper").hide(); + + $("#compare-database-storage-link").on("click", async e => { + e.preventDefault(); + + await send.startProcessingIndication(); + + try { + const result = await send.compareDatabaseStorage(); + + if (result) + await alert("DB Comparison", "IDB and storage are identical."); + else + await alert("DB Comparison", "IDB and storage are NOT identical.") + } + finally { + await send.stopProcessingIndication(); + } + }); + + if (settings.debug_mode()) + $("#compare-database-storage").show() +} + +function configureImpExpPanel() { + $("#export-settings-link").click(async e => { + e.preventDefault(); + + const exported = {}; + exported.addon = "Scrapyard"; + exported.version = browser.runtime.getManifest().version; + + const now = new Date(); + exported.timestamp = now.getTime(); + exported.date = now.toString(); + + const settings = await browser.storage.local.get() || {}; + + if (settings["scrapyard-settings"]) { + delete settings["scrapyard-settings"]["ishell_presents"]; + delete settings["scrapyard-settings"]["dropbox_refresh_token"]; + delete settings["scrapyard-settings"]["onedrive_refresh_token"]; + delete settings["scrapyard-settings"]["pending_announcement"]; + } + + settings["localstorage-settings"] = { + "editor-font-size": localStorage.getItem("editor-font-size"), + "notes-font-size": localStorage.getItem("notes-font-size"), + "scrapyard-sidebar-theme": localStorage.getItem("scrapyard-sidebar-theme") + }; + + Object.assign(exported, settings); + + // download link + const file = new Blob([JSON.stringify(exported, null, 2)], {type: "application/json"}); + const url = URL.createObjectURL(file); + const filename = "scrapyard-settings.json" + + const download = await browser.downloads.download({url: url, filename: filename, saveAs: true}); + + const download_listener = delta => { + if (delta.id === download && delta.state && delta.state.current === "complete") { + browser.downloads.onChanged.removeListener(download_listener); + URL.revokeObjectURL(url); + } + }; + browser.downloads.onChanged.addListener(download_listener); + }); + + $("#import-settings-link").click((e) => { + e.preventDefault(); + $("#import-settings-file-picker").click(); + }); + + $("#import-settings-file-picker").change(async e => { + if (e.target.files.length > 0) { + let reader = new FileReader(); + reader.onload = async function(re) { + const imported = JSON.parse(re.target.result); + + if (imported.addon !== "Scrapyard") { + showNotification("Export format is not supported."); + return; + } + + // versioned operations here + + delete imported.addon; + delete imported.version; + + const localStorageSettings = imported["localstorage-settings"]; + + if (localStorageSettings["editor-font-size"]) + localStorage.setItem("editor-font-size", localStorageSettings["editor-font-size"]); + + if (localStorageSettings["notes-font-size"]) + localStorage.setItem("notes-font-size", localStorageSettings["notes-font-size"]); + + if (localStorageSettings["scrapyard-sidebar-theme"]) + localStorage.setItem("scrapyard-sidebar-theme", localStorageSettings["scrapyard-sidebar-theme"]); + + delete imported["localstorage-settings"]; + + let scrapyardSettings = await browser.storage.local.get() || {}; + Object.assign(scrapyardSettings["savepage-settings"], imported["savepage-settings"]); + Object.assign(scrapyardSettings["scrapyard-settings"], imported["scrapyard-settings"]); + + await browser.storage.local.set(scrapyardSettings); + + await settings.load(); + + // propagate to localstorage + if (settings.platform.firefox && settings.open_sidebar_from_shortcut()) + settings.open_sidebar_from_shortcut(true); + + browser.runtime.reload(); + }; + reader.readAsText(e.target.files[0]); + } + }); + +} + +export async function load() { + configureMaintenancePanel(); + configureAutomationPanel(); + configureImpExpPanel(); +} diff --git a/addon/ui/options/options_backend.css b/addon/ui/options/options_backend.css new file mode 100644 index 00000000..328d2788 --- /dev/null +++ b/addon/ui/options/options_backend.css @@ -0,0 +1,3 @@ +#div-helperapp p, li { + /*font-size: 11pt;*/ +} diff --git a/addon/ui/options/options_backend.html b/addon/ui/options/options_backend.html new file mode 100644 index 00000000..f8f927eb --- /dev/null +++ b/addon/ui/options/options_backend.html @@ -0,0 +1,22 @@ +
+

Scrapyard Backend Application

+ +

Scrapyard backend application allows to transcend the limits of WebExtensions + by providing access to the filesystem and several other features that are otherwise impossible + to implement.

+ +

On Windows, the application could be installed automatically without the need for any configuration. + The CLI-based installer runs on any OS but requires Python 3.7+.

+ +

It is necessary to restart the browser after the application has been installed. + Please close your browser completely before updating to a new version.

+ +

Installed version: checking...

+

Latest version: checking...

+ +

Downloads

+ +
diff --git a/addon/ui/options/options_backend.js b/addon/ui/options/options_backend.js new file mode 100644 index 00000000..65e44f12 --- /dev/null +++ b/addon/ui/options/options_backend.js @@ -0,0 +1,53 @@ +import {fetchWithTimeout} from "../../utils_io.js"; +import {send} from "../../proxy.js"; + +async function loadHelperAppLinks() { + const helperAppVersionP = $("#helper-app-version"); + if (helperAppVersionP.data("loaded")) + return; + + helperAppVersionP.data("loaded", true); + + function setDownloadLinks(assets) { + const app = assets.find(a => a.browser_download_url.endsWith(".exe")).browser_download_url; + const archive = assets.find(a => a.browser_download_url.endsWith(".tgz")).browser_download_url; + $("#helper-windows-inst").attr("href", app); + $("#helper-manual-inst").attr("href", archive); + } + + try { + const apiURL = "https://api.github.com/repos/gchristensen/scrapyard/releases/latest"; + const response = await fetchWithTimeout(apiURL, {timeout: 30000}); + + if (response.ok) { + let release = JSON.parse(await response.text()); + setDownloadLinks(release.assets); + + let version = release.name.split(" "); + version = version[version.length - 1]; + + helperAppVersionP.html(`Latest version: ${version}`); + } + else + throw new Error(); + } + catch (e) { + console.error(e); + setDownloadLinks("#heperapp", "#heperapp"); + helperAppVersionP.html(`Latest version: error`); + } + + const installedVersion = await send.helperAppGetVersion(); + const INSTALLED_VERSION_TEXT = `Installed version: %%%`; + + if (installedVersion) + $("#helper-app-version-installed").html(INSTALLED_VERSION_TEXT + .replace("%%%", "v" + installedVersion)); + else + $("#helper-app-version-installed").html(INSTALLED_VERSION_TEXT + .replace("%%%", "not installed")); +} + +export async function load() { + loadHelperAppLinks(); +} diff --git a/addon/ui/options/options_backup.css b/addon/ui/options/options_backup.css new file mode 100644 index 00000000..a544204c --- /dev/null +++ b/addon/ui/options/options_backup.css @@ -0,0 +1,156 @@ +#backup { + height: 100%; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +#backup-container { + flex-grow: 1; + flex-shrink: 0; + display: flex; + flex-direction: row; + box-sizing: border-box; +} + +#backup-filter { + margin-bottom: 5px; +} + +#backup-list-container { + display: flex; + flex-direction: column; + flex-basis: 30%; + flex-grow: 0; + flex-shrink: 0; + min-height: 0; +} + +#backup-dir-container { + width: 100%; +} + +#create-backup-title, #backup-status-title { + margin-top: 15px; + margin-bottom: 5px; +} + +#backup-dir-title, #backup-list-title { + margin-top: 0; + margin-bottom: 5px; +} + +#backup-directory-path-container { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; +} + +#backup-directory-path { + flex-grow: 1; + margin-left: 5px; + margin-right: 5px; +} + +#backup-directory-path-refresh { + font-family: "FontAwesome"; + font-style: normal; + font-weight: normal; + cursor: pointer; + background-color: white; + display: block; + user-select: none; + margin-right: 2px; +} + +#backup-tree-container { + flex-grow: 1; + flex-shrink: 0; + border: 1px solid #777777; + border-radius: 3px; + overflow: auto; + min-height: 0; + height: 0; +} + +#backup-tree .jstree-icon.jstree-ocl { + width: 2px !important; +} + +#backup-shelves-container { + display: flex; + flex-wrap: nowrap; + align-content: center; + align-items: center; +} + +.backup-comment { + color: #5a5a5a; +} + +#backup-overall-file-size { + margin-top: 5px; +} + +#backup-controls-container { + flex-grow: 1; + padding-left: 100px; + padding-right: 100px; + padding-top: 25px; + display: flex; + flex-direction: column; +} + +#backup-compression-container { + display: flex; + flex-direction: row; + align-content: center; +} + +#compress-backup { + margin-left: 0; +} + + +#backup-status { + margin-left: 15px; + width: 100%; + display: flex; + flex-direction: row; + gap: 10px; +} + +#create-backup-container, #backup-compression-options-container { + border: 1px solid #777777; + border-radius: 3px; + display: flex; + flex-direction: row; + padding: 8px; + gap: 10px; + align-items: center; +} + +#backup-comment-container { + flex-grow: 1; + display: flex; + flex-direction: row; + align-items: center; +} + +#backup-comment { + flex-grow: 1; +} + +#backup-progress-container { + display: flex; + flex-grow: 1; +} + +.zip-icon { + width: 21px; + height: 21px; + display: inline-block; + cursor: pointer; +} diff --git a/addon/ui/options/options_backup.html b/addon/ui/options/options_backup.html new file mode 100644 index 00000000..36c90efe --- /dev/null +++ b/addon/ui/options/options_backup.html @@ -0,0 +1,73 @@ +
+

Backup

+
+
+

Available Backups

+ +
+
+
+
 
+
+
+
+

Backup Directory

+
+ + For example: d:\backups\scrapyard or ~/backups/scrapyard
+ Choose a directory on the partition/cloud that is likely to survive system failure or reinstallation.
+ + +
+
+

Backup Options

+
+
+ + +
+
+ Comment:  +
+ +
+ + +
+
+

Backup Compression

+
+
+ + +
+ +
+ + +
+
+
+

Status

+
Checking for the backend application...
+
+
+
+
diff --git a/addon/ui/options/options_backup.js b/addon/ui/options/options_backup.js new file mode 100644 index 00000000..6195986a --- /dev/null +++ b/addon/ui/options/options_backup.js @@ -0,0 +1,389 @@ +import {send} from "../../proxy.js"; +import {settings} from "../../settings.js"; +import {confirm} from "../dialog.js"; +import {selectricRefresh, ShelfList, simpleSelectric} from "../shelf_list.js"; +import {formatBytes, toHHMMSS} from "../../utils.js"; +import {showNotification} from "../../utils_browser.js"; +import {CLOUD_SHELF_NAME, DONE_SHELF_NAME, EVERYTHING_SHELF_NAME, BROWSER_SHELF_NAME, TODO_SHELF_NAME} from "../../storage.js"; +import {Query} from "../../storage_query.js"; + +export class BackupManager { + constructor() { + const backupDirectoryPathInput = $("#backup-directory-path"); + + backupDirectoryPathInput.val(settings.backup_directory_path()); + + let pathTimeout; + backupDirectoryPathInput.on("input", e => { + clearTimeout(pathTimeout); + pathTimeout = setTimeout(async () => { + await settings.load(); + settings.backup_directory_path(e.target.value); + this.listBackups(); + }, 1000) + }); + + let filterTimeout; + $("#backup-filter").on("input", e => { + clearTimeout(filterTimeout); + filterTimeout = setTimeout(() => { + this.filterBackups(e.target.value); + }, 1000) + }); + + $("#backup-directory-path-refresh").on("click", e => this.listBackups()); + + $("#backup-button").on("click", async e => this.backupSelectedShelf()); + + const compressBackupCheck = $("#compress-backup"); + compressBackupCheck.prop("checked", settings.enable_backup_compression()); + compressBackupCheck.on("change", e => { + settings.load(); + settings.enable_backup_compression(e.target.checked) + }) + + this.shelfList = new ShelfList("#backup-shelf", { + maxHeight: settings.shelf_list_height() || settings.default.shelf_list_height, + _prefix: "backup" + }); + + this.shelfList.initDefault(); + + this.backupTree = $("#backup-tree").jstree({ + plugins: ["wholerow", "contextmenu"], + core: { + worker: false, + animation: 0, + multiple: true, + themes: { + name: "default", + dots: false, + icons: true, + }, + check_callback: true + }, + contextmenu: { + show_at_node: false, + items: this.backupTreeContextMenu.bind(this) + } + }).jstree(true); + } + + async init() { + const helper = await send.helperAppHasVersion({version: "0.4"}); + + if (helper) { + await this.listBackups(); + $("#backup-button").attr("disabled", false); + } + else { + this.setStatus(`
Scrapyard backend application v0.4+ is required
`); + $("#backup-button").attr("disabled", true); + } + } + + backupTreeContextMenu(jnode) { + if (this.backupIsInProcess || this.restoreIsInProcess) + return null; + + let notRestorable = () => { + const selected = this.backupTree.get_selected(true); + const name = jnode.data.name.toLowerCase(); + return name === BROWSER_SHELF_NAME || name === CLOUD_SHELF_NAME + || name === TODO_SHELF_NAME.toLowerCase() || name === DONE_SHELF_NAME.toLowerCase() + || selected?.length > 1; + }; + + return { + restore: { + label: "Restore", + _disabled: notRestorable(), + action: () => this.restoreSelectedShelf(jnode) + }, + restoreAsSeparateShelf: { + label: "Restore as a Separate Shelf", + _disabled: this.backupTree.get_selected(true).length > 1, + action: () => this.restoreSelectedShelf(jnode, true) + }, + delete: { + separator_before: true, + label: "Delete", + action: () => setTimeout(() => this.deleteSelectedBackups()) + }, + }; + } + + metaToJsTreeNode(node) { + const jnode = {}; + let date = new Date(node.timestamp); + date = date.toISOString().split("T")[0]; + let comment = node.comment? `${node.comment}`: ""; + + node.name = node.name || node.title; + node.alt_name = `${node.name} [${date}]`; + + jnode.id = `${node.uuid}-${node.timestamp}`; + jnode.text = `${node.name} [${date}] ${comment}`; + jnode.icon = "/icons/shelf.svg"; + jnode.data = node; + jnode.parent = "#" + + const fileSize = "File size: " + formatBytes(node.file_size); + const tooltip = node.comment? node.comment + "\x0A" + fileSize: fileSize; + + jnode.li_attr = { + class: "show_tooltip", + title: tooltip + }; + + return jnode; + } + + setStatus(html) { + $("#backup-status").html(html); + } + + updateTime() { + let delta = Date.now() - this.processingTime; + $("#backup-processing-time").text(toHHMMSS(delta)); + } + + updateOverallSize() { + if (this.overallBackupSize) + $("#backup-overall-file-size").html(`Overall backup size: ${formatBytes(this.overallBackupSize)}`); + else + $("#backup-overall-file-size").html(" "); + } + + async listBackups() { + if (!this.listingBackups) { + const directory = settings.backup_directory_path(); + let error = false; + + try { + this.listingBackups = true; + this.setStatus("Loading backups..."); + + let backups = await send.listBackups({directory}); + if (backups) { + this.availableBackups = []; + for (let [k, v] of Object.entries(backups)) { + v.file = k; + this.availableBackups.push(v); + } + + this.overallBackupSize = this.availableBackups.reduce((a, b) => a + b.file_size, 0); + + this.availableBackups.sort((a, b) => b.timestamp - a.timestamp); + this.availableBackups = this.availableBackups.map(n => this.metaToJsTreeNode(n)); + + this.backupTree.settings.core.data = this.availableBackups; + this.backupTree.refresh(true); + + this.updateOverallSize(); + } + else { + this.backupTree.settings.core.data = []; + this.backupTree.refresh(true); + } + } + catch (e) { + error = true; + this.setStatus("Error connecting the backend application."); + } + finally { + this.listingBackups = false; + if (!error) + this.setStatus("Ready"); + } + } + } + + filterBackups(text) { + if (text) { + text = text.toLowerCase(); + this.backupTree.settings.core.data = + this.availableBackups.filter(b => b.text.replace(/<[^>]+>/g, "").toLowerCase().includes(text)); + this.backupTree.refresh(true); + } + else { + this.backupTree.settings.core.data = this.availableBackups; + this.backupTree.refresh(true); + } + } + + async backupSelectedShelf() { + await settings.load(); + + if (!settings.backup_directory_path()) { + showNotification("Please, specify backup directory path.") + return; + } + + this.setStatus(`
Progress:
+
00:00
`); + + this.processingTime = Date.now(); + this.processingInterval = setInterval(() => this.updateTime(), 1000); + send.startProcessingIndication({noWait: true}); + + const compress = !!$("#compress-backup:checked").length; + + let exportListener = message => { + if (message.type === "exportProgress") { + if (message.finished) { + if (compress) { + $("#backup-progress-container").remove(); + $("#backup-status").prepend(`Compressing...`); + } + } + else + $("#backup-progress-bar").val(message.progress); + } + }; + + browser.runtime.onMessage.addListener(exportListener); + + try { + this.backupIsInProcess = true; + $("#backup-button").prop("disabled", true); + + await send.backupShelf({ + directory: settings.backup_directory_path(), + shelf: this.shelfList.selectedShelfName, + comment: $("#backup-comment").val(), + compress, + method: settings.backup_compression_method() || "DEFLATE", + level: settings.backup_compression_level() || "5" + }); + + await this.listBackups(); + } + catch (e) { + console.error(e); + showNotification("Backup has failed: " + e.message); + } + finally { + browser.runtime.onMessage.removeListener(exportListener); + $("#backup-button").prop("disabled", false); + clearInterval(this.processingInterval); + send.stopProcessingIndication(); + this.backupIsInProcess = false; + this.setStatus("Ready"); + } + } + + async restoreSelectedShelf(jnode, newShelf) { + const shelves = await Query.allShelves(); + const backupName = newShelf? jnode.data.alt_name: jnode.data.name; + + shelves.push({name: EVERYTHING_SHELF_NAME}); + + if (shelves.find(s => s.name.toLowerCase() === backupName.toLowerCase())) { + if (!await confirm("Warning", `This will replace "${backupName}". Continue?`)) + return; + } + + const PROGRESS_BAR_HTML = `Progress: `; + + let progressIndication = false; + let importListener = message => { + if (message.type === "importProgress") { + if (!progressIndication) { + $("#backup-progress-container").html(PROGRESS_BAR_HTML); + progressIndication = true; + } + const bar = $("#backup-progress-bar"); + bar.val(message.progress); + } + }; + + browser.runtime.onMessage.addListener(importListener); + + this.processingTime = Date.now(); + this.processingInterval = setInterval(() => this.updateTime(), 1000); + + const statusHTML = + settings.undo_failed_imports() + ? "Initializing..." + : PROGRESS_BAR_HTML; + + this.setStatus(`
${statusHTML}
+
00:00
`); + + try { + this.restoreIsInProcess = true; + $("#backup-button").prop("disabled", true); + + await send.restoreShelf({ + directory: settings.backup_directory_path(), + meta: jnode.data, + new_shelf: newShelf + }); + } + catch (e) { + console.error(e); + showNotification("Restore has failed: " + e.message); + } + finally { + browser.runtime.onMessage.removeListener(importListener); + $("#backup-button").prop("disabled", false); + clearInterval(this.processingInterval); + this.restoreIsInProcess = false; + this.setStatus("Ready"); + } + } + + async deleteSelectedBackups() { + if (!await confirm("Warning", "Delete the selected backups?")) + return; + + const selected = this.backupTree.get_selected(true); + + for (let jnode of selected) { + const success = await send.deleteBackup({ + directory: settings.backup_directory_path(), + meta: jnode.data + }); + + if (success) { + this.overallBackupSize -= jnode.data.file_size; + this.backupTree.delete_node(jnode); + } + } + + this.updateOverallSize(); + } + +} + +function configureBackupCompressionPanel() { + const compMethod = $("#option-compression-method").val(settings.backup_compression_method() || "DEFLATE"); + const compLevel = $("#option-compression-level").val(settings.backup_compression_level() || "5"); + + $("#option-compression-method option[value='EMPTY']").remove(); + $("#option-compression-level option[value='EMPTY']").remove(); + + simpleSelectric(compMethod); + selectricRefresh(compMethod); + simpleSelectric(compLevel); + selectricRefresh(compLevel); + + $("#option-compression-method").on("change", async e => { + await settings.load(); + settings.backup_compression_method(e.target.value) + }); + $("#option-compression-level").on("change", async e => { + await settings.load(); + settings.backup_compression_level(parseInt(e.target.value)) + }); +} + +export function load() { + $("a.settings-menu-item[href='#backup']").show(); + configureBackupCompressionPanel(); + + new BackupManager().init(); +} diff --git a/addon/ui/options/options_capture.html b/addon/ui/options/options_capture.html new file mode 100644 index 00000000..268524c1 --- /dev/null +++ b/addon/ui/options/options_capture.html @@ -0,0 +1,166 @@ + + + + + + + + + +
Capture Options +
+
+
+
+  Save Features  + +
+ +
+
+ +
+
+ +
+
+
+ +
+
+  Saved Items  +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+  Limits  +
+ +
+
+ +
+
+ +
+
+
+ +
+
+  Mixed Content  +
+ +
+
+
+ +
+
+  Referer Header  +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+  Lazy Loading  +
+ +
+
+ +
+
+
+
+
+
diff --git a/addon/ui/options/options_capture.js b/addon/ui/options/options_capture.js new file mode 100644 index 00000000..98856c59 --- /dev/null +++ b/addon/ui/options/options_capture.js @@ -0,0 +1,132 @@ +import {send} from "../../proxy.js"; +import {settings} from "../../settings.js"; +import {setSaveCheckHandler} from "../options.js"; + +async function loadCaptureSettings() { + let object = await browser.storage.local.get("savepage-settings"); + object = object["savepage-settings"]; + + function loadCheck(id, v) { + $(`#${id}`).prop("checked", v || object[id]); + } + + function loadValue(id) { + $(`#${id}`).val(object[id]); + } + + /* General options */ + loadCheck("options-retaincrossframes"); + loadCheck("options-removeunsavedurls"); + loadCheck("options-loadshadow"); + + /* Saved Items options */ + loadCheck("options-savehtmlaudiovideo"); + loadCheck("options-savehtmlobjectembed"); + loadCheck("options-savehtmlimagesall"); + loadCheck("options-savecssimagesall"); + loadCheck("options-savecssfontswoff"); + loadCheck("options-savecssfontsall"); + loadCheck("options-savescripts"); + + $("#options-savecssfontswoff").prop("disabled", $("#options-savecssfontsall").is(":checked")); + $("#options-savecssfontsall").on("click", e => { + $("#options-savecssfontswoff").prop("disabled", $("#options-savecssfontsall").is(":checked")); + }); + + loadCheck("options-removeelements"); + + /* Advanced options */ + loadValue("options-maxframedepth"); + loadValue("options-maxresourcesize"); + loadValue("options-maxresourcetime"); + loadCheck("options-allowpassive"); + + $(`#options-refererheader input[name="header"]`, "").val([object["options-crossorigin"]]); + + if (object["options-lazyloadtype"] === "1") + loadCheck("options-lazyloadtype-1", true); + else if (object["options-lazyloadtype"] === "2") + loadCheck("options-lazyloadtype-2", true); +} + +async function storeCaptureSettings(e) { + + if (e.target.id === "options-lazyloadtype-1") + $("#options-lazyloadtype-2").prop("checked", false); + else if (e.target.id === "options-lazyloadtype-2") + $("#options-lazyloadtype-1").prop("checked", false); + + let lazyLoadType = "0"; + if ($("#options-lazyloadtype-1").is(":checked")) + lazyLoadType = "1"; + else if ($("#options-lazyloadtype-2").is(":checked")) + lazyLoadType = "2"; + + // option "options-savedelaytime" is currently not represented in UI, 0 by default + + let newSettings = { + /* General options */ + + "options-retaincrossframes": $("#options-retaincrossframes").is(":checked"), + "options-removeunsavedurls": $("#options-removeunsavedurls").is(":checked"), + "options-loadshadow": $("#options-loadshadow").is(":checked"), + + /* Saved Items options */ + + "options-savehtmlaudiovideo": $("#options-savehtmlaudiovideo").is(":checked"), + "options-savehtmlobjectembed": $("#options-savehtmlobjectembed").is(":checked"), + "options-savehtmlimagesall": $("#options-savehtmlimagesall").is(":checked"), + "options-savecssimagesall": $("#options-savecssimagesall").is(":checked"), + "options-savecssfontswoff": $("#options-savecssfontswoff").is(":checked"), + "options-savecssfontsall": $("#options-savecssfontsall").is(":checked"), + "options-savescripts": $("#options-savescripts").is(":checked"), + + /* Advanced options */ + + "options-maxframedepth": +$("#options-maxframedepth").val(), + "options-maxresourcesize": +$("#options-maxresourcesize").val(), + "options-maxresourcetime": +$("#options-maxresourcetime").val(), + "options-allowpassive": $("#options-allowpassive").is(":checked"), + "options-crossorigin": +$(`#options-refererheader input[name="header"]:checked`).val(), + "options-removeelements": $("#options-removeelements").is(":checked"), + "options-lazyloadtype": lazyLoadType + }; + + let settings = await browser.storage.local.get("savepage-settings"); + settings = settings["savepage-settings"] || {}; + + Object.assign(settings, newSettings); + + await browser.storage.local.set({"savepage-settings": settings}); + + send.savepageSettingsChanged(); +} + +async function loadSaveSettings() { + $("#option-save-unpacked-archives").prop("checked", settings.save_unpacked_archives()); +} + +function configureCaptureSettingsPage() { + $(`#div-capture input[type="checkbox"]`).on("click", storeCaptureSettings); + $(`#div-capture input[type="radio"]`).on("click", storeCaptureSettings); + $(`#div-capture input[type="number"]`).on("input", storeCaptureSettings); + + if (!settings.platform.firefox) { + $("#options-originonly").prop("disabled", true); + $("#options-originpath").prop("disabled", true); + $("label[for='options-originonly']").css("color", "grey"); + $("label[for='options-originpath']").css("color", "grey"); + } + + setSaveCheckHandler("option-save-unpacked-archives", "save_unpacked_archives"); + + // if (settings.platform.firefox && !settings.storage_mode_internal()) + // $("#save-options-row").show(); +} + +export async function load() { + await settings.load(); + await loadCaptureSettings(); + await loadSaveSettings(); + configureCaptureSettingsPage(); +} diff --git a/addon/ui/options/options_changes.html b/addon/ui/options/options_changes.html new file mode 100644 index 00000000..aa153e90 --- /dev/null +++ b/addon/ui/options/options_changes.html @@ -0,0 +1,191 @@ +
+

v2.2 February 17, 2024

+
    +
  • When already bookmarked pages are opened in the browser, a special action icon + is shown on the browser toolbar.
  • +
  • Changed semantics of the iShell bookmarking commands. + See the iShell help for more details.
  • +
+ +

v2.1 November 6, 2022

+
    +
  • Added the files shelf. + This feature requires the backend application v2.1+.
  • +
  • Added an option to create references to the recently archived items at the browser + bookmarks toolbar. See the Browser Bookmarks section on the main settings page.
  • +
  • A limited set of functionality is available without the backend application when the "Content location" option + on the main settings page is set to "Browser internal storage".
  • +
+ +

v2.0 October 16, 2022

+
    +
  • Archived content is stored in the filesystem instead of the browser internal storage. + +
  • +
  • Added the "Export..." item to the context menu of a folder.
  • +
+ +

v1.3 September 20, 2022

+
    +
  • Improved full-text search algorithm.
  • +
+ +

v1.2 August 10, 2022

+
    +
  • Scrapyard is available on Chrome.
  • +
+ +

v1.1 July 2, 2022

+
    +
  • Added options to visually emphasize archives.
  • +
+ +

v1.0 🎉 June 8, 2022

+
    +
  • Added the ability to automatically capture entire sites. Please see the + corresponding section of the user manual for + more details.
  • +
  • Added the universal sidebar filtering mode (now default) that performs search by all attributes.
  • +
  • Deleted items could now be restored with the "Undo" shelf menu command.
  • +
  • It is possible to convert bookmarks into archives by using the "Archive" bookmark context menu item.
  • +
  • Incompatible changes in the integration with iShell. + Scrapyard v1.0 requires iShell v0.4+.
  • +
  • It is recommended to update the backend application to version 1.0.
  • +
+ +

v0.17 May 25, 2022

+
    +
  • Added new keyboard shortcuts (Alt+Q, Alt+W) to quickly bookmark + or archive the active tab into the default shelf. Please see the + corresponding section of the user manual for + more details.
  • +
+ +

v0.16 December 22, 2021

+
    +
  • Added OneDrive support to the + add-on and the Android application.
  • +
  • Added ability to browse all synchronized bookmarks in the Android application (since v2.0).
  • +
+ +

v0.15 November 28, 2021

+
    +
  • Added file-based synchronization. + This feature requires Scrapyard backend application + v0.5+. Please carefully read the corresponding user manual section for more details. +
  • +
  • Added sidebar filtering by folder name.
  • +
  • Added long-tap context menu in the Cloud shelf browser of the Android application.
  • +
  • Incompatible changes in the Cloud shelf internal storage format. The Android application understands + them since version 1.11.
  • +
+ +

v0.14 June 2, 2021

+
    +
  • Added duplicate URL finder. Available as a mode of the link checker.
  • +
+ +

v0.13 May 31, 2021

+
    +
  • Updated user interface to accommodate changes introduced by the new Firefox "Proton" design.
  • +
  • Added "copy-at" and "move-at" iShell commands. This feature requires + iShell v0.3+. + See the help on text command interface for more details.
  • +
  • Added ability to upload local files through the "Upload..." context menu item of a folder. This feature requires Scrapyard + backend application v0.4+.
  • +
  • Added ability to upload local files through the + automation API.
  • +
+ +

v0.12 May 12, 2021

+
    +
  • Added backup management UI (available at the settings + page). This feature requires Scrapyard backend application + v0.3+. See the help on backup for more details. +
  • +
  • Added page info icon and tooltip to the page edit toolbar.
  • +
  • Added import/export of the Scrapyard settings.
  • +
  • Added sidebar filtering by date.
  • +
  • Maximum sidebar shelf list height may be set in pixels.
  • +
  • Added "Locate" context menu item for filtered bookmarks.
  • +
+ +

v0.11 April 25, 2021

+
    +
  • Added full text search through the content of archived web-pages.
  • +
  • Added sidebar filtering by the content of attached notes and comments.
  • +
+ +

v0.10 April 21, 2021

+
    +
  • Added an option to not display the edit toolbar by default. The toolbar can always be toggled + with the Ctrl+Alt+T key combination.
  • +
  • The size in bytes is shown for the newly archived pages at the "More" section of the + property dialog. The number should be considered as a rough approximation of the item + size saved in UTF-8 format.
  • +
  • Added automation API. + The "hideTab" permission is required for an API option to hide tabs appearing in the case of mass API calls. + See the automation documentation + for more details.
  • +
+ +

v0.9 April 14, 2021

+
    +
  • Added multi-account containers support.
  • +
+ +

v0.8April 9, 2021

+
    +
  • Added option to display random bookmarks from your collection at the sidebar. + Please clean the browser cache if not all sidebar icons are displayed properly.
  • +
  • Added search through all bookmarks/archives from the URL-bar with the special "scr" keyword. + See the help section on search for more details.
  • +
  • Added 'New > Sibling Folder' context menu item.
  • +
  • Added option to select sidebar color theme.
  • +
+ +

v0.7 March 21, 2021

+
    +
  • Added quick comments at the bookmark properties.
  • +
  • Added WYSIWYG editor for notes.
  • +
+ +

v0.6 February 12, 2021

+
    +
  • Added internal favicon storage. + To locally store icons of existing bookmarks, use the "Repair icons..." context menu item.
  • +
+ +

v0.5 Feb 5, 2021

+
    +
  • Added the native backend application.
  • +
  • Added "Repair icons..." folder context menu item (lately renamed to "Check Links" with the same functionality).
  • +
  • Added sidebar toggle keyboard shortcut: Alt+Y.
  • +
  • Added option to open bookmarks in the active tab.
  • +
+ +

v0.4 November 15, 2019

+
    +
  • Added the ability to create bookmark URLs. See the help on notes for more details.
  • +
+ +

v0.3 September 2, 2019

+
    +
  • Added cloud bookmarking.
  • +
+ +

v0.2 August 18, 2019

+
    +
  • Added Firefox bookmark integration.
  • +
  • Added ability to import Scrapbook RDF files.
  • +
  • Added JSON as import/export format.
  • +
  • Added notes in Markdown and plain text formats.
  • +
+ +

v0.1 May 30, 2019

+
    +
  • Initial release.
  • +
+
diff --git a/addon/ui/options/options_checklinks.css b/addon/ui/options/options_checklinks.css new file mode 100644 index 00000000..43a2889a --- /dev/null +++ b/addon/ui/options/options_checklinks.css @@ -0,0 +1,172 @@ +#check-links { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; + min-height: 0; + height: 100%; + box-sizing: border-box; +} + +#check-links > * { + flex: 0 1 auto; +} + +#link-check-scope-container { + padding-top: 5px; +} + +#link-check-timeout { + margin-left: 8px; + width: 50px; +} + +#update-icons { + margin-left: 12px; +} + +#link-check-scope { + display: none; +} + +#check-timeout-label, #link-check-mode-label { + padding-left: 12px; +} + +#link-check-mode-label { + margin-right: 8px; +} + +#link-check-mode { + display: none; +} + +#link-check-options { + line-height: 22px; + vertical-align: middle; + margin-left: 16px; + margin-bottom: 16px; + white-space: nowrap; + display: flex; + flex-wrap: nowrap; + align-items: center; + align-content: center; +} + +.header-validity { + padding-bottom: 8px; + display: block; +} + +.header-duplicates { + +} + +.link-check-service-cell { + width: 0; +} + +.result-action-icon, .result-action-icon-last { + width: 16px; + height: 16px; + cursor: pointer; + margin-right: 2px; +} + +.result-action-icon-last { + margin-right: 8px; +} + +.link-info { + margin-left: 16px; +} + +.link-check-error { + white-space: nowrap; +} + +#start-check-links, #delete-selected-links { + margin-left: 16px; + height: 22px; + padding-top: 0; + padding-bottom: 0; +} + +#start-check-links { + width: 63px; +} + +#delete-selected-links { + display: none; +} + +#check-results-container { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + display: none; +} + +#check-results { + margin-left: 15px; + width: calc(100% - 15px); +} + +#current-link { + margin-bottom: 16px; + display: flex; + visibility: hidden; +} + +#current-link > div:first-child { + margin-right: 8px; + line-height: 28px; + vertical-align: middle; +} + +#current-link-title { + overflow: hidden; +} + +#current-link-url { + font-size: 80%; + overflow: hidden; +} + +#current-link-title, #current-link-url { + display: block; + white-space: nowrap; + overflow: hidden; +} + +.validated-link { + padding-left: 8px; +} + +.validated-node { + color: #0074D9; + cursor: pointer; +} + +.duplicate-link { + padding-top: 8px; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 0; + max-width: 0; +} + +.duplicate-node { + color: #0074D9; + cursor: pointer; +} + +.link-check-breadcrumb { + color: #555; +} + +.archive-link { + font-style: italic; +} diff --git a/addon/ui/options/options_checklinks.html b/addon/ui/options/options_checklinks.html new file mode 100644 index 00000000..b2b56a99 --- /dev/null +++ b/addon/ui/options/options_checklinks.html @@ -0,0 +1,30 @@ + diff --git a/addon/ui/options/options_checklinks.js b/addon/ui/options/options_checklinks.js new file mode 100644 index 00000000..7c07ce3b --- /dev/null +++ b/addon/ui/options/options_checklinks.js @@ -0,0 +1,419 @@ +import {settings} from "../../settings.js"; +import {selectricRefresh, ShelfList, simpleSelectric} from "../shelf_list.js"; +import {send} from "../../proxy.js"; +import {EVERYTHING_SHELF_NAME, NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK} from "../../storage.js"; +import {getFaviconFromContent} from "../../favicon.js"; +import {fetchWithTimeout} from "../../utils_io.js"; +import {confirm} from "../dialog.js"; +import {showNotification} from "../../utils_browser.js"; +import {Node} from "../../storage_entities.js"; +import {Path} from "../../path.js"; +import {Bookmark} from "../../bookmarks_bookmark.js"; +import {DiskStorage} from "../../storage_external.js"; + +const DEFAULT_LINK_CHECK_TIMEOUT = 10; + +export class LinkChecker { + + constructor() { + $("#start-check-links").on("click", () => this.startCheckLinks()); + + const linkCheckTimeoutInput = $("#link-check-timeout"); + linkCheckTimeoutInput.val(settings.link_check_timeout() || DEFAULT_LINK_CHECK_TIMEOUT) + linkCheckTimeoutInput.on("input", async e => { + await settings.load(); + let timeout = parseInt(e.target.value); + settings.link_check_timeout(isNaN(timeout)? DEFAULT_LINK_CHECK_TIMEOUT: timeout); + }); + + this.shelfList = new ShelfList("#link-check-scope", { + maxHeight: settings.shelf_list_height() || settings.default.shelf_list_height, + _prefix: "checklinks" + }); + + this.shelfList.initDefault(); + + const linkCheckModeSelect = $("#link-check-mode"); + simpleSelectric("#link-check-mode"); + selectricRefresh(linkCheckModeSelect); + + const updateIconsCheck = $("#update-icons"); + linkCheckModeSelect.on("change", e => { + if (e.target.value === "duplicates") { + updateIconsCheck.prop("checked", false); + updateIconsCheck.prop("disabled", true); + } + else + updateIconsCheck.prop("disabled", false); + }); + + $("#delete-selected-links").on("click", e => this.deleteSelected()); + + this.resultCount = 0; + } + + async init() { + const urlParams = new URLSearchParams(window.location.search); + this.autoStartCheckLinks = !!urlParams.get("menu"); + + if (this.autoStartCheckLinks) { + $("#update-icons").prop("checked", urlParams.get("repairIcons") === "true"); + let scopePath = await Path.compute(parseInt(urlParams.get("scope"))); + $("#link-check-scope-container", $("#check-links")) + .replaceWith(`${scopePath.slice(-1)[0].name}  `); + this.autoLinkCheckScope = scopePath.map(g => g.name).join("/"); + this.startCheckLinks(); + } + } + + async deleteSelected() { + const selectedItems = $(".check-result-selected:checked"); + + if (selectedItems.length && await confirm("Warning", "Do you really want to delete the selected items?")) { + const nodes = []; + for (const check of selectedItems.get()) { + nodes.push(parseInt(check.id)); + $(check).closest("tr").remove(); + } + + await send.deleteNodes({node_ids: nodes}); + send.nodesUpdated(); + } + else if (!selectedItems.length) + showNotification("Nothing is selected."); + } + + async _makeBreadCrumb(node) { + const path = await Path.compute(node); + path.length = path.length - 1; + + let breadcrumb = "   "; + + for (let i = 0; i < path.length; ++i) { + breadcrumb += path[i].name; + + if (i !== path.length - 1) + breadcrumb += " » " + } + + return breadcrumb; + } + + async _checkForValidity() { + const checkResultContainerDiv = $("#check-results-container"); + const checkResultsTitle = $("#check-results-title"); + const checkResultTable = $("#check-results"); + const currentLinkDiv = $("#current-link"); + const updateIcons = $("#update-icons").is(":checked"); + let path; + + if (this.autoStartCheckLinks) + path = this.autoLinkCheckScope; + else { + let scope = this.shelfList.selectedShelfName; + path = scope === EVERYTHING_SHELF_NAME ? undefined : scope; + } + + currentLinkDiv.show(); + currentLinkDiv.css("visibility", "visible"); + checkResultContainerDiv.hide(); + checkResultsTitle.text("Broken links:"); + checkResultsTitle.prop("class", "header-validity"); + checkResultTable.empty(); + + async function updateIcon(node, html) { + let favicon = await getFaviconFromContent(node.uri, html); + + if (favicon) { + node.icon = favicon; + await Bookmark.storeIcon(node); + } + else if (node.icon && !node.stored_icon) { + node.icon = undefined; + await Node.update(node); + } + } + + const displayLinkError = async (error, node) => { + checkResultContainerDiv.show(); + + const breadcrumb = await this._makeBreadCrumb(node); + + checkResultTable.append( + ` + + + + + + + + + + + + + ${error} + + ${node.name} + ${breadcrumb} + + ` + ); + + $(`#link-check-select-${node.id}`).click(e => send.selectNode({node})); + + const link = $(`#checked-link-${node.id}`); + link.click(e => send.browseNode({node})); + + if (node.type === NODE_TYPE_ARCHIVE) { + link.addClass("archive-link"); + $(`#link-check-open-url-${node.id}`).prop("title", "Open Original URL") + } + } + + const nodes = await Bookmark.list({path: path, types: [NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK]}); + + try { + if (updateIcons) + await DiskStorage.openBatchSession(); + + for (let node of nodes) { + if (this.abortCheckLinks) + break; + + if (!node.uri) + continue; + + $("#current-link-title").text(node.name); + $("#current-link-url").text(node.uri); + + let error; + let networkError; + let contentType; + let response; + + try { + let timeout = parseInt($("#link-check-timeout").val()); + timeout = isNaN(timeout)? DEFAULT_LINK_CHECK_TIMEOUT: timeout; + response = await fetchWithTimeout(node.uri, {timeout: timeout * 1000}); + + if (this.abortCheckLinks) + break; + + if (!response.ok) + error = `[HTTP Error: ${response.status}]`; + else + contentType = response.headers.get("content-type"); + } catch (e) { + networkError = true; + + if (e.name === "AbortError") + error = `[Timeout]`; + else + error = "[Unavailable]" + } + + if (error) { + this.resultCount += 1; + this._appendSelectAllRow(checkResultTable, 5); + await displayLinkError(error, node); + + if (networkError && updateIcons && node.icon && !node.stored_icon) { + node.icon = undefined; + await Node.update(node); + } + } + else if (updateIcons && contentType?.toLowerCase()?.startsWith("text/html")) { + try { + await updateIcon(node, await response.text()); + } catch (e) { + console.error(e) + } + } + } + } + finally { + if (updateIcons) + await DiskStorage.closeBatchSession(); + } + } + + async _checkForDuplicates() { + const checkResultContainerDiv = $("#check-results-container"); + const checkResultsTitle = $("#check-results-title"); + const checkResultTable = $("#check-results"); + const currentLinkDiv = $("#current-link"); + const scope = this.shelfList.selectedShelfName; + const path = scope === EVERYTHING_SHELF_NAME ? undefined : scope; + + currentLinkDiv.hide(); + checkResultContainerDiv.hide(); + checkResultsTitle.text("Duplicate links:"); + checkResultsTitle.prop("class", "header-duplicates"); + checkResultTable.empty(); + + send.startProcessingIndication({noWait: true}) + + const nodes = await Bookmark.list({path: path, types: [NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK]}); + const links = new Map(); + + for (let node of nodes) { + if (this.abortCheckLinks) + break; + + if (!node.uri) + continue; + + const uri = node.uri.toLocaleLowerCase(); + + if (links.has(uri)) { + let counter = links.get(uri); + counter += 1; + links.set(uri, counter) + this.resultCount += 1; + } + else { + links.set(uri, 1); + } + } + + const buckets = new Map(); + for (const [link, count] of links.entries()) { + if (count > 1) { + const duplicates = nodes.filter(n => n.uri?.toLocaleLowerCase() === link); + duplicates.sort((a, b) => a.id - b.id); + buckets.set(link, duplicates); + } + } + + function displayLink(link) { + checkResultTable.append( + ` + ${decodeURIComponent(link)} + ` + ); + } + + const displayLinkDuplicate = async node => { + checkResultContainerDiv.show(); + + const breadcrumb = await this._makeBreadCrumb(node); + + checkResultTable.append( + ` + + + + + + + + ${node.name} + ${breadcrumb} + + ` + ); + + $(`#link-check-select-${node.id}`).click(e => send.selectNode({node})); + + const link = $(`#checked-link-${node.id}`); + link.click(e => send.browseNode({node})); + + if (node.type === NODE_TYPE_ARCHIVE) { + link.addClass("archive-link"); + link.prop("href", `ext+scrapyard://${node.uuid}`); + } + } + + if (this.resultCount) { + for (const [link, bucket] of buckets.entries()) { + this._appendSelectAllRow(checkResultTable, 2); + displayLink(link); + for (const node of bucket) { + await displayLinkDuplicate(node); + } + } + } + else { + showNotification("No duplicates found."); + } + + send.stopProcessingIndication(); + } + + _appendSelectAllRow(table, span) { + if (table.children().length === 0) { + table.append(` + + + + + + `); + + $("#check-all-links").on("change", e => { + if ($(e.target).is(":checked")) + $(".check-result-selected").prop("checked", true); + else + $(".check-result-selected").prop("checked", false); + }); + } + } + + async startCheckLinks() { + const startCheckLinksButton = $("#start-check-links"); + + if (!this.checkIsInProgress && startCheckLinksButton.val() === "Check") { + this.resultCount = 0; + this.checkIsInProgress = true; + try { + startCheckLinksButton.val("Stop"); + $("#delete-selected-links").hide(); + + if ($("#link-check-mode").val() === "validity") + await this._checkForValidity(); + else + await this._checkForDuplicates(); + } + finally { + this.checkIsInProgress = false; + this.stopCheckLinks(); + } + } else if (this.checkIsInProgress && startCheckLinksButton.val() === "Check") { + showNotification("Please wait and retry later.") + } + else { + this.stopCheckLinks(); + this.abortCheckLinks = true; + } + } + + stopCheckLinks() { + $("#start-check-links").val("Check"); + $("#current-link-title").text(""); + $("#current-link-url").text(""); + $("#current-link").css("visibility", "hidden"); + this.abortCheckLinks = false; + + if (this.resultCount) + $("#delete-selected-links").show(); + + if ($("#update-icons").is(":checked")) { + setTimeout(() => send.nodesUpdated(), 500); + } + } +} + +export function load() { + return new LinkChecker().init(); +} diff --git a/addon/ui/options/options_cloud.html b/addon/ui/options/options_cloud.html new file mode 100644 index 00000000..a3b18e35 --- /dev/null +++ b/addon/ui/options/options_cloud.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + +
Status +
+
+
+
+ +
+
+
+
+
+
+ + + May consume system resources. +
+
+
+
+
Providers +
+
+
+
+ +  (dropbox.com) + +
+
+ +  (onedrive.live.com) + +
+
+
+
+
Platforms +
+
+

+ Scrapyard application for Android + allows to share links and content to the Cloud shelf from mobile devices. +

+ +
+
+
diff --git a/addon/ui/options/options_cloud.js b/addon/ui/options/options_cloud.js new file mode 100644 index 00000000..fe2a6c6a --- /dev/null +++ b/addon/ui/options/options_cloud.js @@ -0,0 +1,72 @@ +import {settings} from "../../settings.js"; +import {setSaveCheckHandler} from "../options.js" +import {oneDriveClient} from "../../cloud_client_onedrive.js"; +import {dropboxClient} from "../../cloud_client_dropbox.js"; +import {send} from "../../proxy.js"; + +async function configureCloudSettingsPage() { + const dropboxRadio = $("#provider-dropbox"); + const oneDriveRadio = $("#provider-onedrive"); + + let activeProvider; + if (settings.active_cloud_provider() === oneDriveClient.ID) + activeProvider = oneDriveClient; + else + activeProvider = dropboxClient; + + $("input[name=cloud-providers][value=" + activeProvider.ID + "]").prop('checked', true); + + async function setActiveProvider(provider) { + activeProvider = provider; + await settings.active_cloud_provider(activeProvider.ID); + await send.cloudProviderChanged({provider: activeProvider.ID}); + if (activeProvider.isAuthenticated()) + send.shelvesChanged({synchronize: true}) + } + + dropboxRadio.on("change", e => setActiveProvider(dropboxClient)); + oneDriveRadio.on("change", e => setActiveProvider(oneDriveClient)); + + const enableCloudCheck = $("#option-enable-cloud"); + enableCloudCheck.prop("checked", settings.cloud_enabled()); + enableCloudCheck.on("change", async e => { + await settings.load(); + await settings.cloud_enabled(e.target.checked); + + if (e.target.checked) { + const success = await activeProvider.authenticate(); + if (success) + $(`#auth-${activeProvider.ID}`).val("Sign out"); + } + + send.reconcileCloudBookmarkDb() + }); + + $("#option-cloud-background-sync").prop("checked", settings.cloud_background_sync()); + await setSaveCheckHandler("option-cloud-background-sync", "cloud_background_sync", async e => { + send.enableCloudBackgroundSync({enable: e.target.checked}); + }); + + if (dropboxClient.isAuthenticated()) + $(`#auth-dropbox`).val("Sign out"); + if (oneDriveClient.isAuthenticated()) + $(`#auth-onedrive`).val("Sign out"); + + function providerAuthHandler(provider) { + return async () => { + if (provider.isAuthenticated()) + await provider.signOut(); + else + await provider.authenticate(); + + $(`#auth-${provider.ID}`).val(provider.isAuthenticated()? "Sign out": "Sign in"); + }; + } + + $("#auth-dropbox").on("click", providerAuthHandler(dropboxClient)); + $("#auth-onedrive").on("click", providerAuthHandler(oneDriveClient)); +} + +export async function load() { + await configureCloudSettingsPage(); +} diff --git a/addon/ui/options/options_debug.html b/addon/ui/options/options_debug.html new file mode 100644 index 00000000..b6f192cf --- /dev/null +++ b/addon/ui/options/options_debug.html @@ -0,0 +1,42 @@ + +
+

Scrapyard Debug

+ + + + + + + + + + + + + + + + + +
Browser version:
Add-on version:
Internal storage mode:
Unpacked archives:
+ +

+ Enter the following URL into the address bar to open the debug console: +

   +

+ +
+
+ + + Browser restart is required. +
+

Open the backend log

+
+
diff --git a/addon/ui/options/options_debug.js b/addon/ui/options/options_debug.js new file mode 100644 index 00000000..431106d4 --- /dev/null +++ b/addon/ui/options/options_debug.js @@ -0,0 +1,22 @@ +import {setSaveCheckHandler} from "../options.js"; +import {settings} from "../../settings.js"; +import {helperApp} from "../../helper_app.js"; + +export async function load() { + $("a.settings-menu-item[href='#debug']").show(); + + $("#debug-browser-version").text(navigator.userAgent); + $("#debug-addon-version").text(browser.runtime.getManifest().version); + $("#debug-internal-storage-mode").text(settings.storage_mode_internal()? "Yes": "No"); + $("#debug-unpacked-archives").text(settings.save_unpacked_archives()? "Yes": "No"); + + const addonID = browser.runtime.getManifest().applications?.gecko?.id; + const consoleURL = `about:devtools-toolbox?id=${addonID}&type=extension` + $("#debug-log-url").text(consoleURL) + + setSaveCheckHandler("option-enable-helper-app-logging", "enable_helper_app_logging"); + $("#option-enable-helper-app-logging").prop("checked", settings.enable_helper_app_logging()); + + $("#helper-app-log-link").prop("href", helperApp.url("/backend_log")); +} + diff --git a/addon/ui/options/options_diagnostics.html b/addon/ui/options/options_diagnostics.html new file mode 100644 index 00000000..44485b73 --- /dev/null +++ b/addon/ui/options/options_diagnostics.html @@ -0,0 +1,14 @@ + +
+

Scrapyard Diagnostics

+

Please do not try to reinstall the add-on. Create an issue at the Scrapyard + support page + with the info below.

+ +

+
diff --git a/addon/ui/options/options_diagnostics.js b/addon/ui/options/options_diagnostics.js new file mode 100644 index 00000000..ec766f72 --- /dev/null +++ b/addon/ui/options/options_diagnostics.js @@ -0,0 +1,51 @@ +function configureDiagnosticsPage() { + $("a.settings-menu-item[href='#diagnostics']").show(); + + function isIDBWriteError(error) { + return error.name === "OpenFailedError" && error.message + && error.message.includes("A mutation operation was attempted on a " + + "database that did not allow mutations"); + } + + function formatIDBWriteError() { + const errorDescriptionPre = $("#diagnostics-error-info"); + const parent = errorDescriptionPre.parent(); + errorDescriptionPre.remove(); + $("#diagnostics-guide").remove(); + + $("

Scrapyard can not open its database for writing. " + + "This may be a consequence of particular combination of browser and system settings or an interference with " + + "Firefox profile files, for example, by an antivirus as it is explained on the addon " + + "page

.") + .appendTo(parent); + } + + function formatGenericError(error) { + $("#diagnostics-error-info").text( + `Error name: ${error.name}\n` + + `Error message: ${error.message}\n` + + `Origin: ${error.origin}\n` + + `Browser version: ${navigator.userAgent}\n\n` + + `Stacktrace\n\n` + + `${error.stack}`); + } + + let error = localStorage.getItem("scrapyard-diagnostics-error"); + if (error) { + error = JSON.parse(error); + + if (isIDBWriteError(error)) + formatIDBWriteError(); + else + formatGenericError(error); + + localStorage.removeItem("scrapyard-diagnostics-error"); + } + else { + $("#diagnostics-error-info").text("No errors detected."); + } +} + +export function load() { + configureDiagnosticsPage(); +} diff --git a/addon/ui/options/options_help.js b/addon/ui/options/options_help.js new file mode 100644 index 00000000..7b17e4d4 --- /dev/null +++ b/addon/ui/options/options_help.js @@ -0,0 +1,47 @@ +import {fetchText} from "../../utils_io.js"; +import {settings} from "../../settings.js"; + +function scrollToElement (subsection) { + let element = document.getElementById(subsection); + let offset = element.getBoundingClientRect(); + $("#div-help").prop("scrollTop", offset.top) +} + +export async function load() { + if ($("#div-help").is(":empty")) { + let help = await fetchText("locales/en/help.html"); + help = help.replaceAll(`src="images/`, `src="locales/en/images/`); + $("#div-help").html(help); + } + + $("[id^='for-read-more-']").on("click", expandReadMore) + + if (settings.transition_to_disk()) + $(".transition-help-warning").show(); +} + +export function navigate(subsection) { + $("#div-help").prop("scrollTop", 0) + if (subsection) { + setTimeout(() => scrollToElement(subsection), 500); + } +} + +function expandReadMore(evt) { + const elt = $(evt.target).closest("a")[0]; + const divId = elt.id.replace(/^for-/, ""); + const div = document.getElementById(divId); + evt.preventDefault(); + + if (elt.id === "for-read-more-table-of-contents") { + $("#read-more-table-of-contents").toc({content: "#help-content", prefix: "help:"}); + $("#help-content h1, #help-content h2, #help-content h3").append(" 🠉"); + $(".to-top").on("click", e => { + e.preventDefault(); + $("#div-help").prop("scrollTop", 0) + }); + } + + div.style.display = "block"; + elt.parentElement.removeChild(elt); +} diff --git a/addon/ui/options/options_rdf.css b/addon/ui/options/options_rdf.css new file mode 100644 index 00000000..cca67d21 --- /dev/null +++ b/addon/ui/options/options_rdf.css @@ -0,0 +1,51 @@ + +#rdf-import-options { + width: max-content; +} + +#rdf-shelf-name { + flex: 0 0 auto; +} + +#rdf-import-create-search-index-div { + flex: 1 0 auto; + text-align: right; +} + +#rdf-import-threads { + width: 50px; +} + +#rdf-progress-row { + /*display: none;*/ + /*padding-right: 26px;*/ +} + +#rdf-import-progress { + width: 100%; +} + +#start-rdf-import { + margin-left: 16px; + height: 22px; + padding-top: 0; + padding-bottom: 0; + margin-right: 4px; + float: right; +} + +#invalid-imports-container { + flex: 1 1 auto; + padding-top: 16px; + overflow-x: hidden; + overflow-y: auto; + display: none; +} + +.rdf-link-info { + margin-left: 16px; +} + +.invalid-import { + padding-left: 8px; +} diff --git a/addon/ui/options/options_rdf.html b/addon/ui/options/options_rdf.html new file mode 100644 index 00000000..35cf22bb --- /dev/null +++ b/addon/ui/options/options_rdf.html @@ -0,0 +1,53 @@ +
+

Import Scrapbook Files

+
+
+ + + + + + + + + + + + + + + + + + +
Shelf name:
RDF file:   + +
Import type: + +
+
+
Status:  Ready
+ Help + +
+
+ +
diff --git a/addon/ui/options/options_rdf.js b/addon/ui/options/options_rdf.js new file mode 100644 index 00000000..767ca933 --- /dev/null +++ b/addon/ui/options/options_rdf.js @@ -0,0 +1,140 @@ +import {send} from "../../proxy.js"; +import {isBuiltInShelf} from "../../storage.js"; +import {showNotification} from "../../utils_browser.js"; +import {Query} from "../../storage_query.js"; +import {ensureSidebarWindow} from "../../utils_sidebar.js"; +import {selectricRefresh, simpleSelectric} from "../shelf_list.js"; +import {settings} from "../../settings.js"; + +const RDF_IMPORT_THREADS = 5; + +let importing = false; +async function onStartRDFImport(e) { + let finalize = () => { + $("#start-rdf-import").val("Import"); + $("#rdf-shelf-name").prop('disabled', false); + $("#rdf-import-path").prop('disabled', false); + //$("#rdf-import-threads").prop('disabled', false); + + $("#rdf-progress-row").text("Ready"); + importing = false; + browser.runtime.onMessage.removeListener(importListener); + }; + + const shelf = $("#rdf-shelf-name").val(); + const path = $("#rdf-import-path").val(); + const openMode = $("#rdf-import-type").val() === "rdf-open"; + + if (importing) { + send.cancelRdfImport(); + finalize(); + return; + } + + if (settings.storage_mode_internal() && !openMode) { + showNotification({message: "Only \"Open RDF for editing\" mode is available when content is stored internally."}); + return; + } + + if (settings.platform.chrome && $("#rdf-import-type").val() === "rdf-open") { + showNotification({message: "RDF editing is only available on firefox."}); + return; + } + + if (!shelf || !path) { + showNotification({message: "Please, specify all import parameters."}); + return; + } + + if (!path.toLowerCase().endsWith(".rdf")) { + showNotification({message: "Only RDF files are supported."}); + return; + } + + let shelf_node = await Query.shelf(shelf); + if (isBuiltInShelf(shelf) || shelf_node) { + showNotification({message: "The specified shelf already exists."}); + return; + } + + importing = true; + $("#start-rdf-import").val("Cancel"); + $("#rdf-shelf-name").prop('disabled', true); + $("#rdf-import-path").prop('disabled', true); + //$("#rdf-import-threads").prop('disabled', true); + + let progressRow = $("#rdf-progress-row"); + + progressRow.text("initializing bookmark directory structure..."); + //$("#rdf-import-progress").val(0); + //$("#rdf-progress-row").show(); + + let runningProgress = 0; + + let importListener = message => { + if (message.type === "rdfImportProgress") { + let bar = $("#rdf-import-progress"); + if (!bar.length) { + bar = $(``); + progressRow.empty().append(bar); + } + if (message.progress > runningProgress) { + runningProgress = message.progress; + bar.val(message.progress); + } + } + else if (message.type === "rdfImportError") { + let invalid_link = `${message.bookmark.name}`; + $("#invalid-imports-container").show(); + $("#invalid-imports").append(`${message.error}${invalid_link}`); + } + else if (message.type === "obtainingIcons") { + progressRow.text("Obtaining page icons..."); + } + }; + + browser.runtime.onMessage.addListener(importListener); + + if (!_BACKGROUND_PAGE) + await ensureSidebarWindow(1000); + + send.importFile({ + file: path, + file_name: shelf, + file_ext: "RDF", + threads: RDF_IMPORT_THREADS, + quick: openMode, + createIndex: $("#rdf-import-create-search-index").is(":checked") + }).then(finalize) + .catch(e => { + showNotification({message: e.message}); + finalize(); + }); +} + +function selectNode(e) { + e.preventDefault(); + send.selectNode({node: {id: parseInt(e.target.getAttribute("data-id"))}}); +} + +export function load() { + const rdfImportTypeSelect = simpleSelectric("#rdf-import-type"); + selectricRefresh(rdfImportTypeSelect); + + $("#rdf-import-type").on("change", e => { + if ($(e.target).val() === "rdf-open") { + $("#rdf-import-create-search-index") + //.prop("checked", false) + .prop("disabled", false) + } + else { + $("#rdf-import-create-search-index") + .prop("checked", true) + .prop("disabled", true) + } + }); + + $("#invalid-imports-container").on("click", ".invalid-import", selectNode); + $("#start-rdf-import").on("click", onStartRDFImport); +} diff --git a/addon/ui/options/options_settings.html b/addon/ui/options/options_settings.html new file mode 100644 index 00000000..6ef36950 --- /dev/null +++ b/addon/ui/options/options_settings.html @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Content +
+
+
+
+ + + +
+
+
+
+ + + + It is possible to use a limited set of Scrapyard functionality + without the
backend application when storing content in the browser internal storage.
+
+
+
+
+ + + Enable this option if Scrapyard files are updated
externally, + for example, through cloud.
+
+
+
+
+
+
+
Appearance +
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+ + + The edit toolbar could be toggled with
the Ctrl+Alt+T key combination.
+
+
+ + +  / +
+
+
+
+
+
+
Behavior +
+
+
+
+ +
+ + + Display random bookmarks from your collection at the bottom of the sidebar. +
+
+
+
+
+
+
+
+ + + Do not create new tabs for the opened bookmarks. +
+
+ +
+
+
+
+
+
Sidebar Search +
+
+
+
+ +
+
+
+
+
+
+
+ + + When this setting is enabled, cake will also match
cheesecake + in the content of a page.
+
+
+
+
+
+
Browser Bookmarks +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + + + This number of references to the newly archived or bookmarked items
+ will be kept at the Scrapyard folder at the browser bookmarks toolbar.
+ Empty value or 0 means no restrictions.
+
+
+
+
+
+
+
Files +
+
+
+
+ + + The files shelf allows to browse org-mode files
and files of other types as notes in Scrapyard.
+
+
+
+
+
+
+
+ + + + The full path or name (accessible through PATH)
of your text editor executable.
+
+
+
+
+
+
Backend +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/addon/ui/options/options_settings.js b/addon/ui/options/options_settings.js new file mode 100644 index 00000000..d0901c3d --- /dev/null +++ b/addon/ui/options/options_settings.js @@ -0,0 +1,211 @@ +import {settings} from "../../settings.js"; +import {setSaveCheckHandler} from "../options.js"; +import {selectricRefresh, simpleSelectric} from "../shelf_list.js"; +import {STORAGE_POPULATED} from "../../storage.js"; +import {send} from "../../proxy.js"; +import {confirm} from "../dialog.js"; +import {helperApp, HELPER_APP_v2_1_IS_REQUIRED} from "../../helper_app.js"; +import {filesShelf} from "../../plugin_files_shelf.js"; + +function configureScrapyardSettingsPage() { + simpleSelectric("#option-sidebar-theme"); + const storageModeSelect = simpleSelectric("#option-storage-mode"); + + let dataFolderInputTimeout; + $("#option-data-folder-path").on("input", e => { + clearTimeout(dataFolderInputTimeout); + dataFolderInputTimeout = setTimeout(async () => { + await settings.load(); + + const path = e.target.value + await settings.data_folder_path(path); + + const status = await send.checkSyncDirectory({path}); + + if (status === STORAGE_POPULATED) + send.performSync(); + + }, 1000); + }); + + storageModeSelect.on("change", async e => { + if (e.target.value === "filesystem") + setStorageModeToFilesystem(); + else + setStorageModeToInternal(); + }); + + if (settings.storage_mode_internal()) { + $("#option-data-folder-path").prop("disabled", true); + $("#option-synchronize-at-startup").prop("disabled", true); + } + + $("#option-sidebar-theme").on("change", e => { + localStorage.setItem("scrapyard-sidebar-theme", e.target.value); + send.sidebarThemeChanged({theme: e.target.value}); + }); + + let listHeightInputTimeout; + $("#option-shelf-list-max-height").on("input", e => { + clearTimeout(listHeightInputTimeout); + listHeightInputTimeout = setTimeout(async () => { + await settings.load(); + await settings.shelf_list_height(+e.target.value); + send.reloadSidebar({height: +e.target.value}); + }, 1000) + }); + + let filesEditorInputTimeout; + $("#option-editor-executable").on("input", e => { + clearTimeout(filesEditorInputTimeout); + filesEditorInputTimeout = setTimeout(async () => { + await settings.load(); + await settings.files_editor_executable(e.target.value); + }, 1000) + }); + + $("#option-number-of-bookmarks-toolbar-references").on("input", async e => { + await settings.load(); + let value = parseInt(e.target.value) || 0; + + await settings.number_of_bookmarks_toolbar_references(value); + }); + + $("#option-helper-port").on("input", async e => { + await settings.load(); + settings.helper_port_number(+e.target.value); + }); + + $("#transfer-content").on("click", async e => { + e.preventDefault(); + + const success = await send.transferContentToDisk(); + + if (success) + $("#transfer-content-container").hide(); + }); + + if (settings.transition_to_disk()) { + $("#transfer-content-container").show(); + $("#synchronize-at-startup-wrapper").hide(); + $("#storage-mode-wrapper").hide(); + } + + setSaveCheckHandler("option-synchronize-at-startup", "synchronize_storage_at_startup", + () => send.shelvesChanged()); + setSaveCheckHandler("option-capitalize-builtin-shelf-names", "capitalize_builtin_shelf_names", + () => send.shelvesChanged()); + setSaveCheckHandler("option-show-firefox-bookmarks", "show_firefox_bookmarks", + () => send.reconcileBrowserBookmarkDb()); + setSaveCheckHandler("option-show-firefox-bookmarks-toolbar", "show_firefox_toolbar", + () => send.externalNodesReady()); + setSaveCheckHandler("option-enable-files-shelf", "enable_files_shelf", + e => enableFilesShelf(e)); + setSaveCheckHandler("option-visually-emphasise-archives", "visually_emphasise_archives", + () => send.shelvesChanged()); + setSaveCheckHandler("option-archives-icon", "visual_archive_icon", + () => send.shelvesChanged()); + setSaveCheckHandler("option-archives-color", "visual_archive_color", + () => send.shelvesChanged()); + setSaveCheckHandler("option-display-random-bookmark", "display_random_bookmark", + e => send.displayRandomBookmark({display: e.target.checked})); + setSaveCheckHandler("option-sort-shelves-in-popup", "sort_shelves_in_popup"); + setSaveCheckHandler("option-show-firefox-bookmarks-mobile", "show_firefox_mobile"); + setSaveCheckHandler("option-do-not-show-archive-toolbar", "do_not_show_archive_toolbar"); + setSaveCheckHandler("option-switch-to-bookmark", "switch_to_new_bookmark"); + setSaveCheckHandler("option-open-bookmark-in-active-tab", "open_bookmark_in_active_tab"); + setSaveCheckHandler("option-open-sidebar-from-shortcut", "open_sidebar_from_shortcut"); + setSaveCheckHandler("option-do-not-switch-to-ff-bookmark", "do_not_switch_to_ff_bookmark"); + setSaveCheckHandler("option-add-to-bookmarks-toolbar", "add_to_bookmarks_toolbar"); + setSaveCheckHandler("option-undo-failed-imports", "undo_failed_imports"); + setSaveCheckHandler("option-sidebar-filter-partial-match", "sidebar_filter_partial_match"); + setSaveCheckHandler("option-remember-last-filtering-mode", "remember_last_filtering_mode"); +} + +function loadScrapyardSettings() { + $("#option-data-folder-path").val(settings.data_folder_path() || ""); + $("#option-storage-mode").val(settings.storage_mode_internal()? "internal": "filesystem"); + $("#option-synchronize-at-startup").prop("checked", settings.synchronize_storage_at_startup()); + $("#option-sidebar-theme").val(localStorage.getItem("scrapyard-sidebar-theme") || "light"); + $("#option-shelf-list-max-height").val(settings.shelf_list_height()); + $("#option-show-firefox-bookmarks").prop("checked", settings.show_firefox_bookmarks()); + $("#option-show-firefox-bookmarks-toolbar").prop("checked", settings.show_firefox_toolbar()); + $("#option-enable-files-shelf").prop("checked", settings.enable_files_shelf()); + $("#option-editor-executable").val(settings.files_editor_executable()); + $("#option-visually-emphasise-archives").prop("checked", settings.visually_emphasise_archives()); + $("#option-archives-icon").prop("checked", settings.visual_archive_icon()); + $("#option-archives-color").prop("checked", settings.visual_archive_color()); + $("#option-sort-shelves-in-popup").prop("checked", settings.sort_shelves_in_popup()); + $("#option-show-firefox-bookmarks-mobile").prop("checked", settings.show_firefox_mobile()); + $("#option-switch-to-bookmark").prop("checked", settings.switch_to_new_bookmark()); + $("#option-do-not-show-archive-toolbar").prop("checked", settings.do_not_show_archive_toolbar()); + $("#option-do-not-switch-to-ff-bookmark").prop("checked", settings.do_not_switch_to_ff_bookmark()); + $("#option-add-to-bookmarks-toolbar").prop("checked", settings.add_to_bookmarks_toolbar()); + $("#option-number-of-bookmarks-toolbar-references").val(settings.number_of_bookmarks_toolbar_references() || ""); + $("#option-display-random-bookmark").prop("checked", settings.display_random_bookmark()); + $("#option-open-bookmark-in-active-tab").prop("checked", settings.open_bookmark_in_active_tab()); + $("#option-open-sidebar-from-shortcut").prop("checked", settings.open_sidebar_from_shortcut()); + $("#option-capitalize-builtin-shelf-names").prop("checked", settings.capitalize_builtin_shelf_names()); + $("#option-sidebar-filter-partial-match").prop("checked", settings.sidebar_filter_partial_match()); + $("#option-remember-last-filtering-mode").prop("checked", settings.remember_last_filtering_mode()); + $("#option-undo-failed-imports").prop("checked", settings.undo_failed_imports()); + $("#option-helper-port").val(settings.helper_port_number()); + + selectricRefresh($("#option-sidebar-theme")); + selectricRefresh($("#option-storage-mode")); +} + +export function load() { + configureScrapyardSettingsPage(); + loadScrapyardSettings(); +} + +async function setStorageModeToFilesystem() { + if (await confirm("Warning", "This will reset the Scrapyard browser internal storage. " + + "Make sure that you have exported important content. Continue?")) { + $("#option-data-folder-path").prop("disabled", false); + $("#option-synchronize-at-startup").prop("disabled", false); + + await settings.storage_mode_internal(false); + + await send.resetScrapyard(); + browser.runtime.reload(); + } + else { + const storageModeSelect = $("#option-storage-mode").val("internal"); + selectricRefresh(storageModeSelect); + } +} + +async function setStorageModeToInternal() { + if (await confirm("Warning", "This will reset the Scrapyard browser internal storage. Continue?")) { + $("#option-data-folder-path") + .val("") + .prop("disabled", true); + $("#option-synchronize-at-startup") + .prop("checked", false) + .prop("disabled", true) + + settings.save_unpacked_archives(false, false); + settings.data_folder_path("", false); + settings.synchronize_storage_at_startup(false, false); + await settings.storage_mode_internal(true); + + await send.storageModeInternal(); + await send.resetScrapyard(); + browser.runtime.reload(); + } + else { + const storageModeSelect = $("#option-storage-mode").val("filesystem"); + selectricRefresh(storageModeSelect); + } +} + +async function enableFilesShelf(e) { + const helper = await helperApp.hasVersion("2.1", HELPER_APP_v2_1_IS_REQUIRED); + + if (helper) + return filesShelf.enable(e.target.checked); + else + e.target.checked = false; +} diff --git a/addon/ui/options/options_transition.html b/addon/ui/options/options_transition.html new file mode 100644 index 00000000..bd1e5e41 --- /dev/null +++ b/addon/ui/options/options_transition.html @@ -0,0 +1,42 @@ +

Transferring Scrapyard Content to the Filesystem

+ + +
+

Since version 2, Scrapyard is able to store the archived content in the filesystem. This requires +the installation of the Scrapyard native backend application. If you are not ready +for this change, it is possible to disable the automatic transition by clicking on +this link. This will leave the archived content +in the browser internal storage.

+ +

The instructions below describe the process of transferring the archived content +from the browser internal storage to the filesystem.

+ +
    +
  1. Before performing the transition, you might want to back up your browser profile. + In Firefox, you can find your browser profile path at the about:support settings page. + In Chrome, the current profile path could be found at the following URL: chrome://version +
  2. + +
  3. Download and install the Scrapyard native backend application version 2.0+. + It is necessary to close the browser before the installation.
  4. + +
  5. Open the Settings options tab and specify the directory where + you want to keep your archived content.
  6. + +
  7. Click on the "Transfer content" link.
  8. + +
  9. Make sure that the archived content is transferred successfully.
  10. + +
  11. Open the Advanced options tab and click on the + "Reset internal storage" link.
  12. + +
  13. After the reset is finished, restart your browser.
  14. +
+ +
+ + diff --git a/addon/ui/options/options_transition.js b/addon/ui/options/options_transition.js new file mode 100644 index 00000000..c6feaa94 --- /dev/null +++ b/addon/ui/options/options_transition.js @@ -0,0 +1,14 @@ +import {confirm} from "../dialog.js"; +import {settings} from "../../settings.js"; + +export function load() { + $("#disable-transition").on("click", async e => { + e.preventDefault(); + + if (await confirm("Warning", "This will restart Scrapyard. Continue?")) { + settings.storage_mode_internal(true, false); + await settings.transition_to_disk(false); + browser.runtime.reload(); + } + }); +} diff --git a/addon/ui/popup.css b/addon/ui/popup.css new file mode 100644 index 00000000..57d2e6a6 --- /dev/null +++ b/addon/ui/popup.css @@ -0,0 +1,190 @@ +/* CSS Document */ + +body { + font-family: "Helvetica", "Arial", 'sans-serif'; + width: 330px; +} + +input { + border-radius: 3px; + appearance: none; + border: 1px solid #777; +} + +input:focus { + outline: none; + border: 1px solid #007799; +} + +#content { + +} + +#heading { + display: table; +} + +#logo { + height: 32px; + width: 32px; +} + +#title { + line-height: 32px; + display: table-cell; + vertical-align: middle; + padding-left: 8px; + font-weight: bold; + font-size: 22px; +} + +#sidebar-toggle { + line-height: 32px; + display: table-cell; + vertical-align: middle; + padding-left: 14px; + font-weight: bold; + font-size: 22px; + cursor: pointer; +} + +#bookmark-folder-container { + min-height: 20px; +} + +#bookmark-folder { + display: none; +} + +#properties label { + width: 3em; + display: inline-block; +} + +.property-row { + display: flex; + margin-top: 4px; + margin-bottom: 4px; +} + +.property-row label { + flex: 0 1 auto; + margin-right: 8px; + font-size: 90%; + vertical-align: middle; +} + +.property-row input, .property-row select, .selectric-wrapper { + flex: 1 1 auto; +} + +.tree-container { + width: 100%; + height: 200px; + margin-top: 8px; + margin-bottom: 8px; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid #777; + border-radius: 3px; +} + +#treeview { + width: 100%; +} + +#new-folder, #new-shelf { + margin-left: 4px; + flex: 0 1 auto; + min-width: 22px; + background-repeat: no-repeat; + background-position: center; + background-size: 16px 16px; +} + +#new-folder { + background-image: url("/icons/new-group.svg"); +} + +#new-shelf { + background-image: url("/icons/new-shelf.svg"); +} + +#buttons { + margin-top: 8px; + display: flex; + width: 100%; +} + +#buttons .button-container { + height: 50px; + flex: 1 1 auto; + width: 50%; +} + +.bookmark-button { + appearance: none; + border: none; + color: #fff; + cursor: pointer; + font-size: 12pt; + width: 100%; + height: 100%; +} + +#create-bookmark { + background: #008ea4; +} + +div.create-bookmark { + margin-right: 2px; +} + +#create-bookmark:hover { + background: #00AAC5; +} + +#create-archive { + background: #FF9D33; +} + +div.create-archive { + margin-left: 2px; + position: relative; +} + +#create-archive:hover { + background: #FFAD57; +} + +img#crawler-check { + position: absolute; + right: 5px; + top: 13px; + cursor: pointer; +} + +.selectric { + border-radius: 3px; + border: 1px solid #777; +} + +.selectric .label, +.selectric-items li.folder-label { + padding-left: 22px; + position: relative; +} + +.selectric .label::before, +.selectric-items li.folder-label::before { + content: " "; + position: absolute; + left: 2px; + height: 16px; + width: 16px; + background-image: url("../icons/group.svg") +} + +.selectric .label { + margin-left: 2px; +} diff --git a/addon/ui/popup.html b/addon/ui/popup.html new file mode 100644 index 00000000..26557071 --- /dev/null +++ b/addon/ui/popup.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + Bookmark to Scrapyard + +
+ +
+
+ + +
+ + +
+ + +
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ + +
+
+
+ + + diff --git a/addon/ui/popup.js b/addon/ui/popup.js new file mode 100644 index 00000000..37157e8e --- /dev/null +++ b/addon/ui/popup.js @@ -0,0 +1,195 @@ +import { + byName, + DEFAULT_SHELF_NAME, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, + NODE_TYPE_SHELF, + NODE_TYPE_FOLDER, + BROWSER_EXTERNAL_TYPE +} from "../storage.js"; +import {askCSRPermission, getActiveTab, showNotification} from "../utils_browser.js"; +import {selectricRefresh, simpleSelectric} from "./shelf_list.js"; +import {systemInitialization} from "../bookmarks_init.js"; +import {getFaviconFromTab} from "../favicon.js"; +import {Query} from "../storage_query.js"; +import {BookmarkTree} from "./tree.js"; +import {send} from "../proxy.js"; +import {toggleSidebarWindow} from "../utils_sidebar.js"; +import {isSpecialPage} from "../bookmarking.js"; +import {settings} from "../settings.js"; + +let tree; +let bookmarkFolderSelect; +let folderHistory; +let crawlerMode; + +$(init); + +async function init() { + await systemInitialization; + + let nodes = await Query.allFolders(); + + if (settings.sort_shelves_in_popup()) + nodes = sortShelves(nodes); + + tree = new BookmarkTree("#treeview", true); + bookmarkFolderSelect = simpleSelectric("#bookmark-folder"); + folderHistory = loadFolderHistory(nodes); + + $("#bookmark-tags").focus(); + initBookmarkFolderSelect(bookmarkFolderSelect, folderHistory); + tree.update(nodes); + + await saveActiveTabProperties(); + + $("#new-shelf").on("click", () => createNewFolder(NODE_TYPE_SHELF)); + $("#new-folder").on("click", () => createNewFolder(NODE_TYPE_FOLDER)); + $("#crawler-check").on("click", switchCrawlerMode); + $("#treeview").on("select_node.jstree", onTreeFolderSelected); + $("#sidebar-toggle").on("click", toggleSidebar); + $("#create-bookmark").on("click", async () => await addBookmark(NODE_TYPE_BOOKMARK)); + $("#create-archive").on("click", async e => await addBookmark(NODE_TYPE_ARCHIVE)); + bookmarkFolderSelect.on("change", () => tree.selectNode(bookmarkFolderSelect.val(), false, true)); +} + +function sortShelves(nodes) { + const shelves = nodes.filter(n => n.type === NODE_TYPE_SHELF); + const otherNodes = nodes.filter(n => n.type !== NODE_TYPE_SHELF); + + shelves.sort((a, b) => a.id - b.id); + + const builtinShelves = shelves.filter(n => n.id < 2); + const userShelves = shelves.filter(n => n.id > 1); + + userShelves.sort(byName); + + return [...builtinShelves, ...userShelves, ...otherNodes]; +} + +function loadFolderHistory(nodes) { + let folderHistory = localStorage.getItem("popup-folder-history"); + + if (folderHistory && folderHistory !== "null") + folderHistory = JSON.parse(folderHistory).filter(h => nodes.some(n => n.id == h.id)); + else if (!folderHistory || folderHistory === "null") + folderHistory = []; + + return folderHistory; +} + +function saveFolderHistory(nodeId, text, history) { + if (nodeId) { + let folderHistory = history.slice(0); + let existing = folderHistory.find(h => h.id == nodeId); + + if (existing) + folderHistory.splice(folderHistory.indexOf(existing), 1); + + folderHistory = [{id: nodeId, text: text}, ...folderHistory].slice(0, 10); + localStorage.setItem("popup-folder-history", JSON.stringify(folderHistory)); + } +} + +function initBookmarkFolderSelect(bookmarkFolderSelect, folderHistory) { + bookmarkFolderSelect.empty(); + + if (folderHistory?.length) + for (let item of folderHistory) + bookmarkFolderSelect.append(``); + else + bookmarkFolderSelect.append(``); + + selectricRefresh(bookmarkFolderSelect); +} + +function onTreeFolderSelected(e, {node: jnode}) { + let existing = $(`#bookmark-folder option[value='${jnode.id}']`); + + if (!existing.length) { + $("#bookmark-folder option[data-tentative='true']").remove(); + bookmarkFolderSelect.prepend(``) + selectricRefresh(bookmarkFolderSelect) + } + + bookmarkFolderSelect.val(jnode.id); + selectricRefresh(bookmarkFolderSelect) +} + +async function createNewFolder(type) { + const folder = await tree.createNewFolderUnderSelection("$new_node$", type); + + if (folder) { + let newOption = $(`#bookmark-folder option[value='$new_node$']`); + newOption.text(folder.name); + newOption.val(folder.id); + bookmarkFolderSelect.val(folder.id); + selectricRefresh(bookmarkFolderSelect) + } +} + +function switchCrawlerMode(e) { + if (e.target.src.endsWith("mode-page.svg")) { + e.target.src = "../icons/mode-site.svg"; + $("#create-archive").val("Archive Site"); + crawlerMode = true; + } + else { + e.target.src = "../icons/mode-page.svg" + $("#create-archive").val("Archive"); + crawlerMode = false; + } +} + +async function saveActiveTabProperties() { + const activeTab = await getActiveTab(); + const highlightedTabs = await browser.tabs.query({highlighted: true, currentWindow: true}); + + if (activeTab && highlightedTabs.length === 1) { + $("#bookmark-name").val(activeTab.title); + $("#bookmark-url").val(activeTab.url); + + let favicon; + if (!isSpecialPage(activeTab.url)) + favicon = await getFaviconFromTab(activeTab); + + if (favicon) + $("#bookmark-icon").val(favicon); + } +} + +async function addBookmark(nodeType) { + const parentNodeId = $("#bookmark-folder").val(); + let parentNode = tree.adjustBookmarkingTarget(parentNodeId); + saveFolderHistory(parentNode.id + "", parentNode.name, folderHistory); + + const payload = { + type: nodeType, + name: $("#bookmark-name").val(), + uri: $("#bookmark-url").val(), + tags: $("#bookmark-tags").val(), + icon: $("#bookmark-icon").val(), + parent_id: parentNode.id, + __crawl: crawlerMode + }; + + let canProceed = true; + if (nodeType === NODE_TYPE_ARCHIVE) + if (!await askCSRPermission()) + canProceed = false; + + if (canProceed) + await send.captureHighlightedTabs({options: payload}); + + window.close(); +} + +function toggleSidebar() { + if (browser.sidebarAction) + browser.sidebarAction.toggle(); + else + toggleSidebarWindow(); +} + +console.log("==> popup.js loaded"); diff --git a/addon/ui/shelf_list.css b/addon/ui/shelf_list.css new file mode 100644 index 00000000..a29b89ae --- /dev/null +++ b/addon/ui/shelf_list.css @@ -0,0 +1,47 @@ +/* 777: .selectric | .selectric, .selectric-focus .selectric { | .selectric:hover { */ + +.selectric { + border: 1px solid black; + border-radius: 3px; + appearance: none; +} + +.selectric-wrapper { + min-width: unset; +} + +.selectric .button { + font: unset; +} + +.selectric .button img { + margin-left: 4px; + margin-top: 7px; + display: block; + width: 9px; + height: 5px; +} + +.selectric-scroll li.highlighted, .selectric-scroll li.selected { + background: transparent; +} + +.selectric-scroll li.highlighted:hover, .selectric-scroll li.selected:hover { + background: #ddd; +} + +.selectric, .selectric-focus .selectric { + border-color: black; +} + +.selectric:hover { + border-color: black; +} + +.option-builtin { + font-weight: bold; +} + +.divide { + border-bottom: 1px solid black; +} diff --git a/addon/ui/shelf_list.js b/addon/ui/shelf_list.js new file mode 100644 index 00000000..7d960bee --- /dev/null +++ b/addon/ui/shelf_list.js @@ -0,0 +1,237 @@ +import {settings} from "../settings.js"; +import { + isBuiltInShelf, + CLOUD_SHELF_ID, + CLOUD_SHELF_NAME, + CLOUD_SHELF_UUID, + DEFAULT_SHELF_ID, + DEFAULT_SHELF_NAME, + DONE_SHELF_ID, + DONE_SHELF_NAME, + EVERYTHING_SHELF_UUID, + EVERYTHING_SHELF_ID, + BROWSER_SHELF_ID, + BROWSER_SHELF_NAME, + BROWSER_SHELF_UUID, + TODO_SHELF_ID, + TODO_SHELF_NAME, DEFAULT_SHELF_UUID, EVERYTHING_SHELF_NAME, FILES_SHELF_UUID, FILES_SHELF_NAME, FILES_SHELF_ID +} from "../storage.js"; +import {formatShelfName} from "../bookmarking.js"; +import {Query} from "../storage_query.js"; + +const DEFAULT_SHELF_LIST_WIDTH = 91; + +function createSelectricMeter() { + let meter = $("#selectric-meter"); + if (!meter.length) + meter = $(``).appendTo(document.body); + return meter; +} + +function measureSelectricWidth(options) { + const meter = createSelectricMeter(); + + let longestText = ""; + options.each(function() { + const optionText = this.textContent; + if (optionText.length > longestText.length) + longestText = optionText; + }); + + meter.text(longestText); + + if (longestText.toLowerCase() === EVERYTHING_SHELF_NAME) + meter.addClass("option-builtin"); + else + meter.removeClass("option-builtin"); + + return meter.width() + 33; // + selectric margins & 5 pixels +} + +export class ShelfList { + constructor(select, options) { + this._options = options || {}; + this._options.inheritOriginalWidth = !options._prefix; + this._options.arrowButtonMarkup = + ``; + + $(`${select}-placeholder`).remove(); + this._select = $(select).selectric(this._options); + this._element = this._select.closest(".selectric-wrapper"); + + const shelfListWidth = localStorage.getItem(`${options._prefix}-shelf-list-width`) || DEFAULT_SHELF_LIST_WIDTH; + this._element.css("width", shelfListWidth) + } + + _refresh() { + this._select.selectric('refresh'); + const width = measureSelectricWidth($("option", this._select)); + this._element.css("width", width); + + if (this._options._prefix) + localStorage.setItem(`${this._options._prefix}-shelf-list-width`, width) + } + + _styleBuiltinShelf() { + let {name} = this.getCurrentShelf(); + + const label = $("span.label", this._element); + + if (isBuiltInShelf(name)) + label.addClass("option-builtin"); + else + label.removeClass("option-builtin"); + } + + static getStoredWidth(prefix) { + return localStorage.getItem(`${prefix}-shelf-list-width`) + } + + show() { + this._element.show(); + } + + getCurrentShelf() { + let selectedOption = $(`option[value='${this._select.val()}']`, this._select); + return { + id: parseInt(selectedOption.val()), + name: selectedOption.text(), + uuid: selectedOption.attr("data-uuid"), + external: selectedOption.attr("data-external"), + option: selectedOption + }; + } + + async reload() { + let html = ` + + + ` + + if (settings.enable_files_shelf()) + html += ``; + + if (settings.cloud_enabled()) + html += ``; + + if (settings.show_firefox_bookmarks()) + html += ``; + + let shelves = await Query.allShelves(); + + let orgShelfNode = shelves.find(s => s.id === FILES_SHELF_ID); + if (orgShelfNode) + shelves.splice(shelves.indexOf(orgShelfNode), 1); + + let cloudShelfNode = shelves.find(s => s.id === CLOUD_SHELF_ID); + if (cloudShelfNode) + shelves.splice(shelves.indexOf(cloudShelfNode), 1); + + let browserShelfNode = shelves.find(s => s.id === BROWSER_SHELF_ID); + if (browserShelfNode) + shelves.splice(shelves.indexOf(browserShelfNode), 1); + + let defaultShelf = shelves.find(s => s.name.toLowerCase() === DEFAULT_SHELF_NAME); + shelves.splice(shelves.indexOf(defaultShelf), 1); + defaultShelf.name = formatShelfName(defaultShelf.name); + html += ``; + + shelves.sort((a, b) => a.name.localeCompare(b.name)); + + for (let shelf of shelves) + html += ``; + + this._select.html(html); + } + + load() { + return this.reload(); + } + + async initDefault() { + const label = $("span.label", this._element); + label.addClass("option-builtin"); + await this.load(); + this.selectShelf(EVERYTHING_SHELF_ID); + this.change(() => null); + } + + hasShelf(name) { + name = name.toLocaleLowerCase(); + let existingOption = $(`option`, this._select).filter(function(i, e) { + return e.textContent.toLocaleLowerCase() === name; + }); + + return !!existingOption.length; + } + + selectShelf(id) { + const option = $(`option[value="${id}"]`, this._select); + id = option.length? id: DEFAULT_SHELF_ID; + + this._select.val(id); + this._styleBuiltinShelf(); + this._refresh(); + } + + get selectedShelfId() { + return parseInt(this._select.val()); + } + + get selectedShelfName() { + let selectedOption = $(`option[value='${this._select.val()}']`, this._select); + return selectedOption.text(); + } + + get selectedShelfExternal() { + let selectedOption = $(`option[value='${this._select.val()}']`, this._select); + return selectedOption.attr("data-external"); + } + + change(handler) { + const wrapper = (...args) => { + this._styleBuiltinShelf(); + handler.apply(this._select[0], args); + } + this._select.change(wrapper); + } + + removeShelves(ids) { + if (!Array.isArray(ids)) + ids = [ids]; + + for (const id of ids) + $(`option[value="${id}"]`, this._select).remove(); + + this._refresh(); + } + + renameShelf(id, name) { + $(`option[value="${id}"]`, this._select).text(name); + this._refresh(); + } +} + +ShelfList.DEFAULT_WIDTH = DEFAULT_SHELF_LIST_WIDTH; + +export function simpleSelectric(element) { + return $(element).selectric({ + inheritOriginalWidth: false, + arrowButtonMarkup: + `` + }); +} + +export function selectricRefresh(element) { + element.selectric("refresh"); + let wrapper = element.closest(".selectric-wrapper"); + let width = measureSelectricWidth($("option", element)); + wrapper.css("width", width); +} diff --git a/addon/ui/sidebar.css b/addon/ui/sidebar.css new file mode 100644 index 00000000..be83aa1c --- /dev/null +++ b/addon/ui/sidebar.css @@ -0,0 +1,635 @@ +:root { + --theme-background: white; + + --themed-comments-icon: url("../icons/comments.svg"); + --themed-comments-filled-icon: url("../icons/comments-filled.svg"); + --themed-properties-icon: url("../icons/properties.svg"); + --themed-containers-icon: url("../icons/containers.svg"); + + box-sizing: border-box; +} + +*, *::before, *::after, *::marker { + box-sizing: inherit; +} + +* { + font-family: "Helvetica", + "Arial", + "Lucida Sans Unicode", + "Lucida Sans", + "Trebuchet MS", + "Open Sans", + "Fira Sans", + "Liberation Sans", + sans-serif; +} + +html, body { + margin: 0; + min-height: 100vh; + overflow-x: hidden; + width: 100%; + height: 100%; +} + + +input:focus { + outline: none !important; +} + +#main-container { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0 +} + +#flex-container { + /*margin: 10px;*/ + flex-grow: 1; + + display: flex; + flex-direction: column; + + /* for Firefox */ + min-height: 0; + +} + +#treeview-container { + flex-grow: 1; + overflow: auto; + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + /* for Firefox */ + min-height: 0; +} + +#treeview { + overflow: hidden; +} + +.box { + margin:70px 20px 20px 20px; +} + +.main-tools { + width:fit-content; + display:inline-block; +} + +.toolbar { + /*margin-bottom:5px;*/ + position:relative; + background:#fff; + width:100%; + /*height:50px;*/ + padding:8px; + border-bottom:1px solid #999; + white-space:nowrap; + z-index:1; /* prevent dots of "text-overflow:ellipsis" diplayed over me */ +} + +#btnAnnouncement, #btnHelperWarning, #btnBatchWarning { + display: none; +} + +.folder-content { + display:none; + /*margin-left:18px;*/ + /* border:1px solid; */ +} + +.folder-content.root { + display:block; + /*margin-left:0px; + margin:70px 20px 20px 20px;*/ + overflow:visible; +} + +.folder-content.root > .item { + display:block; + margin-left:0px; +} + +.dlg-row { + padding:12px 0 6px 0; + white-space:nowrap; + display:table-row; +} + +.dlg-row input { + vertical-align:middle; + border:none; +} + +.dlg-title { + font-weight:bold; + border-bottom:1px solid #999; + padding:0 10px 5px 10px; +} + +.dlg-dim { + position:fixed; + z-index:999999999; + width:100%; + height:100%; + background:#fff5; + display: flex; + justify-content: center; + align-items: center; +} + +.dlg { + /*top:20%;*/ + margin: 30px; + position:absolute; + background:#ccc; + /*left:10%;*/ + width:80%; + padding:15px 20px 20px 20px; + border-radius:5px; + border:1px solid #777; + overflow:hidden; + /* -webkit-box-sizing: border-box; */ + /* -moz-box-sizing: border-box; */ + /* box-sizing: border-box; */ +} + +.dlg * { + color:#000; +} + +.dlg input, .dlg select, .dlg textarea { + border: 1px solid #777; + border-radius: 3px; + appearance: none; +} + +.dlg input.dialog-input, .dlg textarea { + outline: none; +} + +.dlg input.dialog-input:focus, .dlg textarea:focus { + outline: none; + border: 1px solid #007799; +} + +.dlg-table { + display:table; + overflow:hidden; + width:100%; + position:relative; +} + +.dlg-table .row, .dlg > .row { + display:table-row; + padding:12px 5px 6px 5px; + white-space:nowrap; + overflow:hidden; +} + +.dlg > .row { + display: block; + padding: 14px 5px 4px 5px; +} + +.dlg-table .row .cell { + display:table-cell; + line-height:20px; + vertical-align:middle; + padding:15px 5px 0 5px; + vertical-align:middle; + /* border:1px solid; */ + /* -webkit-box-sizing: border-box; */ + /* -moz-box-sizing: border-box; */ + /* box-sizing: border-box; */ + overflow:hidden; +} + +.dlg .dlg-box { + width:100%; + overflow:hidden; +} + +.drag-mark { + /* background:#f00; */ + border:none; + /* height:2px; */ + border-bottom:1px dashed #f00; + /* margin:0 0 2px 0; */ +} + +.item.folder.drag-into { + border-bottom:1px dashed #f00; +} + +.table { + display:table; +} + +.table-row { + display:table-row; +} + +.table-cell { + display:table-cell; +} + +.tool-button { + cursor:pointer; + vertical-align:middle; + border-radius:11px; + width:22px; + height:22px; +} +.tool-button:hover { + background:#0003; + color:#fff; +} + +.blue-button, .black-button { + background:#09c; + color:#fff; + cursor:pointer; + border-radius:5px; + padding:2px 5px; + border:0; +} + +.black-button { + background:transparent; + color:#000; +} +.item.separator { + width:100%; + height:15px; + border-width:7px 0 7px 0; + border-style:solid; + background:#999; +} + +.item.separator.focus { + background:#900; +} + +select { + color: #000; +} + +/* hex to filter + https://codepen.io/sosuke/pen/Pjoqqp */ +.filter-green-0 { + filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); +} + +#shelfList { + display: none; +} + +.simple-menu { + min-width:50px; + position:absolute; + background:#fff; + right: 10px; + margin-top: 5px; + border: solid 1px black; + z-index: 10000; +} + +.simple-menu div { + color:#000; + padding:5px 15px; + cursor:pointer; +} + +.simple-menu div:hover { + background: #ddd; +} + +#shelves-icon { + width: 16px; + height: 16px; + margin-right: 4px; + display: none; +} + +#shelf-menu-button { + width: 14px; + height: 14px; + margin-left: 8px; + margin-right: 4px; + margin-top: 2px; + cursor: pointer; + background: transparent !important; +} + +#shelf-menu-abort { + display: none; +} + +#shelf-menu-create-folder { + margin-top: 5px; + border-top: 1px solid black; +} + +.dlg-prop-table { + display: flex; + flex-direction: column; + position: relative; +} + +.dlg-prop-table label { + text-align: right; + clear: both; + float:left; + margin-right:15px; +} + +.prop-row { + padding: 5px; + flex: 0 1 auto; +} + +#prop-dlg-comments-icon, #prop-dlg-containers-icon { + height: 16px; + width: 16px; + float: right; + cursor: pointer; + background-image: var(--themed-comments-icon); + background-size: contain; + background-repeat: no-repeat; +} + + +#prop-dlg-containers-icon { + background-image: var(--themed-containers-icon); + background-size: 15px 15px; + background-position: center; + margin-right: 4px; + margin-left: 2px; + height: 16px; + width: 16px; +} + +#dlg-comments-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + padding: 4px; + display: none; +} + +#prop-comments { + height: 100%; + width: 100%; + font-family: "Arial", sans-serif; + font-size: 11pt; +} + +.button-row { + padding: 5px 5px 0; +} + +.prop-row-hidden { + display: none !important; +} + + +#main-controls { + width: 100%; + display: flex; + align-content: center; + align-items: center; + flex-wrap: nowrap; +} + +#main-buttons { + flex: 1 1 auto; +} + +#loading-placeholder { + flex: 0 1 auto; + margin-left: 5px; +} + +#shelf-selector-container { + flex: 0 1 auto; + display: flex; + align-content: center; + align-items: center; + flex-wrap: nowrap; +} + +#search-controls { + width: 100%; + display: flex; + margin-top: 6px; +} + +#search-mode-menu { + margin-top: 20px; + right: 12px; +} + +.search-icon { + width: 16px; + height: 16px; + margin-right: 4px; + margin-left: 4px; + margin-top: 4px; + float: left; +} + +#search-mode-switch { + flex: 0 1 auto; + width: 16px; + height: 16px; + margin-right: 4px; + cursor: pointer; +} + +#search-input-container { + flex: 1 1 auto; + padding-right: 6px; + padding-left: 2px; + position: relative; +} + +#search-input-container::before { + position: absolute; + transform: translate(0,-50%); + left: 6px; + top: 50%; + font-family: "FontAwesome"; + content: "\f002"; +} + +#search-input-clear { + position: absolute; + transform: translate(0,-50%); + right: 10px; + top: 50%; + font-family: "FontAwesome"; + font-style: normal; + font-weight: normal; + cursor: pointer; + display: none; +} + +#search-input { + border-radius: 3px; + height: 18px; + width: 100%; + font-size: 12px; + border: 1px solid black; + padding-left: 16px; +} + +#busy-indicator { + width: 16px; + height: 16px; + margin-top: 2px; + margin-right: 4px; +} + +.more-properties { + display: none; +} + +a#more-properties { + margin-top: 10px; + display: inline-block; +} + +a#set-default-icon, a#copy-reference-url { + text-decoration: none; +} + +#footer { + border-top: 1px solid #999; + height: 50px; + grid-template-columns: auto auto; + grid-template-rows: 24px auto; + grid-auto-rows: min-content; + flex-shrink: 0; + display: none; +} + +#footer-title { + grid-column: 1 / 1; + grid-row: 1 / 1; + padding-top: 4px; + padding-left: 4px; + justify-self: start; + font-weight: bold; +} + +#footer-buttons { + grid-column: 2 / 2; + grid-row: 1 / 1; + justify-self: end; +} + +#footer-close-btn, #footer-reload-btn, #footer-find-btn { + font-family: "FontAwesome"; + font-style: normal; + font-weight: normal; + cursor: pointer; + padding: 2px 4px; +} + +#footer-close-btn { + padding-top: 3px; +} + +#footer-reload-btn, #footer-find-btn { + font-size: 85%; + padding-right: 0; +} + +#footer-content { + grid-column: 1 / span 2; + grid-row: 2 / 2; + align-self: start; + padding-top: 2px; + padding-left: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#random-bookmark-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + background-repeat: no-repeat; + background-size: contain; + float: left; +} + +#random-bookmark-link { + padding: 0; + margin: 0; + line-height: 16px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#containers-menu { + display: none; + right: 30px; + top: 34px; + font-weight: normal; +} + +#containers-menu div { + margin: 0; + padding: 2px 5px; + line-height: 16px; +} + +#containers-menu div:first-child { + padding-top: 4px; +} + +#containers-menu div:last-child { + padding-bottom: 4px; +} + +.container-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 5px; + margin-bottom: -2px; +} + +#sidebar-progress { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background-color: #757575 !important; + width: 0; +} + +#prop-title-icon-image { + height: 16px; + width: 16px; + margin-right: 2px; + background-repeat: no-repeat; + background-size: contain; + cursor: pointer; +} diff --git a/addon/ui/sidebar.html b/addon/ui/sidebar.html new file mode 100644 index 00000000..35e58b69 --- /dev/null +++ b/addon/ui/sidebar.html @@ -0,0 +1,294 @@ + + + + Scrapyard + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + + + + + +
+
+ + + + + +
+
+ + +
+
+ +
+
+ + +
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + +
+ + diff --git a/addon/ui/sidebar.js b/addon/ui/sidebar.js new file mode 100644 index 00000000..1efc60dc --- /dev/null +++ b/addon/ui/sidebar.js @@ -0,0 +1,1029 @@ +import {receive, receiveExternal, send, sendLocal} from "../proxy.js"; +import {settings} from "../settings.js" +import {ishellConnector} from "../plugin_ishell.js" +import {BookmarkTree} from "./tree.js" +import {confirm, showDlg} from "./dialog.js" +import { + SEARCH_MODE_COMMENTS, + SEARCH_MODE_CONTENT, + SEARCH_MODE_DATE, + SEARCH_MODE_FOLDER, + SEARCH_MODE_NOTES, + SEARCH_MODE_TAGS, + SEARCH_MODE_TITLE, SEARCH_MODE_UNIVERSAL, + SearchContext +} from "../search.js"; +import {pathToNameExt} from "../utils.js"; +import { + byPosition, + CLOUD_SHELF_ID, + DEFAULT_SHELF_ID, + DEFAULT_SHELF_NAME, + DONE_SHELF_ID, + DONE_SHELF_NAME, + EVERYTHING_SHELF_ID, + EVERYTHING_SHELF_NAME, + BROWSER_SHELF_ID, + NODE_TYPE_ARCHIVE, + NODE_TYPE_NOTES, + NODE_TYPE_SHELF, + TODO_SHELF_ID, + TODO_SHELF_NAME, + RDF_EXTERNAL_TYPE, + CLOUD_EXTERNAL_TYPE, + FILES_SHELF_ID, + isBuiltInShelf, + isVirtualShelf, + isContentNode +} from "../storage.js"; +import {openPage, showNotification} from "../utils_browser.js"; +import {ShelfList, simpleSelectric} from "./shelf_list.js"; +import {Query} from "../storage_query.js"; +import {Path} from "../path.js"; +import {Shelf} from "../bookmarks_shelf.js"; +import {TODO} from "../bookmarks_todo.js"; +import {Bookmark} from "../bookmarks_bookmark.js"; +import {Folder} from "../bookmarks_folder.js"; +import {Icon, Node} from "../storage_entities.js"; +import {undoManager} from "../bookmarks_undo.js"; +import {systemInitialization} from "../bookmarks_init.js"; +import {getSidebarWindow} from "../utils_sidebar.js"; +import {helperApp} from "../helper_app.js"; +import {ExternalStorage} from "../storage_external.js"; +import {setBookmarkedActionIcon} from "../bookmarking.js"; + +const INPUT_TIMEOUT = 1000; +const MENU_ID_TO_SEARCH_MODE = { + "shelf-menu-search-universal": SEARCH_MODE_UNIVERSAL, + "shelf-menu-search-title": SEARCH_MODE_TITLE, + "shelf-menu-search-folder": SEARCH_MODE_FOLDER, + "shelf-menu-search-tags": SEARCH_MODE_TAGS, + "shelf-menu-search-content": SEARCH_MODE_CONTENT, + "shelf-menu-search-notes": SEARCH_MODE_NOTES, + "shelf-menu-search-comments": SEARCH_MODE_COMMENTS, + "shelf-menu-search-date": SEARCH_MODE_DATE +}; + +let tree; +let context; +let shelfList; + +let randomBookmark; +let randomBookmarkTimeout; + +let browseNode; + +window.addEventListener('DOMContentLoaded', () => { + const shelfListPlaceholderDiv = $("#shelfList-placeholder"); + shelfListPlaceholderDiv.css("width", ShelfList.getStoredWidth("sidebar") || ShelfList.DEFAULT_WIDTH); + shelfListPlaceholderDiv.show(); + $("#shelves-icon").show(); +}); + +$(init); + +async function init() { + await systemInitialization; + + shelfList = new ShelfList("#shelfList", { + maxHeight: settings.shelf_list_height() || settings.default.shelf_list_height, + _prefix: "sidebar" + }); + + tree = new BookmarkTree("#treeview"); + context = new SearchContext(tree); + + shelfList.change(function () { switchShelf(this.value, true, true) }); + + $("#btnLoad").on("click", () => syncShelves()); + $("#btnSearch").on("click", () => openPage("/ui/fulltext.html")); + $("#btnSettings").on("click", () => openPage("/ui/options.html")); + $("#btnHelp").on("click", () => openPage("/ui/options.html#help")); + $("#btnHelperWarning").on("click", () => openPage("/ui/options.html#backend")); + $("#btnBatchWarning").on("click", () => cancelBatchMode()); + + $("#shelf-menu-button").click(async () => { + $("#search-mode-menu").hide(); + + if (await undoManager.canUndo()) + $("#shelf-menu-undo").show(); + else + $("#shelf-menu-undo").hide(); + + $("#shelf-menu").toggle(); + }); + + $("#shelf-menu-create").click(createShelf); + $("#shelf-menu-rename").click(renameShelf); + $("#shelf-menu-delete").click(deleteShelf); + $("#shelf-menu-sort").click(sortShelves); + + $("#shelf-menu-import").click(() => $("#file-picker").click()); + $("#file-picker").change(importShelf); + + $("#shelf-menu-export").click(() => performExport()); + + $("#shelf-menu-undo").click(() => send.performUndo()); + $("#shelf-menu-abort").click(() => send.abortRequested()); + + $("#search-mode-switch").click(() => { + $("#shelf-menu").hide(); + $("#search-mode-menu").toggle(); + }); + + $("#shelf-menu-search-universal").click(e => { + $("#search-mode-switch").prop("src", "/icons/star.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_UNIVERSAL, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-title").click(e => { + $("#search-mode-switch").prop("src", "/icons/bookmark.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_TITLE, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-folder").click(e => { + $("#search-mode-switch").prop("src", "/icons/filter-folder.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_FOLDER, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-tags").click(e => { + $("#search-mode-switch").prop("src", "/icons/tags.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_TAGS, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-content").click(e => { + $("#search-mode-switch").prop("src", "/icons/content-web.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_CONTENT, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-notes").click(e => { + $("#search-mode-switch").prop("src", "/icons/content-notes.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_NOTES, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-comments").click(e => { + $("#search-mode-switch").prop("src", "/icons/content-comments.svg"); + $("#search-input").attr("placeholder", ""); + context.setMode(SEARCH_MODE_COMMENTS, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + $("#shelf-menu-search-date").click(e => { + $("#search-mode-switch").prop("src", "/icons/calendar.svg"); + $("#search-input").attr("placeholder", "examples: 2020-02-20, before 2020-02-20, after 2020-02-20"); + context.setMode(SEARCH_MODE_DATE, shelfList.selectedShelfName); + settings.last_filtering_mode(e.target.id); + performSearch(); + }); + + if (settings.remember_last_filtering_mode()) + $(`#${settings.last_filtering_mode()}`).click(); + + let filterInputTimeout; + $("#search-input").on("input", e => { + clearTimeout(filterInputTimeout); + + if (e.target.value) { + $("#search-input-clear").show(); + filterInputTimeout = setTimeout(() => performSearch(), INPUT_TIMEOUT); + } + else { + filterInputTimeout = null; + performSearch(); + $("#search-input-clear").hide(); + } + }); + + $("#search-input-clear").click(e => { + $("#search-input").val(""); + $("#search-input-clear").hide(); + $("#search-input").trigger("input"); + }); + + $(document).on("click", e => { + if (!e.target.matches("#shelf-menu-button") + && !e.target.matches("#prop-dlg-containers-icon") + && !e.target.matches("#search-mode-switch")) + $(".simple-menu").hide(); + }); + + $(document).on('contextmenu', e => { + if ($(".dlg-dim:visible").length && e.target.localName !== "input") + e.preventDefault(); + }); + + $("#footer-find-btn").click(e => { + selectNode(randomBookmark); + }); + + $("#footer-reload-btn").click(e => { + clearTimeout(randomBookmarkTimeout); + displayRandomBookmark(); + }); + + $("#footer-close-btn").click(async e => { + await settings.load(); + settings.display_random_bookmark(false); + clearTimeout(randomBookmarkTimeout); + $("#footer").hide(); + }); + + $("#btnAnnouncement").click(e => { + openPage(settings.pending_announcement()) + settings.pending_announcement(null); + $("#btnAnnouncement").hide(); + }) + + tree.onRenameShelf = node => shelfList.renameShelf(node.id, node.name); + + tree.onDeleteShelf = node_ids => { + shelfList.removeShelves(node_ids); + + if (!tree._everything) + switchShelf(DEFAULT_SHELF_ID); + }; + + tree.startProcessingIndication = startProcessingIndication; + tree.stopProcessingIndication = stopProcessingIndication; + + tree.sidebarSelectNode = selectNode; + tree.performExport = performExport; + tree.addFilesDirectory = addFilesDirectory; + + receive.startListener(); + receiveExternal.startListener(); + + await loadSidebar(); +} + +window.onbeforeunload = function() { + if (!_SIDEBAR) { + getSidebarWindow().then(w => { + const position = {top: w.top, left: w.left, height: w.height, width: w.width}; + settings.sidebar_window_position(position); + }); + } +}; + +// window.onunload = async function() { +// }; + +async function loadSidebar() { + try { + await shelfList.load(); + + const initialShelf = await getPreselectedShelf() || getLastShelf() || DEFAULT_SHELF_ID; + await switchShelf(initialShelf, true, true); + + stopProcessingIndication(); + } + catch (e) { + console.error(e); + + stopProcessingIndication(); + + if (await confirm("Error", "Scrapyard has encountered a critical error.
Show diagnostic page?")) { + localStorage.setItem("scrapyard-diagnostics-error", + JSON.stringify({origin: "Sidebar initialization", name: e.name, message: e.message, stack: e.stack})); + openPage("options.html#diagnostics"); + } + + return; + } + + if (settings.pending_announcement()) { + $("#btnAnnouncement").css("display", "inline-block"); + } + + if (settings.storage_mode_internal()) { + if (!_BACKGROUND_PAGE) { + const browseModule = await import("../browse.js"); + browseNode = browseModule.browseNodeBackground; + } + } + else { + const helper = await helperApp.probe(); + + if (!helper) + $("#btnHelperWarning").css("display", "inline-block"); + } + + if (settings.display_random_bookmark()) + /* await */ displayRandomBookmark(); + + if (await ExternalStorage.isBatchSessionOpen()) + $("#btnBatchWarning").css("display", "inline-block"); + +} + +let processingTimeout; +function startProcessingIndication(noWait) { + const startIndication = () => $("#shelf-menu-button").attr("src", "/icons/grid.svg"); + if (noWait) { + startIndication(); + clearTimeout(processingTimeout); + } + else + processingTimeout = setTimeout(startIndication, 1000); +} + +function stopProcessingIndication() { + $("#shelf-menu-button").attr("src", "/icons/menu.svg"); + clearTimeout(processingTimeout); +} + +function getLastShelf() { + const lastShelf = localStorage.getItem("scrapyard-last-shelf"); + + if (lastShelf) + return parseInt(lastShelf); + + return DEFAULT_SHELF_ID; +} + +async function getPreselectedShelf() { + if (settings.platform.firefox) { + const externalShelf = localStorage.getItem("sidebar-select-shelf"); + + if (externalShelf) { + localStorage.removeItem("sidebar-select-shelf"); + return parseInt(externalShelf); + } + } + else { + let externalShelf = await browser.storage.session.get("sidebar-select-shelf"); + externalShelf = externalShelf?.["sidebar-select-shelf"]; + + if (externalShelf) { + browser.storage.session.remove("sidebar-select-shelf"); + return externalShelf; + } + } +} + +function setLastShelf(id) { + localStorage.setItem("scrapyard-last-shelf", id); +} + +async function loadShelves(selected, synchronize = true, clearSelection = false) { + try { + updateProgress(0); + + await shelfList.reload(); + const switchToId = selected || getLastShelf() || DEFAULT_SHELF_ID; + return switchShelf(switchToId, synchronize, clearSelection); + } + catch (e) { + console.error(e); + return switchShelf(DEFAULT_SHELF_ID, synchronize, clearSelection); + } +} + +async function syncShelves() { + await loadShelves(); + + if (getLastShelf() === CLOUD_SHELF_ID) + ; + else if (getLastShelf() === FILES_SHELF_ID) + ; + else { + if (!settings.storage_mode_internal()) + return send.performSync(); + } +} + +async function switchShelf(shelf_id, synchronize = true, clearSelection = false) { + if (getLastShelf() != shelf_id) + tree.clearIconCache(); + + shelfList.selectShelf(shelf_id); + let path = shelfList.selectedShelfName; + path = isBuiltInShelf(path)? path.toLocaleLowerCase(): path; + + setLastShelf(shelf_id); + + if (shelf_id == EVERYTHING_SHELF_ID) + $("#shelf-menu-sort").show(); + else + $("#shelf-menu-sort").hide(); + + context.shelfName = path; + + if (canSearch()) + return performSearch(); + else { + if (shelf_id == TODO_SHELF_ID) { + const nodes = await TODO.listTODO(); + tree.list(nodes, TODO_SHELF_NAME, true); + } + else if (shelf_id == DONE_SHELF_ID) { + const nodes = await TODO.listDONE(); + tree.list(nodes, DONE_SHELF_NAME, true); + } + else if (shelf_id == EVERYTHING_SHELF_ID) { + const nodes = await Shelf.listContent(EVERYTHING_SHELF_NAME); + tree.update(nodes, true, clearSelection); + + if (synchronize && settings.cloud_enabled()) { + await send.reconcileCloudBookmarkDb({verbose: true}); + } + + if (synchronize && settings.enable_files_shelf()) { + await send.reconcileExternalFiles(); + } + } + else if (shelf_id == FILES_SHELF_ID) { + const nodes = await Shelf.listContent(path); + tree.update(nodes, false, clearSelection); + if (synchronize && settings.enable_files_shelf()) { + await send.reconcileExternalFiles(); + } + tree.openRoot(); + } + else if (shelf_id == CLOUD_SHELF_ID) { + const nodes = await Shelf.listContent(path); + tree.update(nodes, false, clearSelection); + if (synchronize && settings.cloud_enabled()) { + await send.reconcileCloudBookmarkDb({verbose: true}); + } + tree.openRoot(); + } + else if (shelf_id == BROWSER_SHELF_ID) { + const nodes = await Shelf.listContent(path); + nodes.splice(nodes.indexOf(nodes.find(n => n.id == BROWSER_SHELF_ID)), 1); + + for (let node of nodes) { + if (node.parent_id == BROWSER_SHELF_ID) { + node.type = NODE_TYPE_SHELF; + node.parent_id = null; + } + } + tree.update(nodes, false, clearSelection); + } + else if (path) { + const nodes = await Shelf.listContent(path); + tree.update(nodes, false, clearSelection); + tree.openRoot(); + } + } + + if (shelfList.selectedShelfExternal === RDF_EXTERNAL_TYPE) { + $("#shelf-menu-delete").text("Close"); + $("#shelf-menu-export").hide(); + } + else { + $("#shelf-menu-delete").text("Delete"); + $("#shelf-menu-export").show(); + } +} + +async function createShelf() { + const options = await showDlg("prompt", {caption: "Create Shelf", label: "Name:"}); + + if (options?.title) { + if (!isBuiltInShelf(options.title)) { + const shelf = await send.createShelf({name: options.title}); + if (shelf) + loadShelves(shelf.id); + } + else + showNotification({message: "Can not create shelf with this name."}) + } +} + +async function renameShelf() { + let {id, name} = shelfList.getCurrentShelf(); + + if (name && !isBuiltInShelf(name)) { + const options = await showDlg("prompt", {caption: "Rename", label: "Name", title: name}); + let newName = options?.title; + if (newName && !isBuiltInShelf(newName)) { + await send.renameFolder({id, name: newName}); + tree.renameRoot(newName); + shelfList.renameShelf(id, newName); + } + } + else + showNotification({message: "A built-in shelf could not be renamed."}); +} + + +async function deleteShelf() { + let {id, name, external} = shelfList.getCurrentShelf(); + + if (isBuiltInShelf(name)) { + showNotification({message: "A built-in shelf could not be deleted."}) + return; + } + + const verb = external === RDF_EXTERNAL_TYPE? "close": "delete"; + const proceed = await confirm("Warning", `Do you really want to ${verb} '${name}'?`); + + if (proceed && name) { + await send.softDeleteNodes({node_ids: id}) + shelfList.removeShelves(id); + switchShelf(DEFAULT_SHELF_ID); + } +} + +async function sortShelves() { + let nodes = await Query.allShelves(); + let builtIn = nodes.filter(n => isBuiltInShelf(n.name)).sort((a, b) => a.id - b.id); + let regular = nodes.filter(n => !isBuiltInShelf(n.name)).sort((a, b) => a.name.localeCompare(b.name)); + let sorted = [...builtIn, ...regular]; + + let positions = []; + for (let i = 0; i < sorted.length; ++i) + positions.push({id: sorted[i].id, uuid: sorted[i].uuid, external: sorted[i].external, pos: i}); + + await Bookmark.idb.reorder(positions); + + const storedShelves = positions.filter(p => !p.external); + await send.reorderNodes({positions: storedShelves}); + + loadShelves(getLastShelf(), false); +} + +async function importShelf(e) { + if (e.target.files.length > 0) { + let {name, ext} = pathToNameExt($("#file-picker").val()); + let lname = name.toLocaleLowerCase(); + + if (lname === DEFAULT_SHELF_NAME || lname === EVERYTHING_SHELF_NAME || !isBuiltInShelf(lname)) { + if (shelfList.hasShelf(name)) { + if (await confirm("Warning", "This will replace '" + name + "'.")) { + await performImport(e.target.files[0], name, ext); + $("#file-picker").val(""); + } + } + else { + await performImport(e.target.files[0], name, ext); + $("#file-picker").val(""); + } + } + else + showNotification({message: `Cannot replace '${name}'.`}); + } +} + +function canSearch() { + return context.isInputValid($("#search-input").val()); +} + +async function performSearch() { + let input = $("#search-input").val(); + + if (context.isInputValid(input) && !context.isInSearch) { + context.inSearch(); + } + else if (!context.isInputValid(input) && context.isInSearch) { + context.outOfSearch(); + switchShelf(shelfList.selectedShelfId, false); + } + + if (context.isInputValid(input)) + return context.search(input).then(nodes => tree.list(nodes)); +} + +// "File" is non-serializable on Chrome, hence imported files could not be processed in the background +if (!_BACKGROUND_PAGE) + import("../core_import.js"); + +async function performImport(file, file_name, file_ext) { + startProcessingIndication(true); + + try { + const sender = _BACKGROUND_PAGE? send: sendLocal; + await sender.importFile({file: file, file_name: file_name, file_ext: file_ext}); + stopProcessingIndication(); + + if (file_name.toLocaleLowerCase() === EVERYTHING_SHELF_NAME) + await loadShelves(EVERYTHING_SHELF_ID); + else { + const shelf = await Query.shelf(file_name); + await loadShelves(shelf.id); + } + } + catch (e) { + console.error(e); + stopProcessingIndication(); + showNotification({message: "The import has failed: " + e.message}); + } +} + +async function performExport(node) { + let {name: shelf, uuid} = shelfList.getCurrentShelf(); + + if (node) { + shelf = node; + uuid = node.uuid; + } + + const options = await showDlg("export", { + caption: "Export", + file_name: shelf.name || shelf + }); + + if (options) { + startProcessingIndication(true); + + try { + const sender = _BACKGROUND_PAGE? send: sendLocal; + await sender.exportFile({shelf, uuid, fileName: options.file_name, format: options.format}); + } catch (e) { + console.error(e); + if (!e.message?.includes("Download canceled")) + showNotification({message: "The export has failed: " + e.message}); + } + finally { + stopProcessingIndication(); + } + } +} + +async function selectNode(node, open, forceScroll) { + $("#search-input").val(""); + $("#search-input-clear").hide(); + + const path = await Path.compute(node) + await loadShelves(path[0].id, false); + tree.selectNode(node.id, open, forceScroll); +} + +async function selectOrCreatePath(path) { + if (path) { + let normalized_path = Path.expand(path); + let [shelf] = normalized_path.split("/"); + + const shelfNode = await Query.shelf(shelf); + + if (shelfNode) { + const folder = await Folder.getOrCreateByPath(normalized_path); + await switchShelf(shelfNode.id); + tree.selectNode(folder.id, true); + } + else { + if (isVirtualShelf(shelf)) { + switch (shelf.toUpperCase()) { + case EVERYTHING_SHELF_NAME.toUpperCase(): + await switchShelf(EVERYTHING_SHELF_ID); + break; + case TODO_SHELF_NAME: + await switchShelf(TODO_SHELF_ID); + break; + case DONE_SHELF_NAME: + await switchShelf(DONE_SHELF_ID); + break; + } + } + else { + const shelfNode = await Shelf.add(shelf); + if (shelfNode) { + let folder = await Folder.getOrCreateByPath(normalized_path); + await loadShelves(shelfNode.id); + tree.selectNode(folder.id, true); + } + } + } + } +} + +function sidebarRefresh() { + return switchShelf(getLastShelf(), false); +} + +function sidebarRefreshExternal() { + let last_shelf = getLastShelf(); + + if (last_shelf === EVERYTHING_SHELF_ID || last_shelf === BROWSER_SHELF_ID || last_shelf === CLOUD_SHELF_ID + || last_shelf === FILES_SHELF_ID) + settings.load().then(() => loadShelves(last_shelf, false)); +} + +async function getRandomBookmark() { + const ids = await Query.allNodeIDs(); + + if (!ids?.length) + return null; + + let ctr = 20; + do { + const id = Math.floor(Math.random() * (ids.length - 1)); + const node = await Node.get(ids[id]); + + if (isContentNode(node)) + return node; + + ctr -= 1; + } while (ctr > 0); + + return null; +} + +async function displayRandomBookmark() { + let bookmark = randomBookmark = await getRandomBookmark(); + + if (bookmark) { + let html = ``; + $("#footer-content").html(html); + + let icon = bookmark.icon? `url("${bookmark.icon}")`: "var(--themed-globe-icon)"; + + if (bookmark.type === NODE_TYPE_ARCHIVE) + $("#random-bookmark-link").css("font-style", "italic"); + + if (bookmark.type === NODE_TYPE_NOTES) { + icon = "var(--themed-notes-icon)"; + $("#random-bookmark-link").prop('title', `${bookmark.name}`); + } + else { + $("#random-bookmark-link").prop('title', `${bookmark.name}\x0A${bookmark.uri}`); + } + + if (bookmark.stored_icon) { + icon = `url("${await Icon.get(bookmark)}")`; + } + else if (bookmark.icon) { + let image = new Image(); + image.onerror = e => $("#random-bookmark-icon").css("background-image", "var(--themed-globe-icon)"); + image.src = bookmark.icon; + } + + $("#random-bookmark-icon").css("background-image", icon); + + $("#footer").css("display", "grid"); + + $("#random-bookmark-link").click(e => { + send.browseNode({node: bookmark}); + }); + + randomBookmarkTimeout = setTimeout(displayRandomBookmark, 60000 * 5); + } +} + +async function addFilesDirectory() { + return showDlg("orgdir", { + caption: "Add Org-mode Directory" + }); +} + +async function cancelBatchMode() { + await ExternalStorage.closeBatchSession({external: CLOUD_EXTERNAL_TYPE}); + $("#btnBatchWarning").hide(); +} + +receive.startProcessingIndication = message => { + startProcessingIndication(message.noWait); +}; + +receive.stopProcessingIndication = message => { + stopProcessingIndication(); +}; + +receive.beforeBookmarkAdded = async message => { + const node = message.node; + const select = settings.switch_to_new_bookmark(); + + if (node.type === NODE_TYPE_ARCHIVE) + startProcessingIndication(true); + + if (select) { + node.name = await Bookmark.ensureUniqueName(node.parent_id, node.name); + tree.createTentativeNode(node); + + const path = await Path.compute(node.parent_id); + if (getLastShelf() == path[0].id) + tree.selectNode(node.id); + else { + await loadShelves(path[0].id, false) + tree.createTentativeNode(node, select); + tree.selectNode(node.id); + } + } +}; + +receive.bookmarkCreationFailed = async message => { + tree.removeTentativeNode(message.node); +}; + +receive.bookmarkAdded = message => { + if (message.node.type === NODE_TYPE_ARCHIVE) + stopProcessingIndication(); + + if (settings.switch_to_new_bookmark()) + if (!tree.updateTentativeNode(message.node)) + selectNode(message.node); + + return setBookmarkedActionIcon(); +}; + +receive.bookmarkCreated = message => { + if (settings.switch_to_new_bookmark()) + selectNode(message.node); +}; + +receive.selectNode = message => { + selectNode(message.node, message.open, message.forceScroll); +}; + +receive.selectPath = async (message, sender) => { + await selectOrCreatePath(message.path); +}; + +receive.getTreeSelection = async (message, sender) => { + return tree.getSelectedNodes(); +}; + +receive.notesChanged = message => { + tree.setNotesState(message.node_id, !message.removed); +}; + +receive.nodesUpdated = sidebarRefresh; + +receive.nodesReady = message => { + let last_shelf = getLastShelf(); + + if (last_shelf == EVERYTHING_SHELF_ID || last_shelf == message.shelf.id) + loadShelves(last_shelf, false); +}; + +receive.nodesImported = message => { + const shelfId = message.shelf? message.shelf.id: EVERYTHING_SHELF_ID; + + return loadShelves(shelfId, false); +}; + +receive.externalNodesReady = sidebarRefreshExternal; +receive.externalNodeUpdated = sidebarRefreshExternal; +receive.externalNodeRemoved = sidebarRefreshExternal; + +receive.cloudSyncStart = message => { + startProcessingIndication(); + tree.setNodeIcon(CLOUD_SHELF_ID, "var(--themed-cloud-sync-icon)"); +}; + +receive.cloudSyncEnd = message => { + tree.setNodeIcon(CLOUD_SHELF_ID, "var(--themed-cloud-icon)"); + stopProcessingIndication(); +}; + +receive.shelvesChanged = message => { + return settings.load().then(() => loadShelves(getLastShelf(), message.synchronize)); +}; + +receive.sidebarThemeChanged = message => { + if (message.theme === "dark") + setDarkUITheme(); + else + removeDarkUITheme(); +}; + +receive.displayRandomBookmark = message => { + clearTimeout(randomBookmarkTimeout); + if (message.display) + displayRandomBookmark(); + else + $("#footer").css("display", "none"); +}; + +receive.reloadSidebar = message => { + const sidebarUrl = browser.runtime.getURL(`/ui/sidebar.html#shelf-list-height-${message.height}`); + browser.sidebarAction.setPanel({panel: sidebarUrl}); +}; + +receive.toggleAbortMenu = message => { + if (message.show) + $("#shelf-menu-abort").show(); + else + $("#shelf-menu-abort").hide(); +}; + +receive.exportProgress = message => message.muteSidebar? null: updateProgress(message); +receive.importProgress = message => message.muteSidebar? null: updateProgress(message); +receive.syncProgress = message => updateProgress(message); +receive.cloudSyncProgress = message => updateProgress(message); +receive.fullTextSearchProgress = message => updateProgress(message); + +function updateProgress(message) { + const progressDiv = $("#sidebar-progress"); + if (message.progress) { + if (message.finished) { + setTimeout(() => progressDiv.css("width", "0"), 300); + } + else if (message.progress === 100) { + progressDiv.css("width", message.progress + "%"); + setTimeout(() => progressDiv.css("width", "0"), 200); + } + else + progressDiv.css("width", message.progress + "%"); + } + else + progressDiv.css("width", "0"); +} + +receive.browseNodeSidebar = message => { + if (browseNode) + browseNode(message.node, message); +}; + +receive.storageModeInternal = message => { + $("#btnHelperWarning").hide(); +}; + +receiveExternal.scrapyardSwitchShelfIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + await selectOrCreatePath(message.name); +}; + +async function switchAfterCopy(message, external_path, folder, topNodes) { + if (message.action === "switching") { + const [shelf, ...path] = external_path.split("/"); + const shelfNode = await Query.shelf(shelf); + + await loadShelves(shelfNode.id); + + tree.openNode(folder.id) + tree.selectNode(topNodes); + } + else + await switchShelf(getLastShelf()); +} + +receiveExternal.scrapyardCopyAtIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + let externalPath = Path.expand(message.path); + let selection = tree.getSelectedNodes(); + + if (selection.some(n => n.type === NODE_TYPE_SHELF)) { + showNotification("Can not copy shelves.") + } + else { + selection.sort(byPosition); + selection = selection.map(n => n.id); + + const folder = await Folder.getOrCreateByPath(externalPath); + + try { + await ExternalStorage.openBatchSession(folder); + let newNodes = await send.copyNodes({node_ids: selection, dest_id: folder.id, move_last: true}); + let topNodes = newNodes.filter(n => selection.some(id => id === n.source_node_id)).map(n => n.id); + + await switchAfterCopy(message, externalPath, folder, topNodes); + } + finally { + await ExternalStorage.closeBatchSession(folder); + } + } +}; + +receiveExternal.scrapyardMoveAtIshell = async (message, sender) => { + if (!ishellConnector.isIShell(sender.id)) + throw new Error(); + + let externalPath = Path.expand(message.path); + let selection = tree.getSelectedNodes(); + if (selection.some(n => n.type === NODE_TYPE_SHELF)) { + showNotification("Can not move shelves.") + } + else { + selection.sort(byPosition); + selection = selection.map(n => n.id); + + const folder = await Folder.getOrCreateByPath(externalPath); + + try { + await ExternalStorage.openBatchSession(folder); + await send.moveNodes({node_ids: selection, dest_id: folder.id, move_last: true}); + } + finally { + await ExternalStorage.closeBatchSession(folder); + } + + await switchAfterCopy(message, externalPath, folder, selection); + } +}; + +console.log("==> sidebar.js loaded"); diff --git a/addon/ui/sidebar_dark.css b/addon/ui/sidebar_dark.css new file mode 100644 index 00000000..7cb0b7bf --- /dev/null +++ b/addon/ui/sidebar_dark.css @@ -0,0 +1,335 @@ +:root { + --grey-10: #f9f9fa; + --grey-20: #ededf0; + --grey-30: #d7d7db; + --grey-40: #b1b1b3; + --grey-50: #737373; + --grey-60: #4a4a4f; + --grey-70: #38383d; + --grey-80: #2a2a2e; + --grey-90: #0c0c0d; + + --tone-1: var(--grey-10); + --tone-2: var(--grey-20); + --tone-3: var(--grey-30); + --tone-4: var(--grey-40); + --tone-5: var(--grey-50); + --tone-6: var(--grey-60); + --tone-7: var(--grey-70); + --tone-8: var(--grey-80); + --tone-9: var(--grey-90); + + --in-content-box-background: var(--tone-6) !important; + --in-content-box-background-hover: var(--tone-6) !important; + --in-content-box-border-color: var(--tone-5) !important; + + --in-content-page-color: var(--tone-4) !important; + --in-content-page-background: var(--tone-7) !important; + + --themed-globe-icon: url("../icons/globe2.svg") !important; + --themed-notes-icon: url("../icons/notes2.svg"); + + --theme-version: 0.2; + --theme-background: var(--in-content-page-background) !important; + + --themed-firefox-icon: url("../icons/firefox2.svg") !important; + --themed-chrome-icon: url("../icons/chrome2.svg") !important; + + --themed-files-box-icon: url("../icons/files-box2.svg") !important; + --themed-file-icon: url("../icons/file2.svg") !important; + + --themed-cloud-icon: url("../icons/cloud2.svg") !important; + --themed-cloud-sync-icon: url("../icons/cloud_sync2.svg") !important; + --themed-tape-icon: url("../icons/tape2.svg"); + + --themed-comments-icon: url("../icons/comments2.svg"); + --themed-comments-filled-icon: url("../icons/comments-filled2.svg"); + --themed-properties-icon: url("../icons/properties2.svg"); + --themed-containers-icon: url("../icons/containers2.svg"); + --themed-image-icon: url("../icons/format-image2.svg"); + --themed-pdf-icon: url("../icons/format-pdf2.svg"); +} + +html, body, .toolbar, .footer, .black-button, .simple-menu, .dlg, .dlg *, #settings-menu, .settings-content, .settings-content * { + background-color: var(--in-content-page-background) !important; + border-color: var(--tone-5) !important; + color: var(--in-content-page-color) !important; +} + +.dlg *, +select, +.simple-menu div, +.jstree-default .jstree-anchor .todo-path, +.jstree-default .jstree-anchor .todo-details { + color: var(--in-content-page-color) !important; +} + +.dlg input, .dlg input:focus, .dlg textarea, .dlg select, .selectric { + border: 1px solid var(--in-content-box-border-color) !important; + border-radius: 3px !important; + -moz-appearance: none !important; + outline: none !important; +} + +.jstree-default .jstree-anchor.jstree-clicked, +.jstree-default .jstree-anchor.jstree-clicked .todo-path, +.jstree-default .jstree-anchor.jstree-clicked .todo-details { + color: var(--tone-3)/* !important*/; + transition: none !important; + background-color: transparent !important; +} + +.dlg input[type=button] { + cursor: pointer !important; +} + +.simple-menu div:hover, +.selectric-scroll li:hover, +.dlg input[type=button]:hover, +.jstree-default .jstree-hovered, +.jstree-default .jstree-wholerow-hovered { + background-color: #434347 !important; + transition: none !important; +} + +.jstree-default .jstree-clicked, +.jstree-default .jstree-wholerow-clicked, +.jstree-default .jstree-clicked.jstree-hovered, +.jstree-default .jstree-wholerow-clicked.jstree-wholerow-hovered { + background-color: #4c4c51 !important; +} + +.selectric-scroll li:hover, .simple-menu div:hover { + background-color: var(--tone-4) !important; + color: var(--tone-7) !important; +} + +.jstree-default .jstree-anchor, .jstree-default .jstree-wholerow { + transition: none !important; +} + +.vakata-context, .vakata-context ul, ul.jstree-default-contextmenu { + background-color: var(--in-content-box-background-hover) !important; + border: 1px solid var(--in-content-box-border-color) !important; + padding: 0px !important; + color: var(--in-content-page-color) !important; + box-shadow: none !important; + text-shadow: none !important; +} + +.vakata-context li > a { + color: var(--tone-2) !important; + box-shadow: none !important; + text-shadow: none !important; +} + +.vakata-context li > a:hover, +.vakata-context li.vakata-context-hover > a { + background-color: var(--tone-4) !important; + color: var(--tone-7) !important; +} + +.vakata-context .vakata-contextmenu-disabled a, +.vakata-context .vakata-contextmenu-disabled a:hover { + color: var(--tone-5) !important; +} + +.vakata-context .vakata-context-separator > a, +.vakata-context .vakata-context-separator > a:hover { + border-top: 1px solid var(--in-content-box-border-color) !important; + border-left: 1px solid var(--in-content-box-border-color) !important; + box-shadow: none !important; + background-color: transparent !important; +} + +.vakata-context .vakata-context-separator.vakata-context-hover { + background-color: transparent !important; +} + +.vakata-context li > a .vakata-contextmenu-sep { + border-left: 1px solid var(--in-content-box-border-color) !important; + box-shadow: none !important; + background-color: transparent !important; +} + +.vakata-context li > a.vakata-context-parent { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAAHCAYAAADebrddAAAAL3RFWHRDcmVhdGlvbiBUaW1lANCn0YIgNCDQsNC/0YAgMjAxOSAxMTozMDo1NCArMDQwMPSur74AAAAHdElNRQfjBAQHIASxQAQlAAAACXBIWXMAAAsSAAALEgHS3X78AAAABGdBTUEAALGPC/xhBQAAACVJREFUeNpjePv2w38GYgFIMdEaYIqJ0oCsmKAGskwm2s3EBgYAK7o8kRxQo+YAAAAASUVORK5CYII=") !important; +} + +.jstree-default .jstree-open > .jstree-ocl, .jstree-default .jstree-closed > .jstree-ocl { + background-image: url("../lib/jstree/themes/default/32px_dark.png") !important; +} + +#jstree-dnd.jstree-default .jstree-ok, +#jstree-dnd.jstree-default .jstree-er { + background-image: url("../lib/jstree/themes/default/32px_dark.png") !important; +} + +.selectric .label::before, +.selectric-items li.folder-label::before, +.jstree-default .scrapyard-group > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/group2.svg") !important; +} + +.jstree-default .scrapyard-site > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/web2.svg") !important; +} + +.jstree-default .scrapyard-shelf > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/shelf2.svg") !important; +} + +.jstree-default .scrapyard-notes > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/notes2.svg") !important; +} + +.jstree-default .jstree-anchor.generic-icon .jstree-themeicon { + background-image: url("../icons/globe2.svg") !important; +} + +.jstree-default .browser-bookmark-menu > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/bookmarksMenu2.svg") !important; +} + +.jstree-default .browser-unfiled-bookmarks > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/unfiledBookmarks2.svg") !important; +} + +.jstree-default .browser-bookmark-toolbar > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/bookmarksToolbar2.svg") !important; +} + +.jstree-default .rdf-archive > .jstree-anchor .jstree-themeicon { + background-image: url("../icons/tape2.svg") !important; +} + +input[type=text], .selectric, .selectric .button, .selectric-items, .selectric-scroll li, .simple-menu div { + background-color: var(--in-content-box-background) !important; + border-color: var(--in-content-box-border-color) !important; + color: var(--in-content-page-color)!important; +} + +.selectric .label { + background-color: var(--in-content-box-background) !important; +} + +.midnight-filter, .midnight-filter-menu { + filter: invert(80%) sepia(7%) saturate(52%) hue-rotate(201deg) brightness(90%) contrast(87%); +} + +.simple-menu div:hover .midnight-filter-menu { + filter: invert(0%) sepia(29%) saturate(2147%) hue-rotate(18deg) brightness(99%) contrast(87%); +} + + +.jstree-default .todo-state-todo.jstree-hovered, +.jstree-default .todo-state-todo.jstree-clicked { + color: #fc6dac !important; +} + +.jstree-default .todo-state-waiting.jstree-hovered, +.jstree-default .todo-state-waiting.jstree-clicked { + color: #ff8a00 !important; +} + +.jstree-default .todo-state-postponed.jstree-hovered, +.jstree-default .todo-state-postponed.jstree-clicked { + color: #00b7ee !important; +} + +.jstree-default .todo-state-overdue.jstree-hovered, +.jstree-default .todo-state-overdue.jstree-clicked, +.jstree-default .todo-state-cancelled.jstree-hovered, +.jstree-default .todo-state-cancelled.jstree-clicked { + color: #ff4d26 !important; +} + +.jstree-default .todo-state-done.jstree-hovered, +.jstree-default .todo-state-done.jstree-clicked { + color: #00b60e !important; +} + +a.settings-menu-item { + color: var(--in-content-page-color) !important; +} + +a.settings-menu-item.focus{ + border-right:3px solid var(--tone-5) !important; + background-color:#ddd !important; + color: black !important; + position: relative; + z-index: 10000; +} + +.settings-content input[type=number] { + color: black !important; +} + + +.settings-content input[type=button] { + border: 1px solid var(--in-content-box-border-color) !important; + border-radius: 3px !important; + -moz-appearance: none !important; + cursor: pointer !important; +} + +.settings-content input[type=button]:hover { + background-color: var(--in-content-box-background-hover) !important; +} + +.help-mark { + background-image: url("../icons/help-mark2.svg") !important; +} + +code { + color: rgba(0, 0, 0, 0.75) !important; +} + +span.task-status { + color: white !important; +} + +span.task-status.todo { + color: white !important; +} + +span.task-status.done { + color: white !important; +} + +.section-number { + color: rgb(0, 90, 190) !important; +} + +#source-url { + color: #0074D9 !important; +} + +select#link-scope, select#option-export-format, select#notes-format { + color: black !important; +} + +.node-pending { + color: #909090 !important; +} + +.jstree-rename-input:focus { + outline: none !important; + border: 1px solid #b0b0b0 !important; +} + +.archive-node-jar .jstree-themeicon::after { + background-image: url("/icons/jar2.svg") !important; +} + +.archive-node-color { + color: #ff7b7b; +} + +#new-folder { + background-image: url("/icons/new-group2.svg"); +} + +#new-shelf { + background-image: url("/icons/new-shelf2.svg"); +} diff --git a/addon/ui/site_capture.css b/addon/ui/site_capture.css new file mode 100644 index 00000000..c2cb2990 --- /dev/null +++ b/addon/ui/site_capture.css @@ -0,0 +1,140 @@ +* { + font-family: "Helvetica", "Arial", sans-serif; +} + +input, textarea { + outline: none !important; +} + +#root-container { + display: flex; + flex-direction: column; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + font-size: 12pt; + min-height: 0; + background: white; +} + +#header { + height: 38px; + flex: 0 1 auto; + display: flex; + border-bottom: 1px solid #777; + line-height: 37px; + padding: 0 10px; + font-weight: bold; +} + +#help-link { + font-weight: normal; + font-size: 10pt; +} + +#content { + padding: 10px; + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 0; + flex-grow: 1; + overflow: auto; +} + +#content > * { + flex: 0 1 auto; +} + +#content > textarea { + flex: 1 0 auto; +} + +#footer { + flex: 0 1 auto; + display: flex; + border-top: 1px solid #777; + line-height: 24px; + padding: 4px 8px; +} + +#footer > * { + flex: 0 1 auto; +} + +#header .spacer, #footer .spacer { + flex: 1 1 auto; +} + +.button { + background: #09c; + color: #fff; + cursor: pointer; + border-radius: 5px; + padding: 3px 15px; + border: none; + line-height: 20px; + margin-left: 5px; +} + +.dropdown { + cursor: pointer; + user-select: none; +} + +.dropdown-symbol { + font-size: 80%; + display: inline-block; + padding-right: 3px; +} + +#exclude-links-header, #other-options { + margin-top: 10px; +} + +.option-line { + display: flex; + position: relative; +} + +.option-line .spacer { + flex: 1 0 auto; +} + +.option-line > .option-container { + flex: 0 1 auto; +} + +.option-container input[type="number"] { + width: 40px; +} + +.simple-menu { + min-width: 50px; + position: absolute; + background: #fff; + right: 5px; + top: 15px; + margin-top: 5px; + border: solid 1px black; + z-index: 10000; + display: none; +} + +.simple-menu div { + color: #000; + padding: 5px 15px; + cursor: pointer; + font-size: 10pt; + user-select: none; +} + +.simple-menu div:hover { + background: #ddd; +} + +#ok-button, #close-button { + width: 5em; +} diff --git a/addon/ui/site_capture.html b/addon/ui/site_capture.html new file mode 100644 index 00000000..0b9e7e26 --- /dev/null +++ b/addon/ui/site_capture.html @@ -0,0 +1,77 @@ + + + + + Site Capture Options + + + + + + + + + +
+ +
+ + + + +
+
+ + +   + + +   + + +
+
 
+
+ + +
+
+
+ +
+ + diff --git a/addon/ui/site_capture.js b/addon/ui/site_capture.js new file mode 100644 index 00000000..e5ca1f23 --- /dev/null +++ b/addon/ui/site_capture.js @@ -0,0 +1,132 @@ +var pageURL = null; +var pageLinks = []; + +const DOMAIN_STUB = "INSERT_YOUR_DOMAIN"; + +$(init); + +function init() { + setUpMenu("include"); + setUpMenu("exclude"); + + const helpLink = document.getElementById("help-link"); + helpLink.href = browser.runtime.getURL("ui/options.html#help:site-capture-manual"); + + document.addEventListener("click", e => { + if (!e.target.matches(".dropdown")) + hideMenu(); + }); + + document.getElementById("cancel-button") + .addEventListener("click", e => { + browser.runtime.sendMessage({type: "cancelSiteCapture"}); + }); + + document.getElementById("ok-button") + .addEventListener("click", e => { + const options = { + depth: parseInt(document.getElementById("option-crawling-depth").value) || 1, + delay: parseInt(document.getElementById("option-crawling-delay").value) || 0, + threads: parseInt(document.getElementById("option-processing-threads").value) || 5, + ignoreHashes: document.getElementById("option-ignore-hashes").checked, + includeRules: document.getElementById("include-links").value, + excludeRules: document.getElementById("exclude-links").value + } + browser.runtime.sendMessage({type: "continueSiteCapture", options}); + }); + + const message = {type: "requestFrames", siteCapture: true, siteCaptureOptions: true}; + browser.runtime.sendMessage(message); +}; + +chrome.runtime.onMessage.addListener( + function(message) { + switch (message.type) { + case "replyFrameSiteCapture": + if (message.key === "0") + pageURL = message.url; + + if (message.links) + pageLinks = [...pageLinks, ...message.links] + break; + } + }); + +function setUpMenu(id) { + document.querySelectorAll(`#${id}-presets-menu-dropdown, #${id}-presets-menu-dropdown .dropdown-symbol`) + .forEach(element => { + element.addEventListener("click", e => { + e.stopPropagation(); + const menu = document.getElementById(`${id}-presets-menu`); + const hidden = menu.style.display !== "block"; + hideMenu(); + menu.style.display = hidden? "block": "none"; + }); + }); + + document.getElementById(`${id}-presets-menu-site`) + .addEventListener("click", e => { + const url = pageURL? new URL(pageURL): null; + const host = url?.host || DOMAIN_STUB; + const urlRegex = host.replace(/\./g, "\\."); + insertRule(id, `/^https?://(?:[^.]*\\.)*?${urlRegex}(?:\\d+)?//`) + }); + + document.getElementById(`${id}-presets-menu-domain`) + .addEventListener("click", e => { + const url = pageURL? new URL(pageURL): null; + const origin = url?.origin || DOMAIN_STUB; + const urlRegex = origin.replace(/\./g, "\\."); + insertRule(id, `/^${urlRegex}//`) + }); + + document.getElementById(`${id}-presets-menu-directory`) + .addEventListener("click", e => { + const urlRegex = (pageURL || DOMAIN_STUB) + .replace(/[^/]*$/g, "") + .replace(/\./g, "\\.");; + insertRule(id, `/^${urlRegex}/`); + }); + + document.getElementById(`${id}-presets-menu-path`) + .addEventListener("click", e => { + const urlRegex = (pageURL || DOMAIN_STUB).replace(/\./g, "\\."); + insertRule(id, `/^${urlRegex}(?=[?#]|$)/`); + }); + + document.getElementById(`${id}-presets-menu-all-links`) + .addEventListener("click", e => { + let links = ""; + for (const link of pageLinks) + links += `${link.url} ${formatLinkText(link.text)}\n`; + insertRule(id, links.trim()); + }); + + document.getElementById(`${id}-presets-menu-text`) + .addEventListener("click", e => { + insertRule(id, "$text:/^Chapter \\d+$/i"); + }); +} + +function hideMenu() { + document.querySelectorAll(".simple-menu") + .forEach(element => { + element.style.display = "none"; + }); +} + +function insertRule(id, rule) { + const textarea = document.getElementById(`${id}-links`); + textarea.value = textarea.value + (textarea.value? "\n": "") + `${rule}`; +} + +function formatLinkText(text) { + text = text?.trim()?.replace(/\n/g, " ")?.replace(/\s+/g, " ") || ""; + + let result = `[${text}]`; + + if (result === "[]") + result = ""; + + return result; +} diff --git a/addon/ui/site_capture_content.css b/addon/ui/site_capture_content.css new file mode 100644 index 00000000..60d5eead --- /dev/null +++ b/addon/ui/site_capture_content.css @@ -0,0 +1,21 @@ +#scrapyard-dim { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #777; + opacity: 0.5; + z-index: 2000000; +} + +#scrapyard-site-capture-frame { + position: fixed; + top: 20%; + left: 20%; + width: 60%; + height: 60%; + z-index: 2147483600; + border: none; + overflow: hidden; +} diff --git a/addon/ui/site_capture_content.js b/addon/ui/site_capture_content.js new file mode 100644 index 00000000..d535b758 --- /dev/null +++ b/addon/ui/site_capture_content.js @@ -0,0 +1,53 @@ +function showSiteCaptureOptions() { + const iframeId = "scrapyard-site-capture-frame"; + const iframe = document.getElementById(iframeId); + + if (!iframe) { + const dim = document.createElement('div') + dim.id = "scrapyard-dim"; + document.body.insertBefore(dim, document.body.firstChild); + + const options = document.createElement('iframe'); + options.id = iframeId; + options.style.overflow = "hidden"; + options.src = browser.runtime.getURL("ui/site_capture.html"); + document.body.insertBefore(options, document.body.firstChild); + + document.addEventListener("keydown", keydownListener); + } +} + +function hideSiteCaptureOptions() { + document.querySelectorAll(`#scrapyard-site-capture-frame, #scrapyard-dim`) + .forEach(element => { + element.remove(); + }); + + document.removeEventListener("keydown", keydownListener); +} + +function keydownListener(e) { + if (e.keyCode === 27) // ESC + hideSiteCaptureOptions(); +} + +var bookmark; + +browser.runtime.onMessage.addListener(message => { + switch (message.type) { + case "storeBookmark": + bookmark = message.bookmark; + break; + case "continueSiteCapture": + bookmark.__site_capture = message.options; + browser.runtime.sendMessage({type: "performSiteCapture", bookmark}); + // no break + case "cancelSiteCapture": + hideSiteCaptureOptions(); + break; + } +}); + +showSiteCaptureOptions(); + + diff --git a/addon/ui/style_loader.js b/addon/ui/style_loader.js new file mode 100644 index 00000000..aefd76ff --- /dev/null +++ b/addon/ui/style_loader.js @@ -0,0 +1,22 @@ +function loadCSSFile(id, file) { + let head = document.getElementsByTagName("head")[0]; + let link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = file; + link.media = "all"; + head.appendChild(link); +} + +function setDarkUITheme() { + loadCSSFile("dark-theme", "sidebar_dark.css"); +} + +function removeDarkUITheme() { + $("#dark-theme").remove(); +} + +if (localStorage.getItem("scrapyard-sidebar-theme") === "dark") + setDarkUITheme() + diff --git a/addon/ui/tree.css b/addon/ui/tree.css new file mode 100644 index 00000000..b64f4905 --- /dev/null +++ b/addon/ui/tree.css @@ -0,0 +1,308 @@ +:root { + --themed-globe-icon: url("../icons/globe.svg"); + --themed-notes-icon: url("../icons/notes.svg"); + --themed-firefox-icon: url("../icons/firefox.svg"); + --themed-chrome-icon: url("../icons/chrome.svg"); + --themed-files-box-icon: url("../icons/files-box.svg"); + --themed-file-icon: url("../icons/file.svg"); + --themed-cloud-icon: url("../icons/cloud.svg"); + --themed-cloud-sync-icon: url("../icons/cloud_sync.svg"); + --themed-image-icon: url("../icons/format-image.svg"); + --themed-pdf-icon: url("../icons/format-pdf.svg"); +} + +.jstree-default .jstree-node { + margin-left: 16px; + min-width: 16px; +} +.jstree-default .jstree-icon:empty { + width: 16px; + height: 16px; + line-height: 24px; + margin-top: 4px; +} +.jstree-default > .jstree-no-dots .jstree-open > .jstree-ocl { + background-position: -40px -8px; +} +.jstree-default > .jstree-no-dots .jstree-closed > .jstree-ocl { + background-position: -8px -8px; +} +.jstree-default .jstree-themeicon { + margin-right: 4px; +} +.jstree-default .jstree-clicked { + background: #ddd; +} +.jstree-default .jstree-hovered { + background: #eee; +} + + +.jstree-default .jstree-wholerow-clicked { + background: #ddd; +} + +.jstree-default .jstree-wholerow-hovered { + background: #eee; +} + +.vakata-context, +.vakata-context ul { + margin: 0; + padding: 2px; + position: absolute; + background: #f5f5f5; + border: 1px solid #979797; + box-shadow: 2px 2px 2px #999999; +} +.vakata-context ul { + list-style: none; + left: 100%; + margin-top: -2.7em; + margin-left: -4px; +} +.vakata-context .vakata-context-right ul { + left: auto; + right: 100%; + margin-left: auto; + margin-right: -4px; +} +.vakata-context li { + list-style: none; +} +.vakata-context li > a { + display: block; + padding: 0 2em 0 2em; + text-decoration: none; + width: auto; + color: black; + white-space: nowrap; + line-height: 1.8em; + text-shadow: 1px 1px 0 white; + border-radius: 1px; +} +.vakata-context li > a:hover { + position: relative; + background-color: #e8eff7; + box-shadow: 0 0 2px #ccc; +} +.vakata-context li > a.vakata-context-parent { + background-image: url("data:image/gif;base64,R0lGODlhCwAHAIAAACgoKP///yH5BAEAAAEALAAAAAALAAcAAAIORI4JlrqN1oMSnmmZDQUAOw=="); + background-position: right center; + background-repeat: no-repeat; +} +.vakata-context li > a:focus { + outline: 0; +} +.vakata-context .vakata-context-hover > a { + position: relative; + background-color: #e8eff7; + box-shadow: 0 0 2px #ccc; +} +.vakata-context .vakata-context-separator > a, +.vakata-context .vakata-context-separator > a:hover { + background: white; + border: 0; + border-top: 1px solid #e2e3e3; + height: 1px; + min-height: 1px; + max-height: 1px; + padding: 0; + margin: 0 0 0 1.8em; + border-left: 1px solid #e0e0e0; + text-shadow: 0 0 0 transparent; + box-shadow: 0 0 0 transparent; + border-radius: 0; +} +.vakata-context .vakata-contextmenu-disabled a, +.vakata-context .vakata-contextmenu-disabled a:hover { + color: silver; + background-color: transparent; + border: 0; + box-shadow: 0 0 0; +} +.vakata-context .vakata-contextmenu-disabled > a > i { + filter: grayscale(100%); +} +.vakata-context li > a > i { + text-decoration: none; + display: inline-block; + width: 1.8em; + height: 1.8em; + background: transparent; + margin: 0 0 0 -2em; + vertical-align: top; + text-align: center; + line-height: 1.8em; +} +.vakata-context li > a > i:empty { + width: 1.8em; + line-height: 1.8em; +} +.vakata-context li > a .vakata-contextmenu-sep { + display: inline-block; + width: 1px; + height: 1.8em; + background: white; + margin: 0 0.5em 0 0; + border-left: 1px solid #e2e3e3; +} +.vakata-context .vakata-contextmenu-shortcut { + font-size: 0.8em; + color: silver; + opacity: 0.5; + display: none; +} +.vakata-context-rtl ul { + left: auto; + right: 100%; + margin-left: auto; + margin-right: -4px; +} +.vakata-context-rtl li > a.vakata-context-parent { + background-image: url("data:image/gif;base64,R0lGODlhCwAHAIAAACgoKP///yH5BAEAAAEALAAAAAALAAcAAAINjI+AC7rWHIsPtmoxLAA7"); + background-position: left center; + background-repeat: no-repeat; +} +.vakata-context-rtl .vakata-context-separator > a { + margin: 0 1.8em 0 0; + border-left: 0; + border-right: 1px solid #e2e3e3; +} +.vakata-context-rtl .vakata-context-left ul { + right: auto; + left: 100%; + margin-left: -4px; + margin-right: auto; +} + +.separator-node { + font-family: "Courier New", monospace; +} + +.jstree-default .separator-node::before { + content: "──"; +} + +.jstree-default .separator-node i { + display: none; +} + + + +.jstree-default-responsive .jstree-wholerow-hovered, .vakata-context-hover { + background: #eee; +} +.jstree-default-responsive .jstree-wholerow-clicked { + background: #ddd; +} + +.jstree-anchor.todo-state-todo { + color: #fc6dac; + font-weight: bold; +} + +.jstree-anchor.todo-state-waiting { + color: #ff8a00; + font-weight: bold; +} + +.jstree-anchor.todo-state-postponed { + color: #00b7ee; + font-weight: bold; +} + +.jstree-anchor.todo-state-cancelled, +.jstree-anchor.todo-state-overdue { + color: #ff4d26; + font-weight: bold; +} + +.jstree-anchor.todo-state-done { + color: #00b60e; + font-weight: bold; +} + +.jstree-default .extended-todo { + margin-top: 4px; +} + +.jstree-default .extended-todo .jstree-themeicon { + float: left; +} + +.jstree-default .jstree-anchor { + cursor: pointer; +} + +.jstree-default .extended-todo .jstree-anchor { + display: inline-block; + line-height: normal; + height: auto; + font-size: 98%; +} + +.jstree-default .extended-todo .jstree-wholerow.jstree-wholerow-hovered, +.jstree-default .extended-todo .jstree-wholerow.jstree-wholerow-clicked { + /*height: 31px;*/ +} + +.jstree-default .extended-todo span.todo-path { + font-size: 80%; + font-weight: normal; + font-style: normal; + color: #777; + white-space: nowrap; + text-decoration: none !important; +} + +.jstree-default .extended-todo .todo-details { + color: #777; + font-style: normal; + font-weight: normal; +} + +.jstree-rename-input { + border: 1px solid #808080 !important; + outline: none !important; +} + +.jstree-rename-input:focus { + outline: none !important; +} + +.archive-node { + font-style: italic; +} + +.archive-node-color { + color: #c6300d;; +} + +.archive-node-jar .jstree-themeicon { + margin-right: 17px; +} + +.archive-node-jar .jstree-themeicon::after { + content: " "; + background-image: url("/icons/jar.svg"); + display: inline-block; + width: 16px; + height: 16px; + margin-left: 16px; + background-size: 15px 15px; + background-position: 1px 0; + background-repeat: no-repeat; +} + +a.has-notes, a.has-notes:hover { + text-decoration: underline; +} + +.container-context-menu i { + background-size: 16px 16px !important; +} + +.node-pending { + color: dimgray; +} diff --git a/addon/ui/tree.js b/addon/ui/tree.js new file mode 100644 index 00000000..099ef56d --- /dev/null +++ b/addon/ui/tree.js @@ -0,0 +1,1692 @@ +import {send} from "../proxy.js"; +import {cloudShelf} from "../plugin_cloud_shelf.js" +import {showDlg, confirm} from "./dialog.js" +import {settings} from "../settings.js"; +import { + isContainerNode, + isContentNode, + isBuiltInShelf, + CLOUD_EXTERNAL_TYPE, + CONTENT_NODE_TYPES, + EVERYTHING_SHELF_UUID, + FIREFOX_BOOKMARK_MENU, + FIREFOX_BOOKMARK_MOBILE, + FIREFOX_BOOKMARK_TOOLBAR, + FIREFOX_BOOKMARK_UNFILED, + BROWSER_SHELF_ID, + NODE_TYPE_ARCHIVE, + NODE_TYPE_BOOKMARK, + NODE_TYPE_FOLDER, + NODE_TYPE_NOTES, + NODE_TYPE_SEPARATOR, + NODE_TYPE_SHELF, + RDF_EXTERNAL_TYPE, + TODO_STATE_NAMES, + TODO_STATE_CANCELLED, + TODO_STATE_DONE, + TODO_STATE_POSTPONED, + TODO_STATE_TODO, + TODO_STATE_WAITING, + DEFAULT_SHELF_NAME, + byPosition, + BROWSER_EXTERNAL_TYPE, + FILES_EXTERNAL_TYPE, + FILES_EXTERNAL_ROOT_PREFIX, + NODE_TYPE_FILE, CHROME_BOOKMARK_UNFILED, CHROME_BOOKMARK_TOOLBAR, byContainer +} from "../storage.js"; +import {getThemeVar, isElementInViewport} from "../utils_html.js"; +import {getActiveTabFromSidebar, openContainerTab, openPage, showNotification} from "../utils_browser.js"; +import {IMAGE_FORMATS} from "../utils.js"; +import {createBookmarkFromURL, formatShelfName, setBookmarkedActionIcon} from "../bookmarking.js"; +import {Bookmark} from "../bookmarks_bookmark.js"; +import {Comments, Icon, Node} from "../storage_entities.js"; +import UUID from "../uuid.js"; +import {DiskStorage, ExternalStorage} from "../storage_external.js"; + +export const TREE_STATE_PREFIX = "tree-state-"; +const FOLDER_SELECT_STATE = "folder-select"; +const EXTENDED_TODO_CLASS = "extended-todo"; +const DEFAULT_ICON_CLASS = "generic-icon"; + +// return the original Scrapyard node object stored in a jsTree node +let o = n => n.data; + +class BookmarkTree { + constructor(elementId, foldersOnly = false) { + this._elementId = elementId; + this._foldersOnly = foldersOnly; + + let plugins = ["wholerow", "types", "state"]; + + if (!foldersOnly) + plugins = plugins.concat(["contextmenu", "dnd"]); + + const jstree = $(elementId).jstree({ + plugins: plugins, + core: { + worker: false, + animation: 0, + multiple: !foldersOnly, + check_callback: this.#checkOperation.bind(this), + themes: { + name: "default", + dots: false, + icons: true, + }, + }, + contextmenu: { + show_at_node: false, + items: node => this.contextMenu(node) + }, + types: { + "#": { + "valid_children": [NODE_TYPE_SHELF] + }, + [NODE_TYPE_SHELF]: { + "valid_children": [NODE_TYPE_FOLDER, ...CONTENT_NODE_TYPES, NODE_TYPE_SEPARATOR] + }, + [NODE_TYPE_FOLDER]: { + "valid_children": [NODE_TYPE_FOLDER, ...CONTENT_NODE_TYPES, NODE_TYPE_SEPARATOR] + }, + [NODE_TYPE_BOOKMARK]: { + "valid_children": [] + }, + [NODE_TYPE_ARCHIVE]: { + "valid_children": [] + }, + [NODE_TYPE_NOTES]: { + "valid_children": [] + }, + [NODE_TYPE_SEPARATOR]: { + "valid_children": [] + } + }, + state: { + key: foldersOnly? TREE_STATE_PREFIX + FOLDER_SELECT_STATE: undefined, + _scrollable: foldersOnly + }, + dnd: { + inside_pos: "last" + } + }); + + jstree.on("move_node.jstree", this.#moveNode.bind(this)); + + $(document).on("mousedown", ".jstree-node", e => this.handleMouseClick(e)); + $(document).on("click", ".jstree-anchor", e => this.handleMouseClick(e)); + // $(document).on("auxclick", ".jstree-anchor", e => e.preventDefault()); + + this.iconCache = new Map(); + + this._jstree = jstree.jstree(true); + this._jstree.__icon_set_hook = this.#iconSetHook.bind(this); + this._jstree.__icon_check_hook = this.#iconCheckHook.bind(this); + + if (!foldersOnly) + this.#loadContainers(); + } + + #loadContainers() { + if (browser.contextualIdentities) { + browser.contextualIdentities.query({}).then(containers => { + this._containers = containers; + }); + + browser.contextualIdentities.onCreated.addListener(() => { + browser.contextualIdentities.query({}).then(containers => { + this._containers = containers; + }); + }); + + browser.contextualIdentities.onRemoved.addListener(() => { + browser.contextualIdentities.query({}).then(containers => { + this._containers = containers; + }); + }); + } + } + + #iconSetHook(jnode) { + if (jnode.icon.startsWith("var(")) + return jnode.icon; + else if (jnode.icon.startsWith("/")) + return `url("${jnode.icon}")`; + else { + if (o(jnode)?.stored_icon) { + let icon = this.iconCache.get(jnode.icon); + if (icon) + return `url("${icon}")`; + else + return null; + } + else + return `url("${jnode.icon}")`; + } + } + + #iconCheckHook(a_element, jnode) { + if (jnode.__icon_validated || !jnode.icon || (jnode.icon && jnode.icon.startsWith("var(")) + || (jnode.icon && jnode.icon.startsWith("/"))) + return; + + setTimeout(async () => { + if (o(jnode)?.stored_icon) { + const cached = this.iconCache.get(jnode.icon); + const base64Url = cached || (await Icon.get(o(jnode))); + + if (base64Url) { + if (!cached) + this.iconCache.set(jnode.icon, base64Url); + let iconElement = await this.#getIconElement(a_element); + if (iconElement) + iconElement.style.backgroundImage = `url("${base64Url}")`; + } + } + else { + let image = new Image(); + + image.onerror = async e => { + const fallback_icon = "var(--themed-globe-icon)"; + jnode.icon = fallback_icon; + let iconElement = await this.#getIconElement(a_element); + if (iconElement) + iconElement.style.backgroundImage = fallback_icon; + }; + image.src = jnode.icon; + } + }, 0); + + jnode.__icon_validated = true; + } + + #getIconElement(a_element) { + const a_element2 = document.getElementById(a_element.id); + if (a_element2) + return a_element2.childNodes[0]; + else { + return new Promise((resolve, reject) => { + setTimeout(() => { + const a_element2 = document.getElementById(a_element.id); + if (a_element2) { + resolve(a_element2.childNodes[0]); + } + else { + console.error("can't find icon element"); + resolve(null); + } + }, 100); + }) + } + } + + clearIconCache() { + this.iconCache = new Map(); + } + + handleMouseClick(e) { + if (this._foldersOnly) + return; + + if (e.type === "click" && e.target._mousedown_fired) { + e.target._mousedown_fired = false; + return; + } + + if (e.button === undefined || e.button === 0 || e.button === 1) { + e.preventDefault(); + + if (e.type === "mousedown") + e.target._mousedown_fired = true; + + let element = e.target; + + if (element.classList.contains("jstree-ocl")) // expand/collapse arrow icon + return; + + while (element && !$(element).hasClass("jstree-node")) { + element = element.parentNode; + } + + if (e.type === "mousedown" && e.button === 0 && $(e.target).hasClass("jstree-wholerow")) { + let anchor = $(element).find(".jstree-anchor"); + if (anchor.length) + anchor [0]._mousedown_fired = true; + } + if (e.type === "mousedown" && e.button === 1 && $(e.target).hasClass("jstree-anchor")) { + let anchor = $(element).find(".jstree-anchor"); + if (anchor.length) + anchor[0]._mousedown_fired = false; + } + + let node = o(this._jstree.get_node(element.id)); + let clickable = element.getAttribute("data-clickable") || node.__filtering; + + if (clickable && !e.ctrlKey && !e.shiftKey) { + if (node) { + if (settings.open_bookmark_in_active_tab()) { + getActiveTabFromSidebar().then(activeTab => { + activeTab = e.button === 0 && activeTab ? activeTab : undefined; + send.browseNode({node: node, tab: activeTab, preserveHistory: true}); + }); + } + else + send.browseNode({node: node}); + } + } + return false; + } + } + + static _formatNodeTooltip(node) { + return `${node.name}${node.uri? "\x0A" + node.uri: ""}`; + } + + static _styleTODO(node) { + if (node.todo_state) + return " todo-state-" + (node.__overdue + ? "overdue" + : TODO_STATE_NAMES[node.todo_state]?.toLowerCase()); + + return ""; + } + + static _formatTODO(node) { + let text = "
"; + + for (let i = 0; i < node.__path.length; ++i) { + text += node.__path[i]; + + if (i !== node.__path.length - 1) + text += " » " + } + + if (node.todo_date) + text += " | " + "" + node.todo_date + ""; + + if (node.details) + text += " | " + "" + node.details + ""; + + text += "
"; + text += "" + node.name + "
"; + + return text; + } + + static styleFirefoxFolders(node, jnode) { + if (node.external === BROWSER_EXTERNAL_TYPE && node.external_id === FIREFOX_BOOKMARK_MENU) { + jnode.icon = "/icons/bookmarksMenu.svg"; + jnode.li_attr = {"class": "browser-bookmark-menu"}; + node.special_browser_folder = true; + } + else if (node.external === BROWSER_EXTERNAL_TYPE + && (settings.platform.firefox && node.external_id === FIREFOX_BOOKMARK_UNFILED + || settings.platform.chrome && node.external_id === CHROME_BOOKMARK_UNFILED)) { + jnode.icon = "/icons/unfiledBookmarks.svg"; + jnode.li_attr = {"class": "browser-unfiled-bookmarks"}; + node.special_browser_folder = true; + } + else if (node.external === BROWSER_EXTERNAL_TYPE + && (settings.platform.firefox && node.external_id === FIREFOX_BOOKMARK_TOOLBAR + || settings.platform.chrome && node.external_id === CHROME_BOOKMARK_TOOLBAR)) { + jnode.icon = "/icons/bookmarksToolbar.svg"; + jnode.li_attr = {"class": "browser-bookmark-toolbar"}; + if (!settings.show_firefox_toolbar()) + jnode.state = {hidden: true}; + node.special_browser_folder = true; + } + else if (node.external === BROWSER_EXTERNAL_TYPE && node.external_id === FIREFOX_BOOKMARK_MOBILE) { + if (!settings.show_firefox_mobile()) + jnode.state = {hidden: true}; + node.special_browser_folder = true; + } + } + + static toJsTreeNode(node) { + let jnode = {}; + + jnode.id = node.id; + jnode.text = node.name || ""; + jnode.type = node.type; + jnode.icon = node.icon; + jnode.data = node; // store the original Scrapyard node + jnode.parent = node.parent_id; + + if (!jnode.parent) + jnode.parent = "#"; + + if (node.type === NODE_TYPE_SHELF && node.external === BROWSER_EXTERNAL_TYPE) { + jnode.text = formatShelfName(node.name); + jnode.li_attr = {"class": "browser-logo"}; + if (settings.platform.firefox) + jnode.icon = "var(--themed-firefox-icon)"; + else if (settings.platform.chrome) + jnode.icon = "var(--themed-chrome-icon)"; + else + jnode.icon = "/icons/shelf.svg"; + if (!settings.show_firefox_bookmarks()) { + jnode.state = {hidden: true}; + } + BookmarkTree.styleFirefoxFolders(node, jnode); + } + else if (node.type === NODE_TYPE_SHELF && node.external === FILES_EXTERNAL_TYPE) { + jnode.li_attr = {"class": "files-shelf"}; + jnode.icon = "var(--themed-files-box-icon)"; + } + else if (node.type === NODE_TYPE_SHELF && node.external === CLOUD_EXTERNAL_TYPE) { + jnode.text = formatShelfName(node.name); + jnode.li_attr = {"class": "cloud-shelf"}; + jnode.icon = "var(--themed-cloud-icon)"; + } + else if (node.type === NODE_TYPE_SHELF && node.external === RDF_EXTERNAL_TYPE) { + jnode.li_attr = {"class": "rdf-archive"}; + jnode.icon = "/icons/tape.svg"; + } + else if (node.type === NODE_TYPE_SHELF) { + if (node.name && isBuiltInShelf(node.name)) + jnode.text = formatShelfName(node.name); + jnode.icon = "/icons/shelf.svg"; + jnode.li_attr = {"class": "scrapyard-shelf"}; + } + else if (node.type === NODE_TYPE_FOLDER) { + jnode.icon = "/icons/group.svg"; + jnode.li_attr = {class: "scrapyard-group"}; + + if (node.site) { + jnode.li_attr["data-clickable"] = "true"; + jnode.li_attr["class"] += " scrapyard-site" + jnode.icon = "/icons/web.svg"; + } + + BookmarkTree.styleFirefoxFolders(node, jnode); + + if (node.external === FILES_EXTERNAL_TYPE && node.external_id?.startsWith(FILES_EXTERNAL_ROOT_PREFIX)) { + jnode.icon = "/icons/bookmarksMenu.svg"; + jnode.li_attr = {"class": "browser-bookmark-menu"}; + } + } + else if (node.type === NODE_TYPE_SEPARATOR) { + jnode.text = "─".repeat(60); + jnode.icon = false; + jnode.a_attr = { + class: "separator-node" + }; + } + else { + jnode.li_attr = { + class: "show_tooltip", + title: BookmarkTree._formatNodeTooltip(node), + //"data-id": node.id, + "data-clickable": "true" + }; + + jnode.a_attr = { + class: node.has_notes? "has-notes": "" + }; + + if (node.type === NODE_TYPE_ARCHIVE) { + jnode.li_attr.class += " archive-node"; + + if (settings.visually_emphasise_archives()) { + if (settings.visual_archive_icon()) + jnode.a_attr.class += " archive-node-jar"; + + if (settings.visual_archive_color()) + jnode.a_attr.class += " archive-node-color"; + } + } + + if (node.todo_state) { + jnode.a_attr.class += BookmarkTree._styleTODO(node); + + if (node.__extended_todo) { + jnode.li_attr.class += " " + EXTENDED_TODO_CLASS; + jnode.text = BookmarkTree._formatTODO(node); + } + } + + if (node.type === NODE_TYPE_NOTES) + jnode.li_attr.class += " scrapyard-notes"; + + if (!node.icon) { + if (node.type === NODE_TYPE_NOTES) + jnode.icon = "var(--themed-notes-icon)"; + else if (node.type === NODE_TYPE_FILE) + jnode.icon = "var(--themed-file-icon)"; + else if (node.content_type === "application/pdf") + jnode.icon = "var(--themed-pdf-icon)"; + else if (IMAGE_FORMATS.some(f => f === node.content_type)) + jnode.icon = "var(--themed-image-icon)"; + else { + jnode.icon = "var(--themed-globe-icon)"; + jnode.a_attr.class += " " + DEFAULT_ICON_CLASS; + } + } + } + + return jnode; + } + + set data(nodes) { + this._jstree.settings.core.data = nodes; + } + + get data() { + return this._jstree.settings.core.data + } + + get odata() { + return this._jstree.settings.core.data.map(n => n.data); + } + + get stateKey() { + return this._jstree.settings.state.key; + } + + set stateKey(key) { + this._jstree.settings.state.key = key; + } + + get selected() { + return this._jstree.get_selected(true) + } + + getSelectedNodes() { + const selection = this._jstree.get_top_selected().map(id => parseInt(id)); + return this.odata.filter(n => selection.some(id => id === n.id)); + } + + update(nodes, everything = false, clearSelected = false) { + this.data = nodes.map(n => BookmarkTree.toJsTreeNode(n)); + + let state; + + if (/*this._foldersOnly || */everything) { + this._everything = true; + this._jstree.settings.state.key = TREE_STATE_PREFIX + EVERYTHING_SHELF_UUID; + state = JSON.parse(localStorage.getItem(TREE_STATE_PREFIX + EVERYTHING_SHELF_UUID)); + } + else { + this._everything = false; + const shelves = nodes.filter(n => n.type === NODE_TYPE_SHELF); + + if (shelves.length) { + this._jstree.settings.state.key = TREE_STATE_PREFIX + shelves[0].name; + state = JSON.parse(localStorage.getItem(TREE_STATE_PREFIX + shelves[0].name)); + } + } + + this._jstree.refresh(true, () => state? state.state: null); + + if (clearSelected) + this._jstree.deselect_all(true); + } + + // Used to make a flat list in the tree-view (e.g. in search) + list(nodes, stateKey, clearSelected = false) { + if (stateKey) + this.stateKey = TREE_STATE_PREFIX + stateKey; + + this.data = nodes.map(n => BookmarkTree.toJsTreeNode(n)); + this.data.forEach(n => n.parent = "#"); + + this._jstree.refresh(true); + + if (clearSelected) + this._jstree.deselect_all(true); + } + + renameRoot(name) { + let rootNode = this._jstree.get_node(this.odata.find(n => n.type === NODE_TYPE_SHELF)); + this._jstree.rename_node(rootNode, name); + } + + openRoot() { + let rootNode = this._jstree.get_node(this.odata.find(n => n.type === NODE_TYPE_SHELF)); + this._jstree.open_node(rootNode); + this._jstree.deselect_all(true); + } + + setNotesState(nodeId, hasNotes) { + let jnode = this._jstree.get_node(nodeId); + + if (jnode) { + o(jnode).has_notes = hasNotes; + jnode.a_attr.class = jnode.a_attr.class.replace("has-notes", ""); + + if (hasNotes) + jnode.a_attr.class += " has-notes"; + + this._jstree.redraw_node(jnode, false, false, true); + } + } + + setNodeIcon(nodeId, icon) { + let cloudNode = this._jstree.get_node(nodeId); + + if (cloudNode) + this._jstree.set_icon(cloudNode, icon); + } + + createTentativeNode(node) { + node.__tentative = true; + node.id = node.__tentative_id; + let jnode = BookmarkTree.toJsTreeNode(node); + + jnode.a_attr.class += " node-pending"; + return this._jstree.create_node(node.parent_id, jnode, "last"); + } + + updateTentativeNode(node) { + const jnode = this._jstree.get_node(node.__tentative_id); + if (jnode) { + this._jstree.set_id(node.__tentative_id, node.id); + const jnode = this._jstree.get_node(node.id); + + jnode.a_attr.class = jnode.a_attr.class.replace("node-pending", " "); + + node.__tentative = false; + + Object.assign(o(jnode), node); + jnode.original = BookmarkTree.toJsTreeNode(node); + this.data.push(jnode.original); + + if (node.icon && node.stored_icon) { + this.iconCache.set(node.icon, jnode.icon); + jnode.icon = node.icon; + } + else + jnode.icon = jnode.original.icon; + + this._jstree.redraw_node(jnode) + + return true; + } + return false; + } + + removeTentativeNode(node) { + this._jstree.delete_node(node.__tentative_id); + } + + openNode(nodeId) { + let jnode = this._jstree.get_node(nodeId); + this._jstree.open_node(jnode); + } + + selectNode(nodeId, open, forceScroll) { + this._jstree.deselect_all(true); + this._jstree.select_node(nodeId); + + if (Array.isArray(nodeId)) + nodeId = nodeId[0]; + + if (open) + this._jstree.open_node(nodeId); + + let domNode = document.getElementById(nodeId.toString()); + + if (forceScroll) { + domNode.scrollIntoView(); + $(this._elementId).scrollLeft(0); + } + else { + if (!isElementInViewport(domNode)) { + domNode.scrollIntoView(); + $(this._elementId).scrollLeft(0); + } + } + } + + async createNewFolderUnderSelection(id, type) { + let selectedJNode = this.selected?.[0]; + + if (!selectedJNode && type !== NODE_TYPE_SHELF) + return; + + const parent = type === NODE_TYPE_SHELF? "#": selectedJNode; + const title = type === NODE_TYPE_SHELF? "Shelf": "Folder"; + const className = type === NODE_TYPE_SHELF? "scrapyard-shelf": "scrapyard-group"; + const icon = type === NODE_TYPE_SHELF? "/icons/shelf.svg": "/icons/group.svg"; + + let jnode = this._jstree.create_node(parent, { + id: id, + text: `New ${title}`, + type: type, + icon: icon, + li_attr: {"class": className} + }); + + this._jstree.deselect_all(); + this._jstree.select_node(jnode); + + return new Promise((resolve, reject) => { + this._jstree.edit(jnode, null, async (jnode, success, cancelled) => { + if (cancelled) { + this._jstree.delete_node(jnode); + resolve(null); + } + else { + const folder = type === NODE_TYPE_SHELF + ? await send.createShelf({name: jnode.text}) + : await send.createFolder({parent: parseInt(selectedJNode.id), name: jnode.text}); + + if (folder) { + this._jstree.set_id(jnode.id, folder.id); + jnode.original = BookmarkTree.toJsTreeNode(folder); + jnode.data = folder; + this._jstree.rename_node(jnode, folder.name); + //this.reorderNodes(selectedJNode); + resolve(folder); + } + } + }); + }); + } + + adjustBookmarkingTarget(nodeId) { + let jnode = this._jstree.get_node(nodeId); + let odata = this.odata; + + if (o(jnode)?.id === BROWSER_SHELF_ID) { + let unfiled = odata.find(n => n.external_id === FIREFOX_BOOKMARK_UNFILED) + if (unfiled) + jnode = this._jstree.get_node(unfiled.id); + else + jnode = this._jstree.get_node(odata.find(n => n.name === DEFAULT_SHELF_NAME).id); + } + + return o(jnode); + } + + #checkOperation(operation, jnode, jparent, position, more) { + // disable dnd copy + if (operation === "copy_node") { + return false; + } else if (operation === "move_node") { + if (more.ref && more.ref.id == BROWSER_SHELF_ID + || jparent.id == BROWSER_SHELF_ID || jnode.parent == BROWSER_SHELF_ID) + return false; + + if (o(jnode)?.external !== RDF_EXTERNAL_TYPE && o(jparent)?.external === RDF_EXTERNAL_TYPE + || o(jnode)?.external === RDF_EXTERNAL_TYPE + && more.ref && jnode.parent !== "#" && o(more.ref)?.external !== RDF_EXTERNAL_TYPE) + return false; + + if (o(jnode)?.external !== FILES_EXTERNAL_TYPE && o(jparent)?.external === FILES_EXTERNAL_TYPE + || o(jnode)?.external === FILES_EXTERNAL_TYPE + && more.ref && jnode.parent !== "#" && o(more.ref)?.external !== FILES_EXTERNAL_TYPE) + return false; + } + + return true; + } + + async #moveNode(_, data) { + const tree = this._jstree; + const jnode = tree.get_node(data.node); + const jparent = tree.get_node(data.parent); + const destNode = o(jparent); + + if (data.parent != data.old_parent) { + this.startProcessingIndication(); + + try { + + await ExternalStorage.openBatchSession(destNode); + const newNodes = await send.moveNodes({node_ids: [o(jnode).id], dest_id: destNode.id}); + + // keep jstree nodes synchronized with the database + for (let node of newNodes) { + jnode.original = BookmarkTree.toJsTreeNode(node); + + let oldOriginal = this.data.find(d => d.id == node.id); + if (oldOriginal) + this.data[this.data.indexOf(oldOriginal)] = jnode.original; + else + this.data.push(jnode.original); + } + + await this.reorderNodes(jparent); + } + finally { + await ExternalStorage.closeBatchSession(destNode); + this.stopProcessingIndication(); + } + } + else { + if (jnode.li_attr?.class?.includes(EXTENDED_TODO_CLASS)) + await this.reorderNodes(jparent, "todo_pos"); + else + await this.reorderNodes(jparent); + } + } + + async reorderNodes(jparent, posProperty = "pos") { + let jsiblings = jparent.children.map(c => this._jstree.get_node(c)); + + let positions = []; + for (let i = 0; i < jsiblings.length; ++i) { + const sibling = o(jsiblings[i]); + const orderNode = {}; + + orderNode.id = sibling.id; + orderNode.uuid = sibling.uuid; + orderNode.parent_id = sibling.parent_id; + orderNode.external = sibling.external; + orderNode.external_id = sibling.external_id; + sibling[posProperty] = orderNode[posProperty] = i; + positions.push(orderNode); + } + + if (jparent.id === "#" && this._everything) { + await Bookmark.idb.reorder(positions); + const storedShelves = positions.filter(p => !p.external); + await send.reorderNodes({positions: storedShelves}); + } + else + return send.reorderNodes({positions: positions, posProperty}); + } + + contextMenu(ctxJNode) { + const ctxNode = o(ctxJNode); + + if (ctxNode.__tentative) + return null; + + const tree = this._jstree; + const lightTheme = getThemeVar("--theme-background").trim() === "white"; + + let selectedNodes = tree.get_selected(true) || []; + const multiselect = selectedNodes.length > 1; + + const setTODOState = async state => { + let selectedIds = selectedNodes.map(n => o(n).type === NODE_TYPE_FOLDER || o(n).type === NODE_TYPE_SHELF + ? n.children + : o(n).id); + let nodes = []; + let changedNodes = selectedIds.flat().map(id => tree.get_node(id)); + + selectedIds = changedNodes.filter(n => isContentNode(o(n))).map(n => parseInt(n.id)); + + selectedNodes = changedNodes.filter(n => selectedIds.some(id => id === o(n).id)).map(n => o(n)); + + // a minimal set of attributes compatible with marshalling + selectedNodes.forEach(n => nodes.push({id: n.id, parent_id: n.parent_id, name: n.name, uuid: n.uuid, + external: n.external, external_id: n.external_id, todo_state: state, todo_pos: state? n.todo_pos: undefined})); + + selectedIds.forEach(id => { + let jnode = tree.get_node(id); + o(jnode).todo_state = state; + jnode.a_attr.class = jnode.a_attr.class.replace(/todo-state-[a-zA-Z]+/g, ""); + jnode.a_attr.class += BookmarkTree._styleTODO(o(jnode)); + jnode.text = jnode.text.replace(/todo-state-[a-zA-Z]+/g, jnode.a_attr.class); + tree.redraw_node(jnode, true, false, true); + }); + + this.startProcessingIndication(); + + try { + await send.setTODOState({nodes}); + } + finally { + this.stopProcessingIndication(); + } + } + + let containers = this._containers || []; + let containersSubmenu = {}; + + for (let container of containers) { + containersSubmenu[container.cookieStoreId] = { + label: container.name, + __container_id: container.cookieStoreId, + _istyle: `mask-image: url("${container.iconUrl}"); mask-size: 16px 16px; ` + + `mask-repeat: no-repeat; mask-position: center; background-color: ${container.colorCode};`, + action: async obj => { + if (ctxNode.type === NODE_TYPE_SHELF || ctxNode.type === NODE_TYPE_FOLDER) { + let children = this.odata.filter(n => ctxJNode.children.some(id => id == n.id) && isContentNode(n)); + children = children.filter(c => c.type !== NODE_TYPE_NOTES); + children.forEach(c => c.type = NODE_TYPE_BOOKMARK); + children.sort(byPosition); + + for (let node of children) { + await send.browseNode({node, container: obj.item.__container_id}); + } + } + else { + for (let n of selectedNodes) { + let node = o(n); + if (!isContentNode(node) || !node.uri) + continue; + node.type = NODE_TYPE_BOOKMARK; + await send.browseNode({node, container: obj.item.__container_id}); + } + } + } + } + } + + let items = { + locateItem: { + label: "Locate", + action: async () => { + this.sidebarSelectNode(ctxNode); + } + }, + archiveItem: { + label: "Archive", + separator_before: ctxNode.__filtering, + action: async () => { + send.archiveBookmarks({nodes: selectedNodes.map(n => o(n))}); + } + }, + copyLinkItem: { + label: "Copy Link", + separator_before: ctxNode.__filtering && ctxNode.type !== NODE_TYPE_BOOKMARK, + action: () => navigator.clipboard.writeText(ctxNode.uri) + }, + openItem: { + label: "Open", + separator_before: ctxNode.__filtering, + action: async () => { + for (let jnode of selectedNodes) + await send.browseNode({node: o(jnode)}); + } + }, + openWithEditorItem: { + label: "Edit", + action: async () => { + send.openWithEditor({node: ctxNode}); + } + }, + openNotesItem: { + label: "Open Notes", + action: () => { + send.browseNotes({uuid: ctxNode.uuid}); + } + }, + openOriginalItem: { + label: "Open Original URL", + action: async () => { + let url = ctxNode.uri; + + if (url) + openContainerTab(url, ctxNode.container); + } + }, + openAllItem: { + label: "Open All", + separator_before: ctxNode.__filtering && ctxNode.type, + action: async () => { + let children = this.odata.filter(n => ctxJNode.children.some(id => id == n.id) && isContentNode(n)); + children.sort(byPosition); + + for (let node of children) + await send.browseNode({node: node}); + } + }, + openInContainerItem: { + label: "Open in Container", + submenu: containersSubmenu + }, + sortItem: { + label: "Sort by Name", + action: () => { + let jchildren = ctxJNode.children.map(c => tree.get_node(c)); + jchildren.sort((a, b) => a.text.localeCompare(b.text)); + jchildren.sort((a, b) => byContainer(o(a), o(b))); + ctxJNode.children = jchildren.map(c => c.id); + + tree.redraw_node(ctxJNode, true, false, true); + this.reorderNodes(ctxJNode); + } + }, + addFilesDirectoryItem: { + label: "Add directory", + action: async () => { + const options = await this.addFilesDirectory(); + + if (options?.path) { + options.title = options.title || "Untitled"; + + this.startProcessingIndication(); + + try { + return send.addFilesDirectory({options}); + } + finally { + this.stopProcessingIndication(); + } + } + } + }, + newItem: { + label: "New", + separator_before: true, + submenu: { + newFolderItem: { + label: "Folder", + icon: `/icons/group${lightTheme? "": "2"}.svg`, + action: async () => { + let folder = {id: Bookmark.setTentativeId({}), type: NODE_TYPE_FOLDER, name: "New Folder", + parent_id: ctxNode.id}; + const folderPending = send.createFolder({parent: ctxNode, name: folder.name}); + + let jfolder = BookmarkTree.toJsTreeNode(folder); + tree.deselect_all(true); + + let folderJNode = tree.get_node(tree.create_node(ctxJNode, jfolder, 0)); + tree.select_node(folderJNode); + + tree.edit(folderJNode, null, async (jnode, success, cancelled) => { + this.startProcessingIndication(); + folder = await folderPending; + tree.set_id(folderJNode.id, folder.id); + + if (success && !cancelled && jnode.text) + folder = await send.renameFolder({id: folder.id, name: jnode.text}); + + tree.rename_node(jnode, folder.name); + Object.assign(o(jnode), folder); + jnode.original = BookmarkTree.toJsTreeNode(folder); + await this.reorderNodes(ctxJNode); + + this.stopProcessingIndication(); + }); + } + }, + newSiblingFolderItem: { + label: "Sibling Folder", + icon: `/icons/group${lightTheme? "": "2"}.svg`, + action: async () => { + let jparent = tree.get_node(ctxJNode.parent); + let position = $.inArray(ctxJNode.id, jparent.children); + + let folder = {id: Bookmark.setTentativeId({}), type: NODE_TYPE_FOLDER, name: "New Folder", + parent_id: o(jparent).id}; + const folderPending = send.createFolder({parent: o(jparent), name: folder.name}); + + let jfolder = BookmarkTree.toJsTreeNode(folder); + tree.deselect_all(true); + + let folderJNode = tree.get_node(tree.create_node(jparent, jfolder, position + 1)); + tree.select_node(folderJNode); + + tree.edit(folderJNode, null, async (jnode, success, cancelled) => { + this.startProcessingIndication(); + folder = await folderPending; + tree.set_id(folderJNode.id, folder.id); + + if (success && !cancelled && jnode.text) + folder = await send.renameFolder({id: folder.id, name: jnode.text}); + + tree.rename_node(jnode, folder.name); + Object.assign(o(jnode), folder); + jnode.original = BookmarkTree.toJsTreeNode(folder); + await this.reorderNodes(jparent); + + this.stopProcessingIndication(); + }); + } + }, + newBookmarkItem: { + label: "Bookmark", + icon: `/icons/globe${lightTheme? "": "2"}.svg`, + action: async () => { + const options = await showDlg("prompt", {caption: "New Bookmark", label: "URL:"}); + if (options && options.title) + return createBookmarkFromURL(options.title, ctxNode.id); + } + }, + newNotesItem: { + label: "Notes", + icon: `/icons/notes${lightTheme? "": "2"}.svg`, + action: async () => { + if (isContentNode(ctxNode)) { + send.browseNotes({uuid: ctxNode.uuid}); + return; + } + + let notes = {id: Bookmark.setTentativeId({}), parent_id: ctxNode.id, name: "New Notes", + type: NODE_TYPE_NOTES}; + const notesPending = send.addNotes({name: notes.name, parent_id: notes.parent_id}); + + let jnotes = BookmarkTree.toJsTreeNode(notes); + tree.deselect_all(true); + + let notesNode = tree.get_node(tree.create_node(ctxJNode, jnotes)); + tree.select_node(notesNode); + + tree.edit(notesNode, null, async (jnode, success, cancelled) => { + this.startProcessingIndication(); + notes = await notesPending; + tree.set_id(notesNode.id, notes.id); + + if (success && !cancelled && jnode.text) { + notes.name = jnode.text; + notes = await send.updateBookmark({node: notes}); + } + + Object.assign(o(jnode), notes); + jnode.original = BookmarkTree.toJsTreeNode(notes); + this.data.push(jnode.original); + + this.stopProcessingIndication(); + }); + } + }, + newSeparatorItem: { + label: "Separator Below", + icon: `/icons/separator${lightTheme? "": "2"}.svg`, + action: async () => { + const jparent = tree.get_node(ctxJNode.parent); + const position = $.inArray(ctxJNode.id, jparent.children); + let separator = {id: Bookmark.setTentativeId({}), type: NODE_TYPE_SEPARATOR, + parent_id: o(jparent).id}; + + const jnode = BookmarkTree.toJsTreeNode(separator); + const separatorJNode = tree.get_node(tree.create_node(jparent, jnode, position + 1)); + + separator = await send.addSeparator({parent_id: o(jparent).id}); + tree.set_id(separatorJNode.id, separator.id); + Object.assign(o(separatorJNode), separator); + this.reorderNodes(jparent); + } + }, + } + }, + cutItem: { + separator_before: true, + label: "Cut", + _disabled: selectedNodes.some(n => o(n).type === NODE_TYPE_SHELF), + action: () => tree.cut(selectedNodes) + }, + copyItem: { + label: "Copy", + _disabled: selectedNodes.some(n => o(n).type === NODE_TYPE_SHELF), + action: () => tree.copy(selectedNodes) + }, + pasteItem: { + label: "Paste", + separator_before: ctxNode.type === NODE_TYPE_SHELF || ctxNode.parent_id == BROWSER_SHELF_ID, + _disabled: !(tree.can_paste() && isContainerNode(ctxNode)), + action: async () => { + let buffer = tree.get_buffer(); + let selection = Array.isArray(buffer.node)? buffer.node.map(n => o(n)): [o(buffer.node)]; + selection.sort(byPosition); + selection = selection.map(n => n.id); + + this.startProcessingIndication(); + + try { + let newNodes; + + await ExternalStorage.openBatchSession(ctxNode); + + if (buffer.mode === "copy_node") + newNodes = await send.copyNodes({node_ids: selection, dest_id: ctxNode.id}); + else { + newNodes = await send.moveNodes({node_ids: selection, dest_id: ctxNode.id}); + for (let s of selection) + tree.delete_node(s); + } + + for (let newNode of newNodes) { + let jparent = tree.get_node(newNode.parent_id); + let jnode = BookmarkTree.toJsTreeNode(newNode); + tree.create_node(jparent, jnode, "last"); + + let sourceNode = this.data.find(treeNode => treeNode.id == newNode.id); + if (sourceNode) + this.data[this.data.indexOf(sourceNode)] = jnode; + else + this.data.push(jnode); + } + + await this.reorderNodes(ctxJNode); + tree.clear_buffer(); + } + catch (e) { + console.error(e) + } + + finally { + await ExternalStorage.closeBatchSession(ctxNode); + this.stopProcessingIndication(); + } + } + }, + shareItem: { + label: "Share", + separator_before: true, + submenu: { + cloudItem: { + label: "Cloud", + icon: (lightTheme? "/icons/cloud.png": "/icons/cloud2.png"), + _disabled: !settings.cloud_enabled() || !cloudShelf.isAuthenticated(), + action: async () => { + this.startProcessingIndication(true); + let selectedIds = selectedNodes.map(n => o(n).id); + try { + await send.shareToCloud({node_ids: selectedIds}) + } + finally { + this.stopProcessingIndication(); + } + } + }, + pocketItem: { + label: "Pocket", + icon: "/icons/pocket.svg", + action: async () => { + if (selectedNodes) + await send.shareToPocket({nodes: selectedNodes.map(n => o(n))}); + } + }, + dropboxItem: { + label: "Dropbox", + icon: "/icons/dropbox.png", + action: async () => { + if (selectedNodes) + await send.shareToDropbox({nodes: selectedNodes.map(n => o(n))}); + } + }, + oneDriveItem: { + label: "OneDrive", + icon: "/icons/onedrive.png", + action: async () => { + if (selectedNodes) + await send.shareToOneDrive({nodes: selectedNodes.map(n => o(n))}); + } + } + } + }, + todoItem: { + separator_before: true, + label: "TODO", + submenu: { + todoItem: { + label: "TODO", + icon: "/icons/todo.svg", + action: () => { + setTODOState(TODO_STATE_TODO); + } + }, + waitingItem: { + label: "WAITING", + icon: "/icons/waiting.svg", + action: () => { + setTODOState(TODO_STATE_WAITING); + } + }, + postponedItem: { + label: "POSTPONED", + icon: "/icons/postponed.svg", + action: () => { + setTODOState(TODO_STATE_POSTPONED); + } + }, + cancelledItem: { + label: "CANCELLED", + icon: "/icons/cancelled.svg", + action: () => { + setTODOState(TODO_STATE_CANCELLED); + } + }, + doneItem: { + label: "DONE", + icon: "/icons/done.svg", + action: () => { + setTODOState(TODO_STATE_DONE); + } + }, + clearItem: { + separator_before: true, + label: "Clear", + action: () => { + setTODOState(undefined); + } + } + } + }, + checkLinksItem: { + separator_before: true, + label: "Check Links...", + action: async () => { + await settings.load(); + let query = `?menu=true&repairIcons=${settings.repair_icons()}&scope=${ctxNode.id}`; + openPage(`/ui/options.html${query}#checklinks`); + } + }, + uploadItem: { + label: "Upload...", + action: async () => { + const options = await showDlg("prompt", {caption: "Upload File", label: "File path:"}); + + if (options?.title) + send.uploadFiles({parent_id: ctxNode.id, file_name: options.title}); + } + }, + exportItem: { + label: "Export...", + action: async () => this.performExport(ctxNode) + }, + deleteItem: { + separator_before: true, + _disabled: !this._everything && multiselect && selectedNodes.some(n => o(n).type === NODE_TYPE_SHELF), + label: "Delete", + action: async () => { + if (ctxNode.type === NODE_TYPE_SHELF) { + if (selectedNodes.map(n => o(n)).some(n => isBuiltInShelf(n.name))) { + showNotification({message: "A built-in shelf could not be deleted."}); + return; + } + + const verb = ctxNode.external === RDF_EXTERNAL_TYPE? "close": "delete"; + + if (await confirm("Warning", `Do you really want to ${verb} '${ctxNode.name}'?`)) { + this.startProcessingIndication(); + + let selectedIds = selectedNodes.map(n => o(n).id); + + try { + await send.softDeleteNodes({node_ids: selectedIds}); + + tree.delete_node(selectedNodes); + this.onDeleteShelf(selectedIds); + + await setBookmarkedActionIcon(); + } + finally { + this.stopProcessingIndication(); + } + } + } + else { + if (await confirm("Warning", "Do you really want to delete the selected items?")) { + this.startProcessingIndication(); + + try { + await send.softDeleteNodes({node_ids: selectedNodes.map(n => o(n).id)}); + tree.delete_node(selectedNodes); + + await setBookmarkedActionIcon(); + } + finally { + this.stopProcessingIndication(); + } + } + } + } + }, + propertiesItem: { + separator_before: true, + label: "Properties...", + action: async () => { + if (isContentNode(ctxNode)) { + let properties = await Node.get(ctxNode.id); + + if (properties.has_comments) + properties.comments = await Comments.get(properties); + else + properties.comments = ""; + + if (properties.icon || properties.stored_icon) { + if (properties.stored_icon) + properties.displayed_icon = await Icon.get(properties); + else + properties.displayed_icon = properties.icon; + + properties.user_icon = properties.displayed_icon; + } + else { + properties.displayed_icon = ""; + properties.user_icon = ""; + } + + let hasComments = !!properties.comments; + + properties.containers = this._containers; + + const originalUUID = properties.uuid; + const originalDateAdded = properties.date_added; + if (ctxNode.external === RDF_EXTERNAL_TYPE) { + properties.uuid = properties.external_id; + properties.date_added = UUID.getDate(properties.external_id); + } + + let newProperties = await showDlg("properties", properties); + + if (newProperties) { + delete properties.containers; + delete properties.uuid; + + Object.assign(properties, newProperties); + + if (ctxNode.external === RDF_EXTERNAL_TYPE) { + properties.uuid = originalUUID; + properties.date_added = originalDateAdded; + } + + this.startProcessingIndication(); + + properties.has_comments = !!properties.comments; + + if (hasComments || properties.has_comments) + await Bookmark.storeComments(properties.id, properties.comments); + + delete properties.comments; + + let newIcon; + if (properties.user_icon === "") { + properties.icon = undefined; + properties.stored_icon = undefined; + ctxJNode.icon = BookmarkTree.toJsTreeNode(ctxNode).icon; + } + else if (properties.user_icon && properties.user_icon !== properties.displayed_icon) + newIcon = properties.user_icon; + + Bookmark.clean(properties); + properties = await send.updateBookmark({node: properties}); + + if (newIcon) { + properties.icon = newIcon; + await Bookmark.storeIcon(properties); + + if (ctxJNode.a_attr.class) + ctxJNode.a_attr.class = ctxJNode.a_attr.class.replace(DEFAULT_ICON_CLASS, ""); + + tree.set_icon(ctxJNode, newIcon); + } + + this.stopProcessingIndication(); + + let live_data = this.data.find(n => n.id == properties.id); + Object.assign(ctxNode, properties); + Object.assign(live_data, BookmarkTree.toJsTreeNode(ctxNode)); + + if (!ctxNode.__extended_todo) + tree.rename_node(ctxJNode, properties.name); + else + tree.rename_node(ctxJNode, BookmarkTree._formatTODO(ctxNode)); + + tree.redraw_node(ctxJNode, true, false, true); + + $("#" + properties.id).prop('title', BookmarkTree._formatNodeTooltip(properties)); + } + } + } + }, + renameItem: { + label: "Rename", + action: async () => { + const node = ctxNode; + switch (node.type) { + case NODE_TYPE_SHELF: + const ERROR_MESSAGE = "A built-in shelf could not be renamed."; + if (isBuiltInShelf(node.name)) { + showNotification({message: ERROR_MESSAGE}); + return; + } + + tree.edit(node.id, null, async (jnode, success, cancelled) => { + if (success && !cancelled) { + if (isBuiltInShelf(jnode.text)) { + tree.rename_node(jnode.id, node.name); + showNotification({message: ERROR_MESSAGE}); + return; + } + + this.startProcessingIndication(); + await send.renameFolder({id: node.id, name: jnode.text}) + this.stopProcessingIndication(); + node.name = ctxJNode.original.text = jnode.text; + tree.rename_node(jnode.id, jnode.text); + this.onRenameShelf(node); + } + }); + break; + case NODE_TYPE_FOLDER: + tree.edit(ctxJNode, null, async (jnode, success, cancelled) => { + if (success && !cancelled) { + this.startProcessingIndication(); + const folder = await send.renameFolder({id: node.id, name: jnode.text}); + this.stopProcessingIndication(); + node.name = ctxJNode.original.text = folder.name; + tree.rename_node(ctxJNode, folder.name); + } + }); + break; + } + } + }, + rdfPathItem: { + separator_before: true, + label: "RDF Directory...", + action: async () => { + const options = await showDlg("prompt", {caption: "RDF Directory", label: "Path:", + title: ctxNode.uri}); + if (options) { + let node = await Node.get(ctxNode.id); + ctxNode.uri = node.uri = options.title; + await Node.update(node); + } + } + }, + debugItem: { + separator_before: true, + label: "Debug", + submenu: { + printObjectItem: { + label: "Print object", + action: async () => { + console.log(ctxNode); + } + }, + printStubItem: { + label: "Print update stub", + action: async () => { + const stub = `var Node = (await import("./storage_entities.js")).Node;\n` + + `var node = await Node.get(${ctxNode.id});\n` + + `node.xyz = ...;\n` + + `Node.update(node);` + console.log(stub); + } + }, + + } + }, + }; + + switch (ctxNode.type) { + case NODE_TYPE_SHELF: + delete items.archiveItem; + delete items.cutItem; + delete items.copyItem; + delete items.shareItem; + delete items.newItem.submenu.newSeparatorItem; + delete items.newItem.submenu.newSiblingFolderItem; + if (ctxNode.id === BROWSER_SHELF_ID) { + items = {}; + } + if (ctxNode.external !== RDF_EXTERNAL_TYPE) { + delete items.rdfPathItem; + } + case NODE_TYPE_FOLDER: + //delete items.newSeparatorItem; + delete items.openOriginalItem; + delete items.propertiesItem; + delete items.copyLinkItem; + //delete items.shareItem; + if (items.shareItem) { + delete items.shareItem.submenu.pocketItem; + delete items.shareItem.submenu.dropboxItem; + delete items.shareItem.submenu.oneDriveItem; + } + if (ctxNode.type === NODE_TYPE_FOLDER) + delete items.rdfPathItem; + if (ctxNode.external && ctxNode.external !== CLOUD_EXTERNAL_TYPE) + delete items.newItem.submenu.newNotesItem; + if (ctxNode.special_browser_folder) { + delete items.cutItem; + delete items.copyItem; + delete items.renameItem; + delete items.deleteItem; + delete items.newItem.submenu.newSeparatorItem; + delete items.newItem.submenu.newSiblingFolderItem; + } + if (ctxNode.external === RDF_EXTERNAL_TYPE) { + delete items.cutItem; + delete items.copyItem; + delete items.pasteItem; + } + break; + case NODE_TYPE_NOTES: + case NODE_TYPE_FILE: + delete items.shareItem.submenu.pocketItem; + delete items.newItem.submenu.newNotesItem; + delete items.openInContainerItem; + delete items.copyLinkItem; + delete items.pasteItem; + case NODE_TYPE_BOOKMARK: + delete items.openOriginalItem; + case NODE_TYPE_ARCHIVE: + delete items.sortItem; + delete items.openAllItem; + delete items.newItem.submenu.newFolderItem; + delete items.renameItem; + delete items.rdfPathItem; + delete items.checkLinksItem; + delete items.uploadItem; + delete items.exportItem; + if (ctxNode.external === RDF_EXTERNAL_TYPE) { + delete items.cutItem; + delete items.copyItem; + delete items.pasteItem; + delete items.shareItem.submenu.cloudItem; + delete items.shareItem.submenu.dropboxItem; + delete items.shareItem.submenu.oneDriveItem; + } + break; + } + + if (ctxNode.type !== NODE_TYPE_BOOKMARK) + delete items.archiveItem; + + if (ctxNode.type === NODE_TYPE_SEPARATOR) { + const deleteItem = items.deleteItem; + + items.newSiblingFolderItem = items.newItem.submenu.newSiblingFolderItem; + items.newSiblingFolderItem.icon = undefined; + items.newSiblingFolderItem.label = "New Sibling Folder"; + + for (let k in items) + if (!["newSiblingFolderItem"].find(s => s === k)) + delete items[k]; + + items.deleteItem = deleteItem; + } + + if (selectedNodes.length < 2) { + delete items.openItem; + } + + if (isContentNode(ctxNode)) { + delete items.newItem.submenu.newBookmarkItem; + + if (!ctxNode.has_notes || ctxNode.type === NODE_TYPE_NOTES) { + delete items.openNotesItem; + if (items.newItem.submenu.newNotesItem) + items.newItem.submenu.newNotesItem.label = "Attached Notes"; + } + else if (ctxNode.has_notes) { + delete items.newItem.submenu.newNotesItem; + } + } + else { + delete items.openNotesItem; + } + + if (ctxNode.__extended_todo) { + delete items.newItem.submenu.newSeparatorItem; + delete items.newItem.submenu.newSiblingFolderItem; + } + + if (!(ctxNode.__filtering || ctxNode.__extended_todo)) { + delete items.locateItem; + } + + if (multiselect) { + items["newItem"] && (items["newItem"]._disabled = true); + items["sortItem"] && (items["sortItem"]._disabled = true); + items["uploadItem"] && (items["uploadItem"]._disabled = true); + items["exportItem"] && (items["exportItem"]._disabled = true); + items["renameItem"] && (items["renameItem"]._disabled = true); + items["copyLinkItem"] && (items["copyLinkItem"]._disabled = true); + items["openNotesItem"] && (items["openNotesItem"]._disabled = true); + items["propertiesItem"] && (items["propertiesItem"]._disabled = true); + items["checkLinksItem"] && (items["checkLinksItem"]._disabled = true); + items["openOriginalItem"] && (items["openOriginalItem"]._disabled = true); + } + + if (!settings.debug_mode()) + delete items.debugItem; + + if (!browser.contextualIdentities) + delete items.openInContainerItem; + + if (ctxNode.external === BROWSER_EXTERNAL_TYPE || ctxNode.external === RDF_EXTERNAL_TYPE) { + delete items.newItem.submenu.newNotesItem; + + if (ctxNode.external === RDF_EXTERNAL_TYPE) { + delete items.exportItem; + } + } + + if (ctxNode.external === RDF_EXTERNAL_TYPE && ctxNode.type === NODE_TYPE_SHELF) { + items.deleteItem.label = "Close"; + } + + if (ctxNode.type === NODE_TYPE_SHELF && ctxNode.external === FILES_EXTERNAL_TYPE) { + for (let k in items) + if (!["addFilesDirectoryItem"].find(s => s === k)) + delete items[k]; + } + else { + delete items.addFilesDirectoryItem; + } + + if (ctxNode.external === FILES_EXTERNAL_TYPE) { + delete items.cutItem; + delete items.pasteItem; + delete items.newItem; + delete items.newItem; + delete items.uploadItem; + delete items.checkLinksItem; + delete items.openInContainerItem; + + if (ctxNode.type === NODE_TYPE_FILE) { + delete items.shareItem.submenu.cloudItem; + delete items.openWithEditorItem; + delete items.copyItem; + } + + if (items.copyItem) + items.copyItem.separator_before = true; + + if (!ctxNode.external_id?.startsWith(FILES_EXTERNAL_ROOT_PREFIX)) + delete items.deleteItem; + + if (isContainerNode(ctxNode)) + delete items.openWithEditorItem; + } + else { + delete items.openWithEditorItem; + } + + return items; + } +} + + +export {BookmarkTree}; diff --git a/addon/utils.js b/addon/utils.js new file mode 100644 index 00000000..0dc54b76 --- /dev/null +++ b/addon/utils.js @@ -0,0 +1,434 @@ +import {send, sendLocal} from "./proxy.js"; + +export function capitalize(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +export function camelCaseToSnakeCase(str) { + return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1_$2').toUpperCase(); +} + +export function snakeCaseToCamelCase(str) { + return str.toLowerCase() + .replace(/(_)([a-z])/g, (_match, _p1, p2) => p2.toUpperCase()) +} + +export function merge(to, from) { + for (const [k, v] of Object.entries(from)) { + if (!to.hasOwnProperty(k)) + to[k] = v; + } + return to; +} + +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function partition(items, size) { + const result = []; + + if (size) { + const n = Math.round(items.length / size); + + while (items.length > 0) + result.push(items.splice(0, n)); + + if (result.length > size) { + result[result.length - 2] = [...result[result.length - 2], ...result[result.length - 1]]; + result.splice(result.length - 1, 1); + } + } + + return result; +} + +export function chunk(items, size) { + const chunks = []; + let i = 0; + + while (i < items.length) + chunks.push(items.slice(i, i += size)); + + return chunks; +} + +export function makeReferenceURL(uuid) { + let referenceURL = `ext+scrapyard://${uuid}`; + + if (!_BACKGROUND_PAGE) + referenceURL = browser.runtime.getURL(`/reference.html#${referenceURL}`); + + return referenceURL; +} + +export function pathToNameExt(fullPath) { + + let startIndex = (fullPath.indexOf('\\') >= 0 ? fullPath.lastIndexOf('\\') : fullPath.lastIndexOf('/')); + let dotIndex = fullPath.lastIndexOf('.'); + let file_name = fullPath.substring(startIndex, dotIndex); + let file_ext = fullPath.substring(dotIndex + 1); + + if (file_name.indexOf('\\') === 0 || file_name.indexOf('/') === 0) { + file_name = file_name.substring(1); + } + + return {name: file_name, ext: file_ext}; +} + +export async function computeSHA1(text) { + return hexString(await crypto.subtle.digest("SHA-1", new TextEncoder().encode(text))); +} + +export function hexString(buffer) { + const byteArray = new Uint8Array(buffer); + + const hexCodes = [...byteArray].map(value => { + const hexCode = value.toString(16); + return hexCode.padStart(2, '0'); + }); + + return hexCodes.join(''); +} + +// Extracting signature: + +// contentType = ""; +// binaryString = ""; +// let signature = []; +// for (i = 0; i < byteArray.byteLength; i++) { +// if (i < 4) +// signature.push(byteArray[i].toString(16)); +// binaryString += String.fromCharCode(byteArray[i]); +// } +// +// signature = signature.join("").toUpperCase(); +// contentType = getMimetype(signature); + +export function getMimetype (signature) { + switch (signature) { + case '89504E47': + return 'image/png'; + case '47494638': + return 'image/gif'; + case '25504446': + return 'application/pdf'; + case 'FFD8FFDB': + case 'FFD8FFE0': + return 'image/jpeg'; + case '504B0304': + return 'application/zip'; + case '3C737667': + return 'image/svg+xml'; + default: + return null; + } +} + +export function getMimetypeByExt(url) { + if (!url) + return null; + + url = url.toLowerCase(); + + if (url.endsWith(".png")) + return "image/png"; + else if (url.endsWith(".bmp")) + return "image/bmp"; + else if (url.endsWith(".gif")) + return "image/gif"; + else if (url.endsWith(".tif") || url.endsWith(".tiff")) + return "image/tiff"; + else if (url.endsWith(".jpg") || url.endsWith(".jpeg")) + return "image/jpeg"; + else if (url.endsWith(".ico")) + return "image/x-icon"; + else if (url.endsWith(".svg")) + return "image/svg+xml"; + else if (url.endsWith(".webp")) + return "image/webp"; + else if (url.endsWith(".webm")) + return "video/webm"; + else if (url.endsWith(".mp4") || url.endsWith(".m4v")) + return "video/mp4"; + else if (url.endsWith(".mpeg")) + return "video/mpeg"; + else if (url.endsWith(".avi")) + return "video/x-msvideo"; + else if (url.endsWith(".pdf")) + return "application/pdf"; + else if (url.endsWith(".html") || url.endsWith(".htm")) + return "text/html"; + else if (url.endsWith(".txt")) + return "text/plain"; + else + return "application/octet-stream"; +} + +export const IMAGE_FORMATS = [ + "image/png", + "image/bmp", + "image/gif", + "image/tiff", + "image/jpeg", + "image/x-icon", + "image/webp", + "image/svg+xml" +]; + +export const CONTENT_TYPE_TO_EXT = { + "text/html": "html", + "application/pdf": "pdf", + "text/plain": "txt", + "image/png": "png", + "image/bmp": "bmp", + "image/gif": "gif", + "image/tiff": "tiff", + "image/jpeg": "jpg", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", + "image/webp": "webp", + "image/svg+xml": "svg" +}; + +// https://stackoverflow.com/a/34920444/1689848 +export function stringByteLengthUTF8(s) { + if (!s) + return 0; + + //assuming the String is UCS-2(aka UTF-16) encoded + var n = 0; + for (var i = 0, l = s.length; i < l; i++) { + var hi = s.charCodeAt(i); + if (hi < 0x0080) { //[0x0000, 0x007F] + n += 1; + } + else if (hi < 0x0800) { //[0x0080, 0x07FF] + n += 2; + } + else if (hi < 0xD800) { //[0x0800, 0xD7FF] + n += 3; + } + else if (hi < 0xDC00) { //[0xD800, 0xDBFF] + var lo = s.charCodeAt(++i); + if (i < l && lo >= 0xDC00 && lo <= 0xDFFF) { //followed by [0xDC00, 0xDFFF] + n += 4; + } + } + else { //[0xE000, 0xFFFF] + n += 3; + } + } + return n; +} + +// // https://stackoverflow.com/a/18650828/1689848 +// export function formatBytes(bytes, decimals = 2) { +// if (bytes === 0) return '0 Bytes'; +// +// const k = 1024; +// const dm = decimals < 0 ? 0 : decimals; +// const sizes = ['Bytes', 'KB', 'MB', 'GB']; // :) +// +// const i = Math.floor(Math.log(bytes) / Math.log(k)); +// +// return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +// } + +export function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + let size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm)).toString(); + + let [int, dec] = size.split("."); + + if (int.length > 2) + size = Math.round(parseFloat(size)); + else if (dec && int.length === 2) + size = Math.round((parseFloat(size) * 10)) / 10; + + return size + ' ' + sizes[i]; +} + +export function toHHMMSS (msecs) { + let sec_num = Math.floor(msecs / 1000); + let hours = Math.floor(sec_num / 3600); + let minutes = Math.floor(sec_num / 60) % 60; + let seconds = sec_num % 60; + + return [hours,minutes,seconds] + .map(v => v < 10 ? "0" + v : v) + .filter((v,i) => v !== "00" || i > 0) + .join(":"); +} + +export function cleanObject(object, forUpdate) { + for (let key of Object.keys(object)) { + if (object[key] === 0) + continue; + + if (!object[key]) + if (forUpdate) + object[key] = undefined; + else + delete object[key]; + } + + return object; +} + +export function isDeepEqual(object1, object2, verbose) { + object1 = {...object1}; + object2 = {...object2}; + + let objKeys1 = Object.keys(object1); + let objKeys2 = Object.keys(object2); + + for (const key of objKeys1) + if (object1[key] === undefined) + delete object1[key]; + + for (const key of objKeys2) + if (object2[key] === undefined) + delete object2[key]; + + objKeys1 = Object.keys(object1); + objKeys2 = Object.keys(object2); + + if (objKeys1.length !== objKeys2.length) { + if (verbose) { + console.log("Missing keys:"); + for (const key of [...objKeys1]) + if (key in object2) { + delete object1[key]; + delete object2[key]; + } + + console.log([...Object.keys(object1), ...Object.keys(object2)]) + } + return false; + } + + for (const key of objKeys1) { + const value1 = object1[key]; + const value2 = object2[key]; + + const isObjects = isObject(value1) && isObject(value2); + + if ((isObjects && !isDeepEqual(value1, value2)) || (!isObjects && value1 !== value2)) { + if (verbose) + console.log("Deep unequal key: %s", key); + + return false; + } + } + return true; +} + +function isObject(object) { + return object != null && typeof object === "object"; +} + +export class ProgressCounter { + constructor(total, message, payload = {}, local) { + this._total = total; + this._last = total - 1; + this._message = message; + this._payload = payload; + this._lastProgress = 0; + this._counter = 0; + this._sender = local? sendLocal: send; + } + + increment() { + this._counter += 1 + } + + notify(progress, payload={}) { + if (!progress) { + progress = Math.round((this._counter / this._total) * 100); + if (progress !== this._lastProgress) { + this._lastProgress = progress; + this._sender[this._message](Object.assign({progress}, this._payload, payload)); + } + } + else if (progress) { + this._sender[this._message](Object.assign({progress}, this._payload, payload)); + } + } + + incrementAndNotify(payload={}) { + this.increment(); + this.notify(null, payload); + } + + finish() { + this.notify(100, {finished: true}); + } + + isFinished() { + return this._counter === this._total; + } + + isLast() { + return this._counter === this._last; + } +} + +export class ParallelProcessor { + #errors; + #cancelled; + #progressCounter; + #threadCount; + #processorf; + #resolveResult; + + constructor(processorf) { + this.#processorf = processorf; + } + + async process(items, maxThreads, message) { + items = [...items]; + this.#threadCount = Math.min(maxThreads, items.length); + this.#progressCounter = new ProgressCounter(items.length, message); + + const resultPromise = new Promise(resolve => this.#resolveResult = resolve); + for (let i = 0; i < maxThreads; ++i) + this.#thread(items); + + return resultPromise; + } + + async #thread(items) { + if (items.length && !this.#cancelled) { + let item = items.shift(); + + try { + await this.#processorf(item); + } catch (e) { + console.error(e); + this.#errors = true; + } + + this.#progressCounter.incrementAndNotify(); + + return this.#thread(items); + } + else { + this.#threadCount -= 1; + if (this.#threadCount === 0) + return this.#onFinish(); + } + } + + async #onFinish() { + this.#progressCounter.finish(); + this.#resolveResult(!this.#errors); + } +} diff --git a/addon/utils_browser.js b/addon/utils_browser.js new file mode 100644 index 00000000..3f8d6389 --- /dev/null +++ b/addon/utils_browser.js @@ -0,0 +1,217 @@ +import {settings} from "./settings.js"; +import {getSidebarWindow} from "./utils_sidebar.js"; + +export const ACTION_ICONS = { + 16: "icons/logo16.png", + 24: "icons/logo24.png", + 32: "icons/logo32.png", + 96: "icons/logo96.png", + 128: "icons/logo128.png" +}; + +async function injectCSSFileMV3(tabId, options) { + return browser.scripting.insertCSS({target: {tabId}, files: [options.file]}) +} + +export const injectCSSFile = _MANIFEST_V3? injectCSSFileMV3: browser.tabs?.insertCSS; + +async function injectScriptFileMV3(tabId, options) { + const target = {tabId}; + + if (options.frameId) + target.frameIds = [options.frameId]; + + if (options.allFrames) + target.allFrames = options.allFrames; + + let immediately = false; + if (options.runAt === "document_start") + immediately = true; + + return browser.scripting.executeScript({target, files: [options.file], injectImmediately: immediately}); +} + +export const injectScriptFile = _MANIFEST_V3? injectScriptFileMV3: browser.tabs?.executeScript; + +async function scriptsAllowedMV3(tabId, frameId = 0) { + try { + const scriptResult = await browser.scripting.executeScript({ + target: {tabId, frameIds: [frameId]}, + injectImmediately: true, + // check if it is a builtin Firefox image page + func: () => document.head.querySelectorAll("link[href='resource://content-accessible/ImageDocument.css']")?.length, + }); + + let isHTML = true; + + if (scriptResult && scriptResult[0] && scriptResult[0].result > 0) + isHTML = false; + + return isHTML; + } + catch (e) {} + + return false; +} + +async function scriptsAllowedMV2(tabId, frameId = 0) { + try { + const scriptResult = await browser.tabs.executeScript(tabId, { + frameId: frameId, + runAt: "document_start", + // check if it is a builtin Firefox image page + code: "document.head.querySelectorAll(\"link[href='resource://content-accessible/ImageDocument.css']\")?.length" + }); + + let isHTML = true; + + if (scriptResult && scriptResult[0] && scriptResult[0] > 0) + isHTML = false; + + return isHTML; + } catch (e) {} +} + +const scriptsAllowed = _MANIFEST_V3? scriptsAllowedMV3: scriptsAllowedMV2; + +export async function isHTMLTab(tab) { + if (settings.platform.firefox) + return scriptsAllowed(tab.id); + else { + const [{result}] = await browser.scripting.executeScript({target: {tabId: tab.id}, + func: () => document.contentType}); + return result.toLowerCase() === "text/html"; + } +} + +export function showNotification(args) { + if (typeof arguments[0] === "string") + args = {message: arguments[0]}; + + const iconUrl = _BACKGROUND_PAGE + ? "/icons/scrapyard.svg" + : "/icons/logo128.png"; + + return browser.notifications.create(`sbi-notification-${args.type}`, { + type: args.type ? args.type : "basic", + title: args.title ? args.title : "Scrapyard", + message: args.message, + iconUrl + }); +} + +export function makeReferenceURL(uuid) { + let referenceURL = `ext+scrapyard://${uuid}`; + + if (!_BACKGROUND_PAGE) + referenceURL = browser.runtime.getURL(`/reference.html#${referenceURL}`); + + return referenceURL; +} + +export async function getActiveTab() { + const tabs = await browser.tabs.query({lastFocusedWindow: true, active: true}); + return tabs && tabs.length ? tabs[0] : null; +} + +export async function getActiveTabFromSidebar() { + if (_SIDEBAR) + return getActiveTab(); + else { + const sidebarWindow = getSidebarWindow(); + const tabs = await browser.tabs.query({active: true}); + return tabs.find(t => t.windowId !== sidebarWindow.id); + } +} + +export async function openPage(url) { + return browser.tabs.create({"url": url}); +} + +export async function updateTabURL(tab, url, preserveHistory) { + const options = {url}; + + if (_BACKGROUND_PAGE) + options.loadReplace = !preserveHistory; + + return browser.tabs.update(tab.id, options); +} + +export async function openContainerTab(url, container) { + try { + const options = {"url": url}; + + if (container) + options.cookieStoreId = container; + + return await browser.tabs.create(options); + } catch (e) { + if (e.message?.includes("cookieStoreId")) + showNotification("Invalid bookmark container."); + + return browser.tabs.create({"url": url}); + } +} + +export const CONTEXT_BACKGROUND = 0; +export const CONTEXT_FOREGROUND = 1; + +export function getContextType() { + return typeof WorkerGlobalScope !== "undefined" || window.location.pathname === "/background.html" + ? CONTEXT_BACKGROUND + : CONTEXT_FOREGROUND; +} + +export async function askCSRPermission() { + if (_MANIFEST_V3) + return browser.permissions.request({origins: [""]}); + + return true; +} + +export async function hasCSRPermission(verbose = true) { + if (_MANIFEST_V3) { + const response = await browser.permissions.contains({origins: [""]}); + + if (!response && verbose) + showNotification("Please, enable optional add-on permissions at the Firefox add-on settings page (about:addons)."); + + return response; + } + + return true; +} + +export async function grantPersistenceQuota() { + const shouldAskForPersistence = typeof navigator.storage.persist === "function"; + return !shouldAskForPersistence || shouldAskForPersistence && await navigator.storage.persist(); +} + +export async function startupLatch(f) { + if (_MANIFEST_V3) { + if (browser.storage.session) { + let initialized = await browser.storage.session.get("scrapyard-initialized"); + initialized = initialized?.["scrapyard-initialized"]; + + if (!initialized) { + await f(); + await browser.storage.session.set({"scrapyard-initialized": true}); + } + } + else { + // until there is no storage.session API, + // use an alarm as a flag to call the initialization function only once + const alarm = await browser.alarms.get("startup-flag-alarm"); + if (!alarm) { + await f(); + browser.alarms.create("startup-flag-alarm", {delayInMinutes: 525960}); // one year + } + } + } + else + await f(); +} + +export function gettingStarted() { + return openPage("/ui/options.html#help:start"); +} diff --git a/addon/utils_html.js b/addon/utils_html.js new file mode 100644 index 00000000..02ed3cec --- /dev/null +++ b/addon/utils_html.js @@ -0,0 +1,302 @@ +import {fetchWithTimeout} from "./utils_io.js"; +import {Archive} from "./storage_entities.js"; + +var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' +}; + +export function escapeHtml(string) { + return String(string).replace(/[&<>"'`=\/]/g, s => entityMap[s]); +} + +export function escapeCSS(string) { + return String(string).replace(/[<>]/g, s => entityMap[s]); +} + +export function unescapeHtml(string) { + return string.replace(/&/g, '&') + .replace(/"/g, '\"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/'/g, "'"); +} + +export function parseHtml(htmlText) { + let doc = document.implementation.createHTMLDocument("") + , doc_elt = doc.documentElement + , first_elt; + + doc_elt.innerHTML = htmlText; + first_elt = doc_elt.firstElementChild; + + if (doc_elt.childElementCount === 1 + && first_elt.localName.toLowerCase() === "html") { + doc.replaceChild(first_elt, doc_elt); + } + + return doc; +} + +export function clearDocumentEncoding(doc) { + let meta = doc.querySelector("meta[http-equiv='content-type' i]") + || doc.querySelector("meta[charset]"); + + if (meta) + meta.parentNode.removeChild(meta); +} + +export function fixDocumentEncoding(doc) { + clearDocumentEncoding(doc); + $(doc.getElementsByTagName("head")[0]).prepend(``); +} + +export function isElementInViewport(el) { + var rect = el.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); +} + +export function injectCSS(file, doc) { + if (!doc) + doc = document; + + let link = doc.querySelector(`link[href="${file}]"`); + + if (!link) { + link = doc.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = file; + link.media = "all"; + doc.head.appendChild(link); + } +} + +export function getThemeVar(v) { + let vars = document.querySelector(":root"); + if (vars) { + let style = window.getComputedStyle(vars); + return style.getPropertyValue(v); + } +} + +export function applyInlineStyles(element, recursive = true, exclude) { + + let matchRules = function (el) { + let sheets = Array.from(document.styleSheets); + if (exclude) + sheets = sheets.filter(s => !exclude.some(e => s.href?.endsWith(e))); + let ret = []; + for (let sheet of sheets) { + let rules = sheet.rules || sheet.cssRules; + for (let r in rules) { + if (rules[r].selectorText?.includes("*")) + continue; + if (el.localName === "pre" && el.matches(rules[r].selectorText)) { + console.log(rules[r].selectorText) + } + if (el.matches(rules[r].selectorText)) { + ret.push(rules[r]); + } + } + } + return ret; + } + + const matches = matchRules(element); + + // we need to preserve any pre-existing inline styles. + let srcRules = document.createElement(element.tagName).style; + srcRules.cssText = element.style.cssText; + + matches.forEach(rule => { + for (let prop of rule.style) { + + let val = srcRules.getPropertyValue(prop) || rule.style.getPropertyValue(prop); + let priority = rule.style.getPropertyPriority(prop); + + element.style.setProperty(prop, val, priority); + } + }); + + if (recursive) { + Array.from(element.children).forEach(child => { + applyInlineStyles(child, recursive, exclude); + }); + } +} + +export function indexString(string) { + return createIndex(string, t => t); +} + +export function indexHTML(string) { + const textExtractor = _BACKGROUND_PAGE + ? extractTextRecursive + : removeTags; + + return createIndex(string, textExtractor) +} + +function createIndex(string, textExtractor) { + try { + string = textExtractor(string); + string = string.replace(/\n/g, " ") + .replace(/(?:\p{Z}|[^\p{L}-])+/ug, " "); + + let words = string.split(" ") + .filter(s => s && s.length > 2) + .map(s => s.toLocaleLowerCase()) + + return Array.from(new Set(words)); + } + catch (e) { + console.error(e) + console.log("Index creation has failed.") + return []; + } +} + +function extractTextRecursive(string, parser) { + if (!parser) + parser = new DOMParser(); + + const doc = parser.parseFromString(string, "text/html"); + removeScriptTags(doc); + + let text = doc.body.textContent; + + doc.querySelectorAll("iframe").forEach( + function (element) { + const html = element.srcdoc; + if (html) + text += " " + extractTextRecursive(html, parser); + }); + + return text; +} + +export function instantiateIFramesRecursive(doc, parser, acc = [], topIFrames) { + const invocation = !parser; + if (invocation) + parser = new DOMParser(); + + const iframes = doc.querySelectorAll("iframe"); + + if (invocation) + topIFrames = iframes; + + iframes.forEach( + function (iframe) { + const html = iframe.srcdoc; + if (html) { + const iframeDoc = parseHtml(html); + iframe.__doc = iframeDoc; + acc.push(iframeDoc); + instantiateIFramesRecursive(iframeDoc, parser, acc, topIFrames); + } + }); + + return [acc, topIFrames]; +} + +export function rebuildIFramesRecursive(doc, topIFrames) { + topIFrames.forEach(iframe => { + if (iframe.__doc) { + rebuildIFramesRecursive(iframe.__doc, iframe.__doc.querySelectorAll("iframe")); + iframe.srcdoc = iframe.__doc.documentElement.outerHTML; + } + }) +} + +export async function buildIFramesRecursive(node, doc, topIFrames, acc = []) { + for (let i = 0; i < topIFrames.length; ++i) { + const iframe = topIFrames[i]; + + let iframeHTML = iframe.srcdoc; + + if (!iframeHTML && iframe.src && !iframe.src.startsWith("http")) + iframeHTML = await Archive.getFile(node, iframe.src); + + if (iframeHTML) { + const iframeDoc = parseHtml(iframeHTML); + await buildIFramesRecursive(node, iframeDoc, iframeDoc.querySelectorAll("iframe"), acc); + iframe.__doc = iframeDoc; + acc.push(iframeDoc); + } + } + + return [acc, topIFrames]; +} + +export async function assembleUnpackedIndex(node) { + const indexHTML = await Archive.getFile(node, "index.html"); + + if (indexHTML) { + const doc = parseHtml(indexHTML); + const iframes = doc.querySelectorAll("iframe"); + const [iframeDocs] = await buildIFramesRecursive(node, doc, iframes); + return [doc, ...iframeDocs]; + } +} + +function removeTags(string) { + return string.replace(/]*srcdoc="([^"]*)"[^>]*>/igs, (m, d) => d) + .replace(//igs, "") + .replace(//igs, "") + .replace(//igs, "") + .replace(/&[0-9#a-zA-Z]+;/igs, ' ') + .replace(/<[^>]+>/gs, ' '); +} + +function removeScriptTags(doc) { + $("body script", doc).remove(); + $("body style", doc).remove(); +} + +export async function isHTMLLink(url, timeout = 10000) { + let response; + + try { + response = await fetchWithTimeout(url, {method: "head"}); + } catch (e) { + console.error(e); + } + + if (response?.ok) { + const contentType = response.headers.get("content-type"); + return !!(contentType && contentType.toLowerCase().startsWith("text/html")); + } +} + +export class RDFNamespaces { + //NS_NC; + NS_RDF; + NS_SCRAPBOOK; + + resolver; + + constructor(doc) { + const rootAttrs = Object.values(doc.documentElement.attributes); + const namespaces = rootAttrs.map(a => [a.localName, a.prefix === "xmlns"? a.value: null]); + const namespaceMap = new Map(namespaces); + this.resolver = ns => namespaceMap.get(ns); + + //this.NS_NC = this.resolver("NC"); + this.NS_RDF = this.resolver("RDF"); + this.NS_SCRAPBOOK = namespaces.find(ns => (/NS\d+/i).test(ns[0]))[1]; + } +} diff --git a/addon/utils_io.js b/addon/utils_io.js new file mode 100644 index 00000000..a2d0fcf1 --- /dev/null +++ b/addon/utils_io.js @@ -0,0 +1,201 @@ +export function binaryString2Array(str) { + let byteArray = new Uint8Array(str.length); + + for (let i = 0; i < str.length; ++i) + byteArray[i] = str.charCodeAt(i); + + return byteArray; +} + +export function arrayToBinaryString(arr) { + let str = ""; + const bytes = new Uint8Array(arr); + + for (let i = 0; i < bytes.byteLength; i++) + str += String.fromCharCode(bytes[i]); + + return str; +} + +export async function readFile(file) { + let reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = e => resolve(e.target.result); + reader.onerror = e => reject(e); + + reader.readAsText(file); + }); +} + +export function readBlob(blob, mode) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result); + }; + reader.onerror = e => { + reject(e); + }; + + if (mode === "binarystring") + reader.readAsBinaryString(blob); + else if (mode === "binary") + reader.readAsArrayBuffer(blob); + else + reader.readAsText(blob, "utf-8"); + }); +} + +export class LineReader { + /* options: + chunk_size: The chunk byte size. Default is 256K. + */ + constructor(file, options) { + this.file = file; + this.offset = 0; + this.fileSize = file.size; + this.decoder = new TextDecoder(); + this.reader = new FileReader(); + + this.chunkSize = !options || typeof options.chunk_size === 'undefined' ? 256 * 1024 : parseInt(options.chunk_size); + } + + async* lines() { + let remnantBytes; + let remnantCharacters = ""; + + for (let offset = 0; offset < this.fileSize; offset += this.chunkSize) { + let chunk = await this.readChunk(offset); + let bytes = new Uint8Array(chunk); + let point = bytes.length - 1; + let split = false; + let remnant; + + if ((bytes[point] & 0b11000000) === 0b11000000) + split = true; + else { + while (point && (bytes[point] & 0b11000000) === 0b10000000) { + point -= 1; + } + + if (point !== bytes.length - 1) + split = true; + } + + if (split) { + remnant = bytes.slice(point); + bytes = bytes.slice(0, point); + + if (remnantBytes) { + let newBytes = new Uint8Array(remnantBytes.length + bytes.length); + newBytes.set(remnantBytes); + newBytes.set(bytes, remnantBytes.length); + bytes = newBytes; + } + + remnantBytes = remnant; + } + else { + if (remnantBytes) { + let newBytes = new Uint8Array(remnantBytes.length + bytes.length); + newBytes.set(remnantBytes); + newBytes.set(bytes, remnantBytes.length); + bytes = newBytes; + } + + remnantBytes = null; + } + + let lines = this.decoder.decode(bytes).split("\n"); + + if (lines.length === 1) { + remnantCharacters = remnantCharacters + lines[0]; + } + else if (lines.length) { + if (remnantCharacters) + lines[0] = remnantCharacters + lines[0]; + + remnantCharacters = lines[lines.length - 1]; + lines.length = lines.length - 1; + + yield* lines; + } + } + + yield remnantCharacters; + } + + readChunk(offset) { + return new Promise((resolve, reject) => { + this.reader.onloadend = () => { + resolve(this.reader.result); + }; + this.reader.onerror = e => { + reject(e); + }; + + this.reader.readAsArrayBuffer(this.file.slice(offset, offset + this.chunkSize)); + }); + } +} + +export class LineStream { + constructor(reader) { + this._iterator = reader.lines(); + } + + async read() { + const item = await this._iterator.next(); + if (item.done) + return undefined; + return item.value; + } +} + +export async function fetchText(url, init) { + const response = await fetch(url, init); + + if (response.ok) { + const contentType = response.headers.get("content-type"); + + let encoding; + let result; + + if (contentType) { + const charset = contentType.match(/;\s*charset=([^;]+)/i); + if (charset) + encoding = charset[1].toLowerCase(); + + if (encoding === "utf-8") + encoding = null; + } + + if (encoding) { + const buffer = await response.arrayBuffer(); + const decoder = new TextDecoder(encoding); + + result = decoder.decode(buffer); + } + else + result = await response.text(); + + return result; + } +} + +export async function fetchWithTimeout(resource, options = {}) { + const { timeout = 10000 } = options; + delete options.timeout; + + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(resource, { + ...options, + signal: controller.signal + }); + clearTimeout(id); + + return response; +} diff --git a/addon/utils_sidebar.js b/addon/utils_sidebar.js new file mode 100644 index 00000000..76f4c517 --- /dev/null +++ b/addon/utils_sidebar.js @@ -0,0 +1,60 @@ +import {settings} from "./settings.js"; +import {sleep} from "./utils.js"; + +const SCRAPYARD_SIDEBAR_URL = browser.runtime.getURL("/ui/sidebar.html"); + +export async function getSidebarWindow() { + const popupWindows = await browser.windows.getAll({ + populate: true, + windowTypes: ["popup"] + }); + + for (const window of popupWindows) + if (window.tabs.some(t => t.url === SCRAPYARD_SIDEBAR_URL)) + return window; +} + +export async function createSidebarWindow(focused = true) { + const position = await settings.sidebar_window_position() || {}; + const params = { + url: SCRAPYARD_SIDEBAR_URL, + type: "popup", + focused, + width: position.width || 400, + top: position.top || 50, + left: position.left || 50, + }; + + if (position.height) + params.height = position.height; + + try { + await browser.windows.create(params); + } + catch (e) { + console.error(e); + + params.width = 400; + params.top = 50; + params.left = 50; + await browser.windows.create(params); + } +} + +export async function toggleSidebarWindow() { + const sidebarWindow = await getSidebarWindow(); + + if (sidebarWindow) + await browser.windows.update(sidebarWindow.id, {focused: true}); + else + await createSidebarWindow(); +} + +export async function ensureSidebarWindow(sleepMs) { + const sidebarWindow = await getSidebarWindow(); + + if (!sidebarWindow) { + await createSidebarWindow(false); + await sleep(sleepMs || 700); + } +} diff --git a/addon/uuid.js b/addon/uuid.js new file mode 100644 index 00000000..0996e901 --- /dev/null +++ b/addon/uuid.js @@ -0,0 +1,37 @@ +export default class UUID { + static numeric() { + let uuid = crypto.randomUUID(); + uuid = uuid.replaceAll(/-/g, ""); + return uuid.toUpperCase(); + } + + static date() { + const dt = new Date(); + + return dt.getFullYear() + + ("0" + (dt.getMonth() + 1)).slice(-2) + + ("0" + dt.getDate()).slice(-2) + + ("0" + dt.getHours()).slice(-2) + + ("0" + dt.getMinutes()).slice(-2) + + ("0" + dt.getSeconds()).slice(-2); + }; + + static getDate(uuid) { + const dt = new Date(); + const y = uuid.substring(0, 4); + const m = uuid.substring(4, 6); + const d = uuid.substring(6, 8); + const h = uuid.substring(8, 10); + const mi = uuid.substring(10, 12); + const s = uuid.substring(12, 14); + + dt.setFullYear(y); + dt.setMonth(parseInt(m) - 1); + dt.setDate(d); + dt.setHours(h); + dt.setMinutes(mi); + dt.setSeconds(s); + + return dt; + }; +}; diff --git a/addon/version.txt b/addon/version.txt new file mode 100644 index 00000000..b1b25a5f --- /dev/null +++ b/addon/version.txt @@ -0,0 +1 @@ +2.2.2 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 00000000..dc5ca963 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release/ diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..36a135ea --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,100 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + //id 'com.github.sgtsilvio.gradle.android-retrofix' +} + +android { + compileSdk 33 + + defaultConfig { + applicationId "l2.albitron.scrapyard" + minSdk 21 + targetSdk 33 + versionCode 21 + versionName "2.2.3" + + String dropboxKey = localProperties['DBX_API_KEY'] + buildConfigField "String", "DBX_API_KEY", "\"${dropboxKey}\"" + manifestPlaceholders = [dropboxKey: dropboxKey, msalSigHash: ""] + + //testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + + manifestPlaceholders.msalSigHash = localProperties['MSAL_SIG_HASH'] + } + debug { + manifestPlaceholders.msalSigHash = localProperties['MSAL_SIG_HASH_DEBUG'] + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + packagingOptions { + resources { + excludes += ['META-INF/INDEX.LIST', 'META-INF/AL2.0', 'META-INF/LGPL2.1', + 'META-INF/LICENSE.md', 'META-INF/NOTICE.md', + 'META-INF/io.netty.versions.properties'] + } + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + viewBinding true + //dataBinding true + } +} + +dependencies { + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.0' + implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.2' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + implementation 'androidx.preference:preference-ktx:1.1.1' + implementation 'androidx.work:work-runtime-ktx:2.7.1' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + + implementation 'com.fasterxml.jackson.core:jackson-core:2.12.5' + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.12.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.5' + implementation 'org.apache.commons:commons-text:1.9' + implementation 'org.jsoup:jsoup:1.14.3' + + //implementation 'net.sourceforge.streamsupport:android-retrofuture:1.7.4' + + implementation 'com.dropbox.core:dropbox-core-sdk:5.0.0' + + implementation ("com.microsoft.identity.client:msal:2.2.2") { exclude group: 'com.microsoft.device.display' } + implementation ('com.microsoft.graph:microsoft-graph:3.1.0') { + exclude group: 'javax.activation' + exclude group: 'xpp3' + } + + //testImplementation 'junit:junit:4.+' + //androidTestImplementation 'androidx.test.ext:junit:1.1.2' + //androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' +} + +def getLocalProperties() { + Properties props = new Properties() + if (file('../local.properties').exists()) { + props.load(new FileInputStream(file('../local.properties'))) + } + return props +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..ec8ca37c --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +-keep class l2.albitron.scrapyard.** {*;} +-keep class com.microsoft.graph.models.** {*;} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..176547fe --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/content.html b/android/app/src/main/assets/content.html new file mode 100644 index 00000000..ad4ed01a --- /dev/null +++ b/android/app/src/main/assets/content.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + +
+ + diff --git a/android/app/src/main/assets/css/markers.css b/android/app/src/main/assets/css/markers.css new file mode 100644 index 00000000..1d5697c5 --- /dev/null +++ b/android/app/src/main/assets/css/markers.css @@ -0,0 +1,24 @@ +.scrapyard-marker-1{ + background: #f2cfff; +} +.scrapyard-marker-2{ + background: #b2e8f6; +} +.scrapyard-marker-3{ + background: #bcffc1; +} +.scrapyard-marker-4{ + background:#fff2a1 ; +} +.scrapyard-marker-5{ + border-bottom:2px solid #28c628; +} +.scrapyard-marker-6{ + border-bottom:2px solid #f00; +} +.scrapyard-marker-7{ + border-bottom:2px dotted #28c628; +} +.scrapyard-marker-8{ + border-bottom:2px dotted #f00; +} diff --git a/android/app/src/main/assets/css/notes.css b/android/app/src/main/assets/css/notes.css new file mode 100644 index 00000000..27de26ef --- /dev/null +++ b/android/app/src/main/assets/css/notes.css @@ -0,0 +1,366 @@ +* { + font-family: "Helvetica", "Arial", sans-serif; +} + +input, textarea { + outline: none !important; +} + +textarea:focus { + border: 1px solid #007799; +} + +#root-container { + display: flex; + flex-direction: column; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + font-size: 12pt; + min-height: 0; + background: white; +} + +#full-width-container { + display: none; +} + +#tabbar { + height: 38px; + flex: 0 1 auto; + display: flex; + border-bottom: 1px solid #777; + line-height: 37px; +} + +a#notes-button, a#edit-button { + outline: none; + padding: 0px 15px; + font-size: 14px; + text-align: center; + height: 100%; + width: 50px; + text-decoration: none; + font-weight: bold; + color: black; + flex: 0 1 auto; +} + +a#notes-button.focus, a#edit-button.focus { + border-bottom: 3px solid #079; + background: #ddd; +} + +#notes-for { + display: none; +} + +.source-url { + color: #0074D9 !important; + text-decoration: underline; + cursor: pointer; +} + +#notes-for, span#source-url.notes-title { + font-weight: bold; + color: black !important; +} + +#content { + padding: 16px; + display: flex; + align-items: stretch; + min-height: 0; + flex-grow: 1; + overflow: auto; +} + +#notes { + width: 798px; + font-size: 100%; +} + +#notes *, .ql-editor * { + font-family: "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "Noto Sans", sans-serif; +} + +#notes > *:last-child { + margin-bottom: 16px !important; +} + +#notes code, .ql-editor code { + font-family: "Courier New", monospace; + display: inline-block; +} + +#notes > ul#toc { + padding-left: 0; + margin-top: 0; + list-style: none; +} + +#notes-button-content, #edit-button-content { + flex: 1 1 auto; + height: 100%; + display: flex; +} + +#edit-button-content { + display: none; +} + +#wysiwyg-editor { + height: calc(100% - 24px); +} + +#editor { + width: calc(100% - 4px); + height: calc(100% - 4px); + font-size: 12pt; + font-family: "Lucida Console", "Courier New", monospace; +} + +#bottomline { + flex: 0 1 auto; + display: flex; + border-top: 1px solid #777; + line-height: 24px; + padding: 4px 8px; +} + +#bottomline > * { + flex: 0 1 auto; +} + +#tabbar .spacer, #bottomline .spacer { + flex: 1 1 auto; +} + +#inserts { + display: none; +} + +#close-button { + display: none; + background: #09c; + color: #fff; + cursor: pointer; + border-radius: 5px; + padding: 3px 15px; + border: none; + line-height: 20px; + margin-left: 5px; +} + +span.task-status { + color: white !important; +} + +span.task-status.todo { + color: white !important; +} + +span.task-status.done { + color: white !important; +} + +#format-selector { + display: none; +} + +#notes-width option[value="actual"] { + display: none; +} + +#font-sizes, #editor-font-sizes, #width-controls { + user-select: none; + margin-left: 10px; + display: inline-block; +} + +.font-button, .width-button { + cursor: pointer; + padding: 0 4px; + width: 14px; + height: 14px; + display: inline-block; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.width-button { + background-size: 12px 12px; + margin-bottom: -2px; +} + +#font-size-smaller, #editor-font-size-smaller { + background-image: url("../icons/font-smaller.svg"); +} + +#font-size-default, #editor-font-size-default { + background-image: url("../icons/font-default.svg"); +} + +#font-size-larger, #editor-font-size-larger { + background-image: url("../icons/font-larger.svg"); +} + +#decrease-width, #decrease-width { + background-image: url("../icons/dec.svg"); +} + +#increase-width { + background-image: url("../icons/inc.svg"); +} + +pre { + tab-size: 4; + -moz-tab-size: 4; +} + +pre.plaintext { + padding: 0; + -moz-border-radius: 0; + border-radius: 0; + background-color: transparent; + display: block; + margin: 0; + /*line-height: 18px;*/ + border: none; + -webkit-border-radius: 0; + word-wrap: break-word; + font-size: 94%; +} + +#space-left, #space-right, .content-filler { + flex: 1; +} + +#editor-container { + display: flex; + flex-direction: column; + width: 830px; +} + +#editor { + display: none; +} + +.ql-toolbar.ql-snow .ql-formats { + margin-right: 4px; +} + +.ql-snow .ql-picker.ql-size { + width: 76px; +} + +.ql-snow .ql-picker.ql-header { + width: 90px; +} + +.ql-snow .ql-picker.ql-font { + width: 100px; +} + +.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button { + height: 22px; + padding: 2px 3px; + width: 26px; +} + +.ql-toolbar.ql-snow { + padding: 4px; + min-height: 34px; +} + +.ql-container { + font-size: 115%; +} + +.ql-editor .ql-align-justify { + white-space: normal; +} + +.ql-editor p, .ql-editor ol, .ql-editor ul, .ql-editor pre, .ql-editor blockquote, .ql-editor h1, .ql-editor h2, +.ql-editor h3, .ql-editor h4, .ql-editor h5, .ql-editor h6 { + margin-top: 10px; +} + +.ql-editor *:first-child { + margin-top: 0; +} + +p.linebreak-true { + text-indent: 0px; + margin-top: 0px; +} + +button.ql-showHtml { + background-image: url("../icons/code.svg") !important; + background-size: 16px 16px !important; + background-position: center !important; + background-repeat: no-repeat !important; +} + +button.ql-hr { + background-image: url("../icons/line.svg") !important; + /*background-size: 16px 16px !important;*/ + background-position: center !important; + background-repeat: no-repeat !important; +} + + +.ql-snow .ql-editor pre.ql-syntax { + font-size: 90%; + background-color: #f5f5f5; + color: black; +} + +.quill-html-editor { + width: 100%; + height: 100%; + margin: 0px; + box-sizing: border-box; + font-size: 15px; + outline: none; + padding: 20px; + line-height: 24px; + font-family: Consolas, Menlo, Monaco, "Courier New", monospace; + position: absolute; + top: 0; + bottom: 0; + border: none; + display: none; + resize: none; +} + +#quill { + width: unset; + height: calc(100% - 40px); +} + +.format-html { + line-height: 1.3; + font-size: 115%; + box-sizing: border-box; +} + +.format-html ul > li::before { + content: '\2022'; +} + +.format-html li::before { + margin-left: -1.5em; + margin-right: 0.3em; + text-align: right; + display: inline-block; + white-space: nowrap; + width: 1.2em; +} + +.format-html ol li::before { + content: counter(list-0, decimal) '. '; +} diff --git a/android/app/src/main/assets/css/org.css b/android/app/src/main/assets/css/org.css new file mode 100644 index 00000000..2fd315df --- /dev/null +++ b/android/app/src/main/assets/css/org.css @@ -0,0 +1,244 @@ +/* Code */ + +a, a:visited { + color: #0074D9; +} + +code, pre { + padding: 0 3px 2px; + -moz-border-radius: 3px; + border-radius: 3px; + font-family: "Courier New", monospace; +} +code { + background-color: #eee; + color: rgba(0, 0, 0, 0.75); + padding: 1px 3px; +} + +pre { + background-color: #f5f5f5; + display: block; + padding: 8.5px; + margin: 0 0 18px; + line-height: 18px; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.15); + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; +} + +table { + width: 100%; + margin-bottom: 18px; + padding: 0; + border-collapse: separate; + *border-collapse: collapse; + /* IE7, collapse table to remove spacing */ + + font-size: 13px; + border: 1px solid #ddd; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +table th, table td { + padding: 10px 10px 9px; + line-height: 18px; + text-align: left; +} +table th { + padding-top: 9px; + font-weight: bold; + vertical-align: middle; + border-bottom: 1px solid #ddd; +} +table td { + vertical-align: top; +} +table th + th, table td + td { + border-left: 1px solid #ddd; +} +table tr + tr td { + border-top: 1px solid #ddd; +} +table tbody tr:first-child td:first-child { + -webkit-border-radius: 4px 0 0 0; + -moz-border-radius: 4px 0 0 0; + border-radius: 4px 0 0 0; +} +table tbody tr:first-child td:last-child { + -webkit-border-radius: 0 4px 0 0; + -moz-border-radius: 0 4px 0 0; + border-radius: 0 4px 0 0; +} +table tbody tr:last-child td:first-child { + -webkit-border-radius: 0 0 0 4px; + -moz-border-radius: 0 0 0 4px; + border-radius: 0 0 0 4px; +} +table tbody tr:last-child td:last-child { + -webkit-border-radius: 0 0 4px 0; + -moz-border-radius: 0 0 4px 0; + border-radius: 0 0 4px 0; +} +.zebra-striped tbody tr:nth-child(odd) td { + background-color: #f9f9f9; +} +.zebra-striped tbody tr:hover td { + background-color: #f5f5f5; +} +table .header { + cursor: pointer; +} +table .header:after { + content: ""; + float: right; + margin-top: 7px; + border-width: 0 4px 4px; + border-style: solid; + border-color: #000 transparent; + visibility: hidden; +} +table .headerSortUp, table .headerSortDown { + background-color: rgba(141, 192, 219, 0.25); + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); +} +table .header:hover:after { + visibility: visible; +} +table .headerSortDown:after, table .headerSortDown:hover:after { + visibility: visible; + filter: alpha(opacity=60); + -khtml-opacity: 0.6; + -moz-opacity: 0.6; + opacity: 0.6; +} +table .headerSortUp:after { + border-bottom: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid #000; + visibility: visible; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + filter: alpha(opacity=60); + -khtml-opacity: 0.6; + -moz-opacity: 0.6; + opacity: 0.6; +} +table .blue { + color: #049cdb; + border-bottom-color: #049cdb; +} +table .headerSortUp.blue, table .headerSortDown.blue { + background-color: #ade6fe; +} +table .green { + color: #46a546; + border-bottom-color: #46a546; +} +table .headerSortUp.green, table .headerSortDown.green { + background-color: #cdeacd; +} +table .red { + color: #9d261d; + border-bottom-color: #9d261d; +} +table .headerSortUp.red, table .headerSortDown.red { + background-color: #f4c8c5; +} +table .yellow { + color: #ffc40d; + border-bottom-color: #ffc40d; +} +table .headerSortUp.yellow, table .headerSortDown.yellow { + background-color: #fff6d9; +} +table .orange { + color: #f89406; + border-bottom-color: #f89406; +} +table .headerSortUp.orange, table .headerSortDown.orange { + background-color: #fee9cc; +} +table .purple { + color: #7a43b6; + border-bottom-color: #7a43b6; +} +table .headerSortUp.purple, table .headerSortDown.purple { + background-color: #e2d5f0; +} + + +dl dt { + font-weight: bold; +} + +blockquote { + margin-bottom: 18px; + border-left: 5px solid #eee; + padding-left: 15px; +} +blockquote p { + font-weight: 300; + margin-bottom: 0; +} +blockquote:before, +blockquote:after { + content: ""; +} + + + +/* Code */ + +pre code { + background-color: inherit; + color: inherit; + padding: 0; +} + + +/* TODO status*/ +span.task-status { + padding : 0 0.2em; + margin-right : 10px; + color: white; +} + +span.task-status.todo { + color: white; + background-color: rgb(39, 149, 182); +} + +span.task-status.done { + color: white; + background-color: rgb(81, 143, 31); +} + +/* Section Number */ +.section-number { + padding-right : 10px; + font-weight : bold; + color : rgb(0, 90, 190); + font-style : italic; +} + +.org-subscript-child { + font-size : 80%; +} + +#toc ul { + list-style: none; + padding-left: 20px; +} + +#toc a { + text-decoration: none; +} diff --git a/android/app/src/main/assets/css/sidebar.css b/android/app/src/main/assets/css/sidebar.css new file mode 100644 index 00000000..e9df53ed --- /dev/null +++ b/android/app/src/main/assets/css/sidebar.css @@ -0,0 +1,592 @@ +:root { + --theme-background: white; + + --themed-comments-icon: url("icons/page-blank.svg"); + --themed-comments-filled-icon: url("icons/page.svg"); + --themed-properties-icon: url("icons/properties.svg"); + --themed-containers-icon: url("icons/containers.svg"); +} + +*{ + font-family: "Helvetica", + "Arial", + "Lucida Sans Unicode", + "Lucida Sans", + "Trebuchet MS", + "Open Sans", + "Fira Sans", + "Liberation Sans", + sans-serif; +} + +html,body { + margin:0; + min-height: 100vh; + overflow-x: hidden; +} + +#main-container { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0 +} + +#flex-container { + /*margin: 10px;*/ + flex-grow: 1; + + display: flex; + flex-direction: column; + + /* for Firefox */ + min-height: 0; + +} + +#treeview-container { + flex-grow: 1; + overflow: auto; + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + /* for Firefox */ + min-height: 0; +} + +#treeview { + /*height: 100%;*/ +} + +.box { + margin:70px 20px 20px 20px; +} + +.main-tools { + width:fit-content; + width:-moz-fit-content; + display:inline-block; +} + +.toolbar { + /*margin-bottom:5px;*/ + /*position:fixed;*/ + background:#fff; + width:100%; + /*height:50px;*/ + padding:8px; + border-bottom:1px solid #999; + white-space:nowrap; + z-index:1; /* prevent dots of "text-overflow:ellipsis" diplayed over me */ +} + +.folder-content { + display:none; + /*margin-left:18px;*/ + /* border:1px solid; */ +} + +.folder-content.root { + display:block; + /*margin-left:0px; + margin:70px 20px 20px 20px;*/ + overflow:visible; +} + +.folder-content.root > .item { + display:block; + margin-left:0px; +} + +.dlg-row { + padding:12px 0 6px 0; + white-space:nowrap; + display:table-row; +} + +.dlg-row input { + vertical-align:middle; + border:none; +} + +.dlg-title { + font-weight:bold; + border-bottom:1px solid #999; + padding:0 10px 5px 10px; +} + +.dlg-dim { + position:fixed; + z-index:999999999; + width:100%; + height:100%; + background:#fff5; + display: flex; + justify-content: center; + align-items: center; +} + +.dlg { + /*top:20%;*/ + margin: 30px; + position:absolute; + background:#ccc; + /*left:10%;*/ + width:80%; + padding:15px 20px 20px 20px; + border-radius:5px; + border:1px solid #777; + overflow:hidden; + /* -webkit-box-sizing: border-box; */ + /* -moz-box-sizing: border-box; */ + /* box-sizing: border-box; */ +} + +.dlg * { + color:#000; +} + +.dlg input, .dlg select, .selectric { + border: 1px solid #777 !important; + border-radius: 3px !important; + -moz-appearance: none !important; +} + +.dlg input.dialog-input, .dlg textarea { + outline: none !important; +} + +.dlg-table { + display:table; + overflow:hidden; + width:100%; + position:relative; +} + +.dlg-table .row { + display:table-row; + padding:12px 5px 6px 5px; + white-space:nowrap; + overflow:hidden; +} + +.dlg-table .row .cell { + display:table-cell; + line-height:20px; + vertical-align:middle; + padding:15px 5px 0 5px; + vertical-align:middle; + /* border:1px solid; */ + /* -webkit-box-sizing: border-box; */ + /* -moz-box-sizing: border-box; */ + /* box-sizing: border-box; */ + overflow:hidden; +} + +.dlg .dlg-box { + width:100%; + overflow:hidden; +} + +.drag-mark { + /* background:#f00; */ + border:none; + /* height:2px; */ + border-bottom:1px dashed #f00; + /* margin:0 0 2px 0; */ +} + +.item.folder.drag-into { + border-bottom:1px dashed #f00; +} + +.table { + display:table; +} + +.table-row { + display:table-row; +} + +.table-cell { + display:table-cell; +} + +.tool-button { + cursor:pointer; + vertical-align:middle; + border-radius:11px; + width:22px; + height:22px; +} +.:hover { + background:#0003; + color:#fff; +} + +.blue-button, .black-button { + background:#09c; + color:#fff; + cursor:pointer; + border-radius:5px; + padding:2px 5px; + border:0; +} + +.black-button { + background:transparent; + color:#000; +} +.item.separator { + width:100%; + height:15px; + border-width:7px 0 7px 0; + border-style:solid; + background:#999; +} + +.item.separator.focus { + background:#900; +} + +select { + color: #000; +} + +/* hex to filter + https://codepen.io/sosuke/pen/Pjoqqp */ +.filter-green-0 { + filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); +} + +.simple-menu { + min-width:50px; + position:absolute; + background:#fff; + right: 10px; + margin-top: 5px; + border: solid 1px black; + z-index: 10000; +} + +.simple-menu div { + color:#000; + padding:5px 15px; + cursor:pointer; +} + +.simple-menu div:hover { + background: #ddd; +} + +#shelves-icon { + width: 16px; + height: 16px; +} + +#shelf-menu-button { + width: 14px; + height: 14px; + margin-top: 3px; + margin-left: 5px; + margin-right: 4px; + cursor: pointer; + background: transparent !important; +} + +#shelf-menu-create-folder { + margin-top: 5px; + border-top: 1px solid black; +} + +.dlg-prop-table { + display: flex; + flex-direction: column; + position: relative; +} + +.dlg-prop-table label { + text-align: right; + clear: both; + float:left; + margin-right:15px; +} + +.prop-row { + padding: 5px; + flex: 0 1 auto; +} + +#prop-dlg-comments-icon, #prop-dlg-containers-icon { + height: 16px; + width: 16px; + float: right; + cursor: pointer; + background-image: var(--themed-comments-icon); + background-size: contain; + background-repeat: no-repeat; +} + + +#prop-dlg-containers-icon { + background-image: var(--themed-containers-icon); + background-size: 15px 15px; + background-position: center; + margin-right: 4px; + height: 16px; + width: 16px; +} + +#dlg-comments-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + padding: 4px; + display: none; +} + +#prop-comments { + height: 100%; + width: 100%; + font-family: "Arial", sans-serif; + font-size: 11pt; +} + +.button-row { + padding: 5px 5px 0; +} + +.prop-row-hidden { + display: none !important; +} + + +#main-controls { + width: 100%; + display:flex; +} + +#main-buttons { + flex: 1 1 auto; +} + +#shelf-selector-container { + flex: 0 1 auto; +} + +#shelf-selector-container > div:first-child, #shelf-selector-container > div:last-child { + display: inline-block; + vertical-align: top; +} + +#shelf-selector-container > div:first-child { + padding-top: 2px; +} + +#search-controls { + width: 100%; + display: flex; + margin-top: 6px; +} + +#search-mode-menu { + margin-top: 20px; + right: 12px; +} + +.search-icon { + width: 16px; + height: 16px; + margin-right: 4px; + margin-left: 4px; + margin-top: 4px; + float: left; +} + +#search-mode-switch { + flex: 0 1 auto; + width: 16px; + height: 16px; + margin-right: 4px; + cursor: pointer; +} + +#search-input-container { + flex: 1 1 auto; + padding-right: 6px; + padding-left: 2px; + position: relative; +} + +#search-input-container::before { + position: absolute; + transform: translate(0,-50%); + left: 6px; + top: 50%; + font-family: "FontAwesome"; + content: "\f002"; +} +#search-input-clear { + position: absolute; + transform: translate(0,-50%); + right: 10px; + top: 50%; + font-family: "FontAwesome"; + font-style: normal; + font-weight: normal; + cursor: pointer; + display: none; +} +#search-input { + border-radius: 4px; + height: 18px; + width: 100%; + font-size: 12px; + border: 1px solid black; + padding-left: 16px; +} + +.selectric-scroll li.highlighted, .selectric-scroll li.selected { + background: transparent; +} + +.selectric-scroll li.highlighted:hover, .selectric-scroll li.selected:hover { + background: #ddd; +} + +.option-builtin { + font-weight: bold; +} + +.divide { + border-bottom: 1px solid black; +} + +#busy-indicator { + width: 16px; + height: 16px; + margin-top: 2px; + margin-right: 4px; +} + +.more-properties { + display: none; +} + +a#more-properties { + margin-top: 10px; + display: inline-block; +} + +a#set-default-icon, a#copy-reference-url { + text-decoration: none; +} + +#footer { + border-top: 1px solid #999; + height: 50px; + grid-template-columns: auto auto; + grid-template-rows: 24px auto; + grid-auto-rows: min-content; + flex-shrink: 0; + display: none; +} + +#footer-title { + grid-column: 1 / 1; + grid-row: 1 / 1; + padding-top: 4px; + padding-left: 4px; + justify-self: start; + font-weight: bold; +} + +#footer-buttons { + grid-column: 2 / 2; + grid-row: 1 / 1; + justify-self: end; +} + +#footer-close-btn, #footer-reload-btn, #footer-find-btn { + font-family: "FontAwesome"; + font-style: normal; + font-weight: normal; + cursor: pointer; + padding: 2px 4px; +} + +#footer-close-btn { + padding-top: 3px; +} + +#footer-reload-btn, #footer-find-btn { + font-size: 85%; + padding-right: 0; +} + +#footer-content { + grid-column: 1 / span 2; + grid-row: 2 / 2; + align-self: start; + padding-top: 2px; + padding-left: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#random-bookmark-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + background-repeat: no-repeat; + background-size: contain; + float: left; +} + +#random-bookmark-link { + padding: 0; + margin: 0; + line-height: 16px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#containers-menu { + display: none; + right: 30px; + top: 34px; + font-weight: normal; +} + +#containers-menu div { + margin: 0; + padding: 2px 5px; + line-height: 16px; +} + +#containers-menu div:first-child { + padding-top: 4px; +} + +#containers-menu div:last-child { + padding-bottom: 4px; +} + + +.container-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 5px; + margin-bottom: -2px; +} diff --git a/android/app/src/main/assets/css/tree.css b/android/app/src/main/assets/css/tree.css new file mode 100644 index 00000000..7909f3c7 --- /dev/null +++ b/android/app/src/main/assets/css/tree.css @@ -0,0 +1,280 @@ +#treeview { + overflow-x: hidden; +} +.jstree-default .jstree-node { + margin-left: 16px; + min-width: 16px; + margin-top: 4px; + margin-bottom: 4px; +} +.jstree-default .jstree-icon:empty { + width: 16px; + height: 16px; + line-height: 24px; + margin-top: 4px; +} +.jstree-default > .jstree-no-dots .jstree-open > .jstree-ocl { + background-position: -40px -8px; +} +.jstree-default > .jstree-no-dots .jstree-closed > .jstree-ocl { + background-position: -8px -8px; +} +.jstree-default .jstree-themeicon { + margin-right: 4px; +} +.jstree-default .jstree-clicked { + background: #ddd; +} +.jstree-default .jstree-hovered { + background: #eee; +} + + +.jstree-default .jstree-wholerow-clicked { + background: #ddd; +} + +.jstree-default .jstree-wholerow-hovered { + background: #eee; +} + +.vakata-context, +.vakata-context ul { + margin: 0; + padding: 2px; + position: absolute; + background: #f5f5f5; + border: 1px solid #979797; + box-shadow: 2px 2px 2px #999999; +} +.vakata-context ul { + list-style: none; + left: 100%; + margin-top: -2.7em; + margin-left: -4px; +} +.vakata-context .vakata-context-right ul { + left: auto; + right: 100%; + margin-left: auto; + margin-right: -4px; +} +.vakata-context li { + list-style: none; +} +.vakata-context li > a { + display: block; + padding: 0 2em 0 2em; + text-decoration: none; + width: auto; + color: black; + white-space: nowrap; + line-height: 1.8em; + text-shadow: 1px 1px 0 white; + border-radius: 1px; +} +.vakata-context li > a:hover { + position: relative; + background-color: #e8eff7; + box-shadow: 0 0 2px #ccc; +} +.vakata-context li > a.vakata-context-parent { + background-image: url("data:image/gif;base64,R0lGODlhCwAHAIAAACgoKP///yH5BAEAAAEALAAAAAALAAcAAAIORI4JlrqN1oMSnmmZDQUAOw=="); + background-position: right center; + background-repeat: no-repeat; +} +.vakata-context li > a:focus { + outline: 0; +} +.vakata-context .vakata-context-hover > a { + position: relative; + background-color: #e8eff7; + box-shadow: 0 0 2px #ccc; +} +.vakata-context .vakata-context-separator > a, +.vakata-context .vakata-context-separator > a:hover { + background: white; + border: 0; + border-top: 1px solid #e2e3e3; + height: 1px; + min-height: 1px; + max-height: 1px; + padding: 0; + margin: 0 0 0 1.8em; + border-left: 1px solid #e0e0e0; + text-shadow: 0 0 0 transparent; + box-shadow: 0 0 0 transparent; + border-radius: 0; +} +.vakata-context .vakata-contextmenu-disabled a, +.vakata-context .vakata-contextmenu-disabled a:hover { + color: silver; + background-color: transparent; + border: 0; + box-shadow: 0 0 0; +} +.vakata-context .vakata-contextmenu-disabled > a > i { + filter: grayscale(100%); +} +.vakata-context li > a > i { + text-decoration: none; + display: inline-block; + width: 1.8em; + height: 1.8em; + background: transparent; + margin: 0 0 0 -2em; + vertical-align: top; + text-align: center; + line-height: 1.8em; +} +.vakata-context li > a > i:empty { + width: 1.8em; + line-height: 1.8em; +} +.vakata-context li > a .vakata-contextmenu-sep { + display: inline-block; + width: 1px; + height: 1.8em; + background: white; + margin: 0 0.5em 0 0; + border-left: 1px solid #e2e3e3; +} +.vakata-context .vakata-contextmenu-shortcut { + font-size: 0.8em; + color: silver; + opacity: 0.5; + display: none; +} +.vakata-context-rtl ul { + left: auto; + right: 100%; + margin-left: auto; + margin-right: -4px; +} +.vakata-context-rtl li > a.vakata-context-parent { + background-image: url("data:image/gif;base64,R0lGODlhCwAHAIAAACgoKP///yH5BAEAAAEALAAAAAALAAcAAAINjI+AC7rWHIsPtmoxLAA7"); + background-position: left center; + background-repeat: no-repeat; +} +.vakata-context-rtl .vakata-context-separator > a { + margin: 0 1.8em 0 0; + border-left: 0; + border-right: 1px solid #e2e3e3; +} +.vakata-context-rtl .vakata-context-left ul { + right: auto; + left: 100%; + margin-left: -4px; + margin-right: auto; +} + +.separator-node { + font-family: "Courier New", monospace; +} + +.jstree-default .separator-node::before { + content: "──"; +} + +.jstree-default .separator-node i { + display: none; +} + + + +.jstree-default-responsive .jstree-wholerow-hovered, .vakata-context-hover { + background: #eee; +} +.jstree-default-responsive .jstree-wholerow-clicked { + background: #ddd; +} + +.jstree-default .todo-state-todo { + color: #fc6dac; + font-weight: bold; +} + +.jstree-default .todo-state-waiting { + color: #ff8a00; + font-weight: bold; +} + +.jstree-default .todo-state-postponed { + color: #00b7ee; + font-weight: bold; +} + +.jstree-default .todo-state-cancelled, +.jstree-default .todo-state-overdue { + color: #ff4d26; + font-weight: bold; +} + +.jstree-default .todo-state-done { + color: #00b60e; + font-weight: bold; +} + +.jstree-default .extended-todo { + margin-top: 4px; +} + +.jstree-default .extended-todo .jstree-themeicon { + float: left; +} + +.jstree-default .jstree-anchor { + cursor: pointer; +} + +.jstree-default .extended-todo .jstree-anchor { + display: inline-block; + line-height: normal; + height: auto; + font-size: 98%; +} + +.jstree-default .extended-todo .jstree-wholerow.jstree-wholerow-hovered, +.jstree-default .extended-todo .jstree-wholerow.jstree-wholerow-clicked { + /*height: 31px;*/ +} + +.jstree-default .extended-todo span.todo-path { + font-size: 80%; + font-weight: normal; + font-style: normal; + color: #777; + white-space: nowrap; + text-decoration: none !important; +} + +.jstree-default .extended-todo .todo-details { + color: #777; + font-style: normal; + font-weight: normal; +} + +.jstree-rename-input { + border: 1px solid silver !important; +} + +.jstree-rename-input:focus { + outline: none !important; + border: 1px solid silver !important; +} + +.archive-node { + font-style: italic; +} + +a.has-notes, a.has-notes:hover { + text-decoration: underline; +} + +.container-context-menu i { + background-size: 16px 16px !important; +} + +.node-pending { + color: dimgray; +} diff --git a/android/app/src/main/assets/css/treeview.css b/android/app/src/main/assets/css/treeview.css new file mode 100644 index 00000000..89823631 --- /dev/null +++ b/android/app/src/main/assets/css/treeview.css @@ -0,0 +1,90 @@ +body { + margin: 0; + padding: 0; + position: relative; +} + +#toolbar { + display: flex; + flex: 0 0 auto; +} + +#main-buttons { + flex: 1 0 30%; +} + +#item-filter { + flex: 1 0 65%; + padding-right: 20px; + position: relative; +} + +#search-input { + width: 100%; + padding-left: 25px; + border: 1px solid #555555; + border-radius: 3px; + height: unset; + font-size: unset; +} + +#search-input-clear { + right: 25px; + display: none; +} + +#item-filter::before { + position: absolute; + transform: translate(0,-50%); + left: 6px; + top: 50%; + font-family: "FontAwesome"; + content: "\f002"; +} + +#treeview-container { + position: relative; +} + +#animation, #offline, #empty_sync { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-position: center; + background-repeat: no-repeat; + z-index: 100000; +} + +#animation { + background-image: url("../icons/loader.gif"); +} + +#offline { + background-image: url("../icons/no-connection.svg"); + background-color: white; + background-size: 30%; + display: none; +} + +#empty_sync { + background-color: white; + display: flex; + align-items: center; + align-content: center; + display: none; +} + +#empty_sync p { + text-align: center; + width: 100%; +} + +.themed_text { + color: #007799; +} + +a.has-notes, a.has-notes:hover { + text-decoration: underline !important; +} \ No newline at end of file diff --git a/android/app/src/main/assets/fonts/fontawesome.css b/android/app/src/main/assets/fonts/fontawesome.css new file mode 100644 index 00000000..1054d193 --- /dev/null +++ b/android/app/src/main/assets/fonts/fontawesome.css @@ -0,0 +1,6 @@ +@font-face { + font-family:'FontAwesome'; + src:url('fontawesome.woff2') format('woff2'); + font-weight:normal; + font-style:normal +} \ No newline at end of file diff --git a/android/app/src/main/assets/fonts/fontawesome.woff2 b/android/app/src/main/assets/fonts/fontawesome.woff2 new file mode 100644 index 00000000..dc52d954 Binary files /dev/null and b/android/app/src/main/assets/fonts/fontawesome.woff2 differ diff --git a/android/app/src/main/assets/icons/cloud.svg b/android/app/src/main/assets/icons/cloud.svg new file mode 100644 index 00000000..2b60f17d --- /dev/null +++ b/android/app/src/main/assets/icons/cloud.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/firefox.svg b/android/app/src/main/assets/icons/firefox.svg new file mode 100644 index 00000000..fe2335a3 --- /dev/null +++ b/android/app/src/main/assets/icons/firefox.svg @@ -0,0 +1,58 @@ + + + + + + image/svg+xml + + newtab-firefox-gry + + + + + + newtab-firefox-gry + + diff --git a/android/app/src/main/assets/icons/format-image.svg b/android/app/src/main/assets/icons/format-image.svg new file mode 100644 index 00000000..90fa30cb --- /dev/null +++ b/android/app/src/main/assets/icons/format-image.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/format-pdf.svg b/android/app/src/main/assets/icons/format-pdf.svg new file mode 100644 index 00000000..cc326106 --- /dev/null +++ b/android/app/src/main/assets/icons/format-pdf.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/globe.svg b/android/app/src/main/assets/icons/globe.svg new file mode 100644 index 00000000..136518f0 --- /dev/null +++ b/android/app/src/main/assets/icons/globe.svg @@ -0,0 +1,59 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/android/app/src/main/assets/icons/group.svg b/android/app/src/main/assets/icons/group.svg new file mode 100644 index 00000000..da1f1385 --- /dev/null +++ b/android/app/src/main/assets/icons/group.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/loader.gif b/android/app/src/main/assets/icons/loader.gif new file mode 100644 index 00000000..2eac75cf Binary files /dev/null and b/android/app/src/main/assets/icons/loader.gif differ diff --git a/android/app/src/main/assets/icons/no-connection.svg b/android/app/src/main/assets/icons/no-connection.svg new file mode 100644 index 00000000..6cadae8d --- /dev/null +++ b/android/app/src/main/assets/icons/no-connection.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/notes.svg b/android/app/src/main/assets/icons/notes.svg new file mode 100644 index 00000000..e7d88390 --- /dev/null +++ b/android/app/src/main/assets/icons/notes.svg @@ -0,0 +1,65 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/reload.svg b/android/app/src/main/assets/icons/reload.svg new file mode 100644 index 00000000..13bb16e4 --- /dev/null +++ b/android/app/src/main/assets/icons/reload.svg @@ -0,0 +1,90 @@ + + + + + + image/svg+xml + + + reload + + + + + + + + + + + + + + reload + + + diff --git a/android/app/src/main/assets/icons/shelf.svg b/android/app/src/main/assets/icons/shelf.svg new file mode 100644 index 00000000..0322d89a --- /dev/null +++ b/android/app/src/main/assets/icons/shelf.svg @@ -0,0 +1,95 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/icons/tape.svg b/android/app/src/main/assets/icons/tape.svg new file mode 100644 index 00000000..210dc31c --- /dev/null +++ b/android/app/src/main/assets/icons/tape.svg @@ -0,0 +1,96 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/assets/js/content.js b/android/app/src/main/assets/js/content.js new file mode 100644 index 00000000..5e27d60c --- /dev/null +++ b/android/app/src/main/assets/js/content.js @@ -0,0 +1,44 @@ +var DB_TYPE_CLOUD = "cloud" +var DB_TYPE_SYNC = "sync" + +var ASSET_FILES = "files" +var ASSET_ARCHIVE = "archive_content.blob" +var ASSET_NOTES = "notes.json" + +function replaceDocument(content, asset, dbType) { + var content = extractContent(content, asset, dbType) + content = injectStyles(content, asset) + + document.open(); + document.write(content); + document.close(); +} + +function extractContent(content, asset, dbType) { + var result; + + if (ASSET_NOTES === asset) { + var notes = JSON.parse(content) + + if (notes.html) + result = notes.html; + else + result = "
" + notes.content + "
"; + } + else if (ASSET_ARCHIVE === asset || ASSET_FILES === asset) + result = content + + return result; +} + +function injectStyles(content, asset) { + var links = ""; + + if (ASSET_ARCHIVE === asset) + links = ""; + else if (ASSET_NOTES === asset) + links = "" + + ""; + + return content.replace("", links + ""); +} diff --git a/android/app/src/main/assets/js/proto.js b/android/app/src/main/assets/js/proto.js new file mode 100644 index 00000000..5c9fe972 --- /dev/null +++ b/android/app/src/main/assets/js/proto.js @@ -0,0 +1,17 @@ +String.prototype.repeat || (String.prototype.repeat = function(count) { + if (count < 1) return ''; + var result = '', pattern = this.valueOf(); + while (count > 1) { + if (count & 1) result += pattern; + count >>= 1, pattern += pattern; + } + return result + pattern; +}); + +String.prototype.startsWith || (String.prototype.startsWith = function(word) { + return this.lastIndexOf(word, 0) === 0; +}); + +String.prototype.includes || (String.prototype.includes = function(word) { + return this.indexOf(word, 0) >= 0; +}); \ No newline at end of file diff --git a/android/app/src/main/assets/js/treeview.js b/android/app/src/main/assets/js/treeview.js new file mode 100644 index 00000000..69aff30d --- /dev/null +++ b/android/app/src/main/assets/js/treeview.js @@ -0,0 +1,543 @@ +var DEFAULT_POSITION = 2147483647; + +var FORMAT_TYPE_CLOUD = "cloud"; +var FORMAT_TYPE_INDEX = "index"; + +var NODE_TYPE_SHELF = "shelf"; +var NODE_TYPE_FOLDER = "folder"; +var NODE_TYPE_BOOKMARK = "bookmark"; +var NODE_TYPE_ARCHIVE = "archive"; +var NODE_TYPE_SEPARATOR = "separator"; +var NODE_TYPE_NOTES = "notes"; + +var DEFAULT_SHELF_UUID = "default" + +var CLOUD_SHELF_ID = -5; +var CLOUD_SHELF_NAME = "cloud"; +var CLOUD_EXTERNAL_NAME = "cloud"; + +var RDF_EXTERNAL_TYPE = "rdf"; + +var TODO_STATE_TODO = "TODO"; +var TODO_STATE_DONE = "DONE"; +var TODO_STATE_WAITING = "WAITING"; +var TODO_STATE_POSTPONED = "POSTPONED"; +var TODO_STATE_CANCELLED = "CANCELLED"; + +var IMAGE_FORMATS = [ + "image/png", + "image/bmp", + "image/gif", + "image/tiff", + "image/jpeg", + "image/x-icon", + "image/webp", + "image/svg+xml" +]; + +var CONTENT_TYPES = [NODE_TYPE_ARCHIVE, NODE_TYPE_BOOKMARK, NODE_TYPE_NOTES]; +var CONTAINER_TYPES = [NODE_TYPE_SHELF, NODE_TYPE_FOLDER]; + +var NOTES_OBJECT_FILE = "notes.json" +var ARCHIVE_CONTENT_FILE = "archive_content.blob" +var ARCHIVE_CONTENT_FILES = "files" + +function o(n) { return n.data; } + +function _styleTODO(node) { + if (node.todo_state) + return " todo-state-" + (node._overdue + ? "overdue" + : node.todo_state.toLowerCase()); + + return ""; +} + +function toJsTreeNode(node) { + var jnode = {}; + + jnode.id = node.uuid; + jnode.text = node.title || ""; + jnode.type = node.type; + jnode.icon = node.icon; + jnode.data = node; // store the original Scrapyard node + jnode.parent = node.parent; + + if (!jnode.parent) + jnode.parent = "#"; + + if (node.type === NODE_TYPE_SHELF && node.external === CLOUD_EXTERNAL_NAME) { + jnode.li_attr = {"class": "cloud-shelf"}; + jnode.icon = "icons/cloud.svg"; + } + else if (node.type === NODE_TYPE_SHELF && node.external === RDF_EXTERNAL_TYPE) { + jnode.li_attr = {"class": "rdf-archive"}; + jnode.icon = "icons/tape.svg"; + } + else if (node.type === NODE_TYPE_SHELF) { + jnode.icon = "icons/shelf.svg"; + jnode.li_attr = {"class": "scrapyard-shelf"}; + } + else if (node.type === NODE_TYPE_FOLDER) { + jnode.icon = "icons/group.svg"; + jnode.li_attr = { + class: "scrapyard-group", + }; + } + else if (node.type === NODE_TYPE_SEPARATOR) { + jnode.text = "─".repeat(60); + jnode.icon = false; + jnode.a_attr = { + class: "separator-node" + }; + } + else if (node.type !== NODE_TYPE_SHELF) { + jnode.li_attr = { + class: "show_tooltip", + title: _formatNodeTooltip(node), + "data-clickable": "true" + }; + + if (node.type === NODE_TYPE_ARCHIVE) + jnode.li_attr.class += " archive-node"; + + jnode.a_attr = { + class: node.has_notes? "has-notes": "" + }; + + if (node.todo_state) + jnode.a_attr.class += _styleTODO(node); + + if (node.type === NODE_TYPE_NOTES) + jnode.li_attr.class += " scrapyard-notes"; + + if (node.type === NODE_TYPE_NOTES) + jnode.icon = "icons/notes.svg"; + else if (node.content_type === "application/pdf") + jnode.icon = "icons/format-pdf.svg"; + else if (IMAGE_FORMATS.some(function (f) { return f === node.content_type; })) + jnode.icon = "icons/format-image.svg"; + + if (!jnode.icon && !node.has_icon) { + jnode.icon = "icons/globe.svg"; + jnode.a_attr.class += " generic-icon"; + } + else if (node.has_icon) { + jnode.icon = node.uuid; + } + } + + return jnode; +} + +function _formatNodeTooltip(node) { + return node.title + (node.url? "\x0A" + node.url: ""); +} + +function createTree() { + var plugins = ["wholerow", "contextmenu"]; + + window.rememberTreeState = window.location.hash.includes("rememberTreeState") + if (window.rememberTreeState) + plugins.push("state"); + + $("#treeview").jstree({ + plugins: plugins, + core: { + worker: false, + animation: 0, + multiple: false, + themes: { + name: "default", + dots: false, + icons: true, + }, + check_callback: function(operation, node, parent, position) { + if(operation == 'delete_node') { + return true; + } + } + }, + contextmenu: { + show_at_node: false, + items: contextMenu + }, + state: {} + }); + + window.tree = $("#treeview").jstree(true); +} + +createTree(); + +function contextMenu(jnode) { + var node = jnode.data; + var items = { + openOriginalItem: { + label: "Open Original URL", + action: function () { + if (node.url) + document.location.href = node.url; + } + }, + viewNotesItem: { + label: "View notes", + action: function() { + Android.openArchive(node.uuid, NOTES_OBJECT_FILE); + } + }, + deleteItem: { + label: "Delete", + separator_before: node.has_notes || node.type === NODE_TYPE_ARCHIVE, + action: function() { + if (confirm("Do you really want to delete the selected item?")) { + Android.deleteNode(node.uuid); + window.tree.delete_node(jnode); + } + } + } + }; + + if (node.type !== NODE_TYPE_ARCHIVE) + delete items.openOriginalItem; + + if (!node.has_notes) + delete items.viewNotesItem; + + if (node.type === NODE_TYPE_SHELF && node.external === CLOUD_EXTERNAL_NAME) + delete items.deleteItem; + + return items; +} + +$(document).on("click", ".jstree-anchor", handleMouseClick); + +function handleMouseClick(e) { + var jnode = window.tree.get_selected(true)[0]; + if (jnode) { + var node = o(jnode); + + if (node.type === NODE_TYPE_BOOKMARK) { + document.location.href = node.url; + } + else if (node.type === NODE_TYPE_ARCHIVE) { + if (node.contains === ARCHIVE_CONTENT_FILES) + Android.openArchive(node.uuid, ARCHIVE_CONTENT_FILES); + else if (node.content_type && node.content_type.indexOf("text/html") >= 0) + Android.openArchive(node.uuid, ARCHIVE_CONTENT_FILE); + else if (node.content_type) + Android.downloadArchive(node.uuid, node.title, node.content_type); + else + document.location.href = node.url; + } + else if (node.type === NODE_TYPE_NOTES) { + Android.openArchive(node.uuid, NOTES_OBJECT_FILE); + } + else if (node.type === NODE_TYPE_FOLDER || node.type === NODE_TYPE_SHELF) { + window.tree.toggle_node(jnode.id); + } + } +} + +var INPUT_TIMEOUT = 1000; +var filterInputTimeout; +$("#search-input").on("input", function (e) { + clearTimeout(filterInputTimeout); + + if (e.target.value) { + $("#search-input-clear").show(); + filterInputTimeout = setTimeout(function () { performSearch(e.target.value) }, INPUT_TIMEOUT); + } + else { + filterInputTimeout = null; + $("#search-input-clear").hide(); + performSearch(); + } +}); + +$("#search-input-clear").click(function (e) { + clearSearchInput(); + $("#search-input").trigger("input"); +}); + +function clearSearchInput() { + $("#search-input").val(""); + $("#search-input-clear").hide(); +} + +function performSearch(text) { + if (text) { + if (text.length > 2) { + text = text.toLocaleLowerCase(); + var results = tree.__nodes.filter(function (jnode) { + var node = o(jnode); + return CONTENT_TYPES.some(function (t) { return t == node.type }) + && (node.title && node.title.toLocaleLowerCase().indexOf(text) >= 0 + || node.url && node.url.toLocaleLowerCase().indexOf(text) >= 0); + }); + + listTreeNodes(results); + } + } + else { + tree.settings.core.data = tree.__nodes; + tree.refresh(true); + if (tree.__nodes.length > 0 && tree.__nodes[0].text == CLOUD_SHELF_NAME) + tree.open_node(CLOUD_SHELF_NAME); + } +} + +function listTreeNodes(nodes) { + nodes = nodes.map(function (n) { return $.extend({}, n) }); + nodes.forEach(function (n) { n.parent = "#" }); + tree.settings.core.data = nodes; + + tree.refresh(true); + tree.deselect_all(true); +} + +tree.iconCache = {} + +tree.__icon_set_hook = function(jnode) { + if (jnode.icon && jnode.icon.startsWith("icons/")) { + return "url(\"" + jnode.icon + "\")"; + } + else { + var node = o(jnode) + + if (node && node.download_icon) { + var icon = this.iconCache[node.icon]; + + if (icon) + return "url(\"" + icon + "\")"; + else + return "url(\"icons/globe.svg\")"; + } + else + return "url(\"" + jnode.icon + "\")"; + } +}; + +tree.__icon_check_hook = function(a_element, jnode) { + if (jnode.__icon_validated || (jnode.icon && jnode.icon.startsWith("icons/"))) + return; + + setTimeout(function () { + var node = o(jnode); + + if (node && node.download_icon) { + var cached = tree.iconCache[node.icon]; + + if (cached) + setNodeIcon(cached, a_element) + else + downloadIcon(node, a_element.id); + } + else { + var image = new Image(); + + image.onerror = function(e) { + var fallback_icon = "icons/globe.svg"; + jnode.icon = fallback_icon; + setNodeIcon(fallback_icon, a_element); + }; + image.src = jnode.icon; + } + }, 0); + + jnode.__icon_validated = true; +}; + +function getIconElement(a_element) { + return new Promise(function (resolve, reject) { + var a_element2 = document.getElementById(a_element.id); + if (a_element2) { + resolve(a_element2.childNodes[0]); + } + else { + setTimeout(function () { + var a_element2 = document.getElementById(a_element.id); + if (a_element2) { + resolve(a_element2.childNodes[0]); + } + else { + console.error("can't find icon element"); + resolve(null); + } + }, 100); + } + }); +} + +function setNodeIcon(icon, a_element) { + getIconElement(a_element).then(function (element) { + if (element) + element.style.backgroundImage = "url(\"" + icon + "\")" + }); +} + +function setNodeIconExternal(icon, elementId, hash) { + if (icon) { + tree.iconCache[hash] = icon; + setNodeIcon(icon, {id: elementId}); + } + else + setNodeIcon("icons/globe.svg", {id: elementId}); +} + +function downloadIcon(node, elementId) { + return new Promise(function (resolve, reject) { + Android.downloadIcon(node.uuid, elementId, node.icon); + resolve(); + }) +} + +var root = {id: CLOUD_SHELF_NAME, + pos: -2, + title: CLOUD_SHELF_NAME, + uuid: CLOUD_SHELF_NAME, + type: NODE_TYPE_SHELF, + external: CLOUD_EXTERNAL_NAME + }; + +function byPosition(a, b) { + var a_pos = a.pos === undefined? DEFAULT_POSITION: a.pos; + var b_pos = b.pos === undefined? DEFAULT_POSITION: b.pos; + return a_pos - b_pos; +} + +function injectCloudBookmarks(bookmarks) { + clearSearchInput(); + + var lines = bookmarks.split("\n").filter(function (s) {return !!s}); + var metaJSON = lines.shift(); + var meta; + + if (metaJSON) + meta = JSON.parse(metaJSON); + + if (meta && meta.type === FORMAT_TYPE_CLOUD) + injectCloudShelfBookmarks(lines) + else if (meta && meta.type === FORMAT_TYPE_INDEX) + injectSyncBookmarks(lines) + + hideLoadingAnimation(); +} + +function injectCloudShelfBookmarks(lines) { + var nodes = [root]; + + if (lines) { + lines.forEach(function (line) { + var node = JSON.parse(line); + node.download_icon = node.has_icon; + nodes.push(node); + }); + } + + nodes.sort(byPosition); + + var jnodes = nodes.map(toJsTreeNode); + tree.settings.state.key = "tree-state-cloud"; + addNodesToTree(jnodes); + tree.open_node(CLOUD_SHELF_NAME); +} + +function injectSyncBookmarks(lines) { + var nodes = []; + + if (lines) { + lines.forEach(function (line) { + var node = JSON.parse(line); + + if (node.type === NODE_TYPE_SHELF && node.uuid === DEFAULT_SHELF_UUID) + node.pos = -1; + node.download_icon = node.has_icon; + nodes.push(node); + }); + } + + nodes.sort(byPosition); + + var jnodes = nodes.map(toJsTreeNode); + tree.settings.state.key = "tree-state-sync"; + addNodesToTree(jnodes); +} + +function addNodesToTree(nodes) { + tree.__nodes = nodes; + tree.settings.core.data = nodes; + + if (window.rememberTreeState) { + var stateJSON = localStorage.getItem(tree.settings.state.key); + + if (stateJSON) { + var state = JSON.parse(stateJSON); + + tree.refresh(true, function() {return state.state}); + } + else + tree.refresh(true); + } + else + tree.refresh(true) + + tree.deselect_all() +} + +function handleEmptyContent(dbType) { + if (dbType === "cloud") + handleEmptyCloud() + else if (dbType === "sync") + handleEmptySync() + + hideLoadingAnimation(); +} + +function handleEmptyCloud() { + var nodes = [root]; + var jnodes = nodes.map(toJsTreeNode); + addNodesToTree(jnodes); +} + +function handleEmptySync() { + showEmptySync(); +} + +function handleNoConnection() { + hideLoadingAnimation(); + showOffline(); +} + +function showLoadingAnimation() { + $("#animation").show(); +} + +function hideLoadingAnimation() { + $("#animation").hide(); +} + +function showOffline() { + $("#offline").show(); +} + +function hideOffline() { + $("#offline").hide(); +} + +function showEmptySync() { + $("#empty_sync").css("display", "flex"); +} + +function hideEmptySync() { + $("#empty_sync").hide(); +} + +function hideFillers() { + hideOffline(); + hideEmptySync(); +} + +$("#btnLoad").on("click", function(e) { + Android.refreshTree(); +}); diff --git a/android/app/src/main/assets/lib/jquery.js b/android/app/src/main/assets/lib/jquery.js new file mode 100644 index 00000000..773ad95c --- /dev/null +++ b/android/app/src/main/assets/lib/jquery.js @@ -0,0 +1,10598 @@ +/*! + * jQuery JavaScript Library v3.4.1 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2019-05-01T21:04Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket #14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var document = window.document; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var concat = arr.concat; + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + return typeof obj === "function" && typeof obj.nodeType !== "number"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.4.1", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }, + + // Support: Android <=4.0 only + // Make sure we trim BOM and NBSP + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a global context + globalEval: function( code, options ) { + DOMEval( code, { nonce: options && options.nonce } ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // Support: Android <=4.0 only + trim: function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), +function( i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +} ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.4 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2019-04-08 + */ +(function( window ) { + +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ({}).hasOwnProperty, + arr = [], + pop = arr.pop, + push_native = arr.push, + push = arr.push, + slice = arr.slice, + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[i] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = "(?:\\\\.|[\\w-]|[^\0-\\xa0])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { + var high = "0x" + escaped - 0x10000; + // NaN means non-codepoint + // Support: Firefox<24 + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? + escaped : + high < 0 ? + // BMP codepoint + String.fromCharCode( high + 0x10000 ) : + // Supplemental Plane codepoint (surrogate pair) + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + + // ID selector + if ( (m = match[1]) ) { + + // Document context + if ( nodeType === 9 ) { + if ( (elem = context.getElementById( m )) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && (elem = newContext.getElementById( m )) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( (m = match[3]) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) && + + // Support: IE 8 only + // Exclude object elements + (nodeType !== 1 || context.nodeName.toLowerCase() !== "object") ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && rdescend.test( selector ) ) { + + // Capture the context ID, setting it first if necessary + if ( (nid = context.getAttribute( "id" )) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", (nid = expando) ); + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[i] = "#" + nid + " " + toSelector( groups[i] ); + } + newSelector = groups.join( "," ); + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return (cache[ key + " " ] = value); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement("fieldset"); + + try { + return !!fn( el ); + } catch (e) { + return false; + } finally { + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem.namespaceURI, + docElem = (elem.ownerDocument || elem).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9-11, Edge + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + if ( preferredDoc !== document && + (subWindow = document.defaultView) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert(function( el ) { + el.className = "i"; + return !el.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert(function( el ) { + el.appendChild( document.createComment("") ); + return !el.getElementsByTagName("*").length; + }); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + }); + + // ID filter and find + if ( support.getById ) { + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute("id") === attrId; + }; + }; + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode("id"); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode("id"); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( (elem = elems[i++]) ) { + node = elem.getAttributeNode("id"); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find["TAG"] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( (elem = results[i++]) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( (support.qsa = rnative.test( document.querySelectorAll )) ) { + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( el ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll("[msallowcapture^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push("~="); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push(".#.+[+~]"); + } + }); + + assert(function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement("input"); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll("[name=d]").length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll(":enabled").length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll(":disabled").length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll("*,:x"); + rbuggyQSA.push(",.*:"); + }); + } + + if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector) )) ) { + + assert(function( el ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + }); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + /* Sorting + ---------------------------------------------------------------------- */ + + // Document order sorting + sortOrder = hasCompare ? + function( a, b ) { + + // Flag for duplicate removal + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + // Sort on method existence if only one input has compareDocumentPosition + var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; + if ( compare ) { + return compare; + } + + // Calculate position if both inputs belong to the same document + compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? + a.compareDocumentPosition( b ) : + + // Otherwise we know they are disconnected + 1; + + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + return -1; + } + if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; + } : + function( a, b ) { + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + var cur, + i = 0, + aup = a.parentNode, + bup = b.parentNode, + ap = [ a ], + bp = [ b ]; + + // Parentless nodes are either documents or disconnected + if ( !aup || !bup ) { + return a === document ? -1 : + b === document ? 1 : + aup ? -1 : + bup ? 1 : + sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + + // If the nodes are siblings, we can do a quick check + } else if ( aup === bup ) { + return siblingCheck( a, b ); + } + + // Otherwise we need full lists of their ancestors for comparison + cur = a; + while ( (cur = cur.parentNode) ) { + ap.unshift( cur ); + } + cur = b; + while ( (cur = cur.parentNode) ) { + bp.unshift( cur ); + } + + // Walk down the tree looking for a discrepancy + while ( ap[i] === bp[i] ) { + i++; + } + + return i ? + // Do a sibling check if the nodes have a common ancestor + siblingCheck( ap[i], bp[i] ) : + + // Otherwise nodes in our document sort first + ap[i] === preferredDoc ? -1 : + bp[i] === preferredDoc ? 1 : + 0; + }; + + return document; +}; + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + if ( support.matchesSelector && documentIsHTML && + !nonnativeSelectorCache[ expr + " " ] && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || support.disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch (e) { + nonnativeSelectorCache( expr, true ); + } + } + + return Sizzle( expr, document, null, [ elem ] ).length > 0; +}; + +Sizzle.contains = function( context, elem ) { + // Set document vars if needed + if ( ( context.ownerDocument || context ) !== document ) { + setDocument( context ); + } + return contains( context, elem ); +}; + +Sizzle.attr = function( elem, name ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val !== undefined ? + val : + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null; +}; + +Sizzle.escape = function( sel ) { + return (sel + "").replace( rcssescape, fcssescape ); +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ +Sizzle.uniqueSort = function( results ) { + var elem, + duplicates = [], + j = 0, + i = 0; + + // Unless we *know* we can detect duplicates, assume their presence + hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); + results.sort( sortOrder ); + + if ( hasDuplicate ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { + j = duplicates.push( i ); + } + } + while ( j-- ) { + results.splice( duplicates[ j ], 1 ); + } + } + + // Clear input after sorting to release objects + // See https://github.com/jquery/sizzle/pull/225 + sortInput = null; + + return results; +}; + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( !nodeType ) { + // If no nodeType, this is expected to be an array + while ( (node = elem[i++]) ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (jQuery #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + + return ret; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + attrHandle: {}, + + find: {}, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( runescape, funescape ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 what (child|of-type) + 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 4 xn-component of xn+y argument ([+-]?\d*n|) + 5 sign of xn-component + 6 x of xn-component + 7 sign of y-component + 8 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1].slice( 0, 3 ) === "nth" ) { + // nth-* requires argument + if ( !match[3] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); + match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); + + // other types prohibit arguments + } else if ( match[3] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var excess, + unquoted = !match[6] && match[2]; + + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + // Accept quoted arguments as-is + if ( match[3] ) { + match[2] = match[4] || match[5] || ""; + + // Strip excess characters from unquoted arguments + } else if ( unquoted && rpseudo.test( unquoted ) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + match[0] = match[0].slice( 0, excess ); + match[2] = unquoted.slice( 0, excess ); + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ className + " " ]; + + return pattern || + (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && + classCache( className, function( elem ) { + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); + }); + }, + + "ATTR": function( name, operator, check ) { + return function( elem ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.slice( -check.length ) === check : + operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, what, argument, first, last ) { + var simple = type.slice( 0, 3 ) !== "nth", + forward = type.slice( -4 ) !== "last", + ofType = what === "of-type"; + + return first === 1 && last === 0 ? + + // Shortcut for :nth-*(n) + function( elem ) { + return !!elem.parentNode; + } : + + function( elem, context, xml ) { + var cache, uniqueCache, outerCache, node, nodeIndex, start, + dir = simple !== forward ? "nextSibling" : "previousSibling", + parent = elem.parentNode, + name = ofType && elem.nodeName.toLowerCase(), + useCache = !xml && !ofType, + diff = false; + + if ( parent ) { + + // :(first|last|only)-(child|of-type) + if ( simple ) { + while ( dir ) { + node = elem; + while ( (node = node[ dir ]) ) { + if ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) { + + return false; + } + } + // Reverse direction for :only-* (if we haven't yet done so) + start = dir = type === "only" && !start && "nextSibling"; + } + return true; + } + + start = [ forward ? parent.firstChild : parent.lastChild ]; + + // non-xml :nth-child(...) stores cache data on `parent` + if ( forward && useCache ) { + + // Seek `elem` from a previously-cached index + + // ...in a gzip-friendly way + node = parent; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex && cache[ 2 ]; + node = nodeIndex && parent.childNodes[ nodeIndex ]; + + while ( (node = ++nodeIndex && node && node[ dir ] || + + // Fallback to seeking `elem` from the start + (diff = nodeIndex = 0) || start.pop()) ) { + + // When found, cache indexes on `parent` and break + if ( node.nodeType === 1 && ++diff && node === elem ) { + uniqueCache[ type ] = [ dirruns, nodeIndex, diff ]; + break; + } + } + + } else { + // Use previously-cached element index if available + if ( useCache ) { + // ...in a gzip-friendly way + node = elem; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex; + } + + // xml :nth-child(...) + // or :nth-last-child(...) or :nth(-last)?-of-type(...) + if ( diff === false ) { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) && + ++diff ) { + + // Cache the index of each encountered element + if ( useCache ) { + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + uniqueCache[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } + } + } + } + } + + // Incorporate the offset, then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + // Potentially complex pseudos + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + // Don't keep the element (issue #299) + input[0] = null; + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + text = text.replace( runescape, funescape ); + return function( elem ) { + return ( elem.textContent || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + // "Whether an element is represented by a :lang() selector + // is based solely on the element's language value + // being equal to the identifier C, + // or beginning with the identifier C immediately followed by "-". + // The matching of C against the element's language value is performed case-insensitively. + // The identifier C does not have to be a valid language name." + // http://www.w3.org/TR/selectors/#lang-pseudo + "lang": markFunction( function( lang ) { + // lang value must be a valid identifier + if ( !ridentifier.test(lang || "") ) { + Sizzle.error( "unsupported lang: " + lang ); + } + lang = lang.replace( runescape, funescape ).toLowerCase(); + return function( elem ) { + var elemLang; + do { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { + + elemLang = elemLang.toLowerCase(); + return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; + } + } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); + return false; + }; + }), + + // Miscellaneous + "target": function( elem ) { + var hash = window.location && window.location.hash; + return hash && hash.slice( 1 ) === elem.id; + }, + + "root": function( elem ) { + return elem === docElem; + }, + + "focus": function( elem ) { + return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); + }, + + // Boolean properties + "enabled": createDisabledPseudo( false ), + "disabled": createDisabledPseudo( true ), + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + // Contents + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), + // but not by others (comment: 8; processing instruction: 7; etc.) + // nodeType < 6 works because attributes (2) do not appear as children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + if ( elem.nodeType < 6 ) { + return false; + } + } + return true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + // Element/input types + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "text": function( elem ) { + var attr; + return elem.nodeName.toLowerCase() === "input" && + elem.type === "text" && + + // Support: IE<8 + // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); + }, + + // Position-in-collection + "first": createPositionalPseudo(function() { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length ) { + var i = 0; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length ) { + var i = 1; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? + argument + length : + argument > length ? + length : + argument; + for ( ; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Add button/input type pseudos +for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { + Expr.pseudos[ i ] = createInputPseudo( i ); +} +for ( i in { submit: true, reset: true } ) { + Expr.pseudos[ i ] = createButtonPseudo( i ); +} + +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +tokenize = Sizzle.tokenize = function( selector, parseOnly ) { + var matched, match, tokens, type, + soFar, groups, preFilters, + cached = tokenCache[ selector + " " ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + // Don't consume trailing commas as valid + soFar = soFar.slice( match[0].length ) || soFar; + } + groups.push( (tokens = []) ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + matched = match.shift(); + tokens.push({ + value: matched, + // Cast descendant combinators to space + type: match[0].replace( rtrim, " " ) + }); + soFar = soFar.slice( matched.length ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + (match = preFilters[ type ]( match ))) ) { + matched = match.shift(); + tokens.push({ + value: matched, + type: type, + matches: match + }); + soFar = soFar.slice( matched.length ); + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +}; + +function toSelector( tokens ) { + var i = 0, + len = tokens.length, + selector = ""; + for ( ; i < len; i++ ) { + selector += tokens[i].value; + } + return selector; +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + skip = combinator.next, + key = skip || dir, + checkNonElements = base && key === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + return matcher( elem, context, xml ); + } + } + return false; + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + var oldCache, uniqueCache, outerCache, + newCache = [ dirruns, doneName ]; + + // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching + if ( xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + if ( matcher( elem, context, xml ) ) { + return true; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + outerCache = elem[ expando ] || (elem[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {}); + + if ( skip && skip === elem.nodeName.toLowerCase() ) { + elem = elem[ dir ] || elem; + } else if ( (oldCache = uniqueCache[ key ]) && + oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { + + // Assign to newCache so results back-propagate to previous elements + return (newCache[ 2 ] = oldCache[ 2 ]); + } else { + // Reuse newcache so results back-propagate to previous elements + uniqueCache[ key ] = newCache; + + // A match means we're done; a fail means we have to keep checking + if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { + return true; + } + } + } + } + } + return false; + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + var temp, i, elem, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + temp = condense( matcherOut, postMap ); + postFilter( temp, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = temp.length; + while ( i-- ) { + if ( (elem = temp[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + if ( seed ) { + if ( postFinder || preFilter ) { + if ( postFinder ) { + // Get the final matcherOut by condensing this intermediate into postFinder contexts + temp = []; + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + // Restore matcherIn since elem is not yet a final match + temp.push( (matcherIn[i] = elem) ); + } + } + postFinder( null, (matcherOut = []), temp, xml ); + } + + // Move matched elements from seed to results to keep them synchronized + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) && + (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { + + seed[temp] = !(results[temp] = elem); + } + } + } + + // Add elements to results, through postFinder if defined + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + // Avoid hanging onto element (issue #299) + checkContext = null; + return ret; + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; + } else { + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && toSelector( tokens ) + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + var bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, outermost ) { + var elem, j, matcher, + matchedCount = 0, + i = "0", + unmatched = seed && [], + setMatched = [], + contextBackup = outermostContext, + // We must always have either seed elements or outermost context + elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), + // Use integer dirruns iff this is the outermost matcher + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), + len = elems.length; + + if ( outermost ) { + outermostContext = context === document || context || outermost; + } + + // Add elements passing elementMatchers directly to results + // Support: IE<9, Safari + // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id + for ( ; i !== len && (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + j = 0; + if ( !context && elem.ownerDocument !== document ) { + setDocument( elem ); + xml = !documentIsHTML; + } + while ( (matcher = elementMatchers[j++]) ) { + if ( matcher( elem, context || document, xml) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // `i` is now the count of elements visited above, and adding it to `matchedCount` + // makes the latter nonnegative. + matchedCount += i; + + // Apply set filters to unmatched elements + // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount` + // equals `i`), unless we didn't visit _any_ elements in the above loop because we have + // no element matchers and no seed. + // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that + // case, which will result in a "00" `matchedCount` that differs from `i` but is also + // numerically zero. + if ( bySet && i !== matchedCount ) { + j = 0; + while ( (matcher = setMatchers[j++]) ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ selector + " " ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !match ) { + match = tokenize( selector ); + } + i = match.length; + while ( i-- ) { + cached = matcherFromTokens( match[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + + // Save selector and tokenization + cached.selector = selector; + } + return cached; +}; + +/** + * A low-level selection function that works with Sizzle's compiled + * selector functions + * @param {String|Function} selector A selector or a pre-compiled + * selector function built with Sizzle.compile + * @param {Element} context + * @param {Array} [results] + * @param {Array} [seed] A set of elements to match against + */ +select = Sizzle.select = function( selector, context, results, seed ) { + var i, tokens, token, type, find, + compiled = typeof selector === "function" && selector, + match = !seed && tokenize( (selector = compiled.selector || selector) ); + + results = results || []; + + // Try to minimize operations if there is only one selector in the list and no seed + // (the latter of which guarantees us context) + if ( match.length === 1 ) { + + // Reduce context if the leading compound selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + context.nodeType === 9 && documentIsHTML && Expr.relative[ tokens[1].type ] ) { + + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + + // Precompiled matchers will still verify ancestry, so step up a level + } else if ( compiled ) { + context = context.parentNode; + } + + selector = selector.slice( tokens.shift().value.length ); + } + + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; + } + + break; + } + } + } + } + + // Compile and execute a filtering function if one is not provided + // Provide `match` to avoid retokenization if we modified the selector above + ( compiled || compile( selector, match ) )( + seed, + context, + !documentIsHTML, + results, + !context || rsibling.test( selector ) && testContext( context.parentNode ) || context + ); + return results; +}; + +// One-time assignments + +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; + +// Support: Chrome 14-35+ +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = !!hasDuplicate; + +// Initialize against the default document +setDocument(); + +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( el ) { + // Should return 1, but returns 4 (following) + return el.compareDocumentPosition( document.createElement("fieldset") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// https://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( el ) { + el.innerHTML = ""; + return el.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( el ) { + el.innerHTML = ""; + el.firstChild.setAttribute( "value", "" ); + return el.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( el ) { + return el.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return elem[ name ] === true ? name.toLowerCase() : + (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + null; + } + }); +} + +return Sizzle; + +})( window ); + + + +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; + +// Deprecated +jQuery.expr[ ":" ] = jQuery.expr.pseudos; +jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; +jQuery.escapeSelector = Sizzle.escape; + + + + +var dir = function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; +}; + + +var siblings = function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; +}; + + +var rneedsContext = jQuery.expr.match.needsContext; + + + +function nodeName( elem, name ) { + + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + +}; +var rsingleTag = ( /^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i ); + + + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + return !!qualifier.call( elem, i, elem ) !== not; + } ); + } + + // Single element + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + } ); + } + + // Arraylike of elements (jQuery, arguments, Array) + if ( typeof qualifier !== "string" ) { + return jQuery.grep( elements, function( elem ) { + return ( indexOf.call( qualifier, elem ) > -1 ) !== not; + } ); + } + + // Filtered directly for both simple and complex selectors + return jQuery.filter( qualifier, elements, not ); +} + +jQuery.filter = function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + if ( elems.length === 1 && elem.nodeType === 1 ) { + return jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : []; + } + + return jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + } ) ); +}; + +jQuery.fn.extend( { + find: function( selector ) { + var i, ret, + len = this.length, + self = this; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter( function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + } ) ); + } + + ret = this.pushStack( [] ); + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + return len > 1 ? jQuery.uniqueSort( ret ) : ret; + }, + filter: function( selector ) { + return this.pushStack( winnow( this, selector || [], false ) ); + }, + not: function( selector ) { + return this.pushStack( winnow( this, selector || [], true ) ); + }, + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + } +} ); + + +// Initialize a jQuery object + + +// A central reference to the root jQuery(document) +var rootjQuery, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + // Shortcut simple #id case for speed + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/, + + init = jQuery.fn.init = function( selector, context, root ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Method init() accepts an alternate rootjQuery + // so migrate can support jQuery.sub (gh-2101) + root = root || rootjQuery; + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector[ 0 ] === "<" && + selector[ selector.length - 1 ] === ">" && + selector.length >= 3 ) { + + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && ( match[ 1 ] || !context ) ) { + + // HANDLE: $(html) -> $(array) + if ( match[ 1 ] ) { + context = context instanceof jQuery ? context[ 0 ] : context; + + // Option to run scripts is true for back-compat + // Intentionally let the error be thrown if parseHTML is not present + jQuery.merge( this, jQuery.parseHTML( + match[ 1 ], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + + // Properties of context are called as methods if possible + if ( isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[ 2 ] ); + + if ( elem ) { + + // Inject the element directly into the jQuery object + this[ 0 ] = elem; + this.length = 1; + } + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || root ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this[ 0 ] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( isFunction( selector ) ) { + return root.ready !== undefined ? + root.ready( selector ) : + + // Execute immediately if ready is not present + selector( jQuery ); + } + + return jQuery.makeArray( selector, this ); + }; + +// Give the init function the jQuery prototype for later instantiation +init.prototype = jQuery.fn; + +// Initialize central reference +rootjQuery = jQuery( document ); + + +var rparentsprev = /^(?:parents|prev(?:Until|All))/, + + // Methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.fn.extend( { + has: function( target ) { + var targets = jQuery( target, this ), + l = targets.length; + + return this.filter( function() { + var i = 0; + for ( ; i < l; i++ ) { + if ( jQuery.contains( this, targets[ i ] ) ) { + return true; + } + } + } ); + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + matched = [], + targets = typeof selectors !== "string" && jQuery( selectors ); + + // Positional selectors never match, since there's no _selection_ context + if ( !rneedsContext.test( selectors ) ) { + for ( ; i < l; i++ ) { + for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) { + + // Always skip document fragments + if ( cur.nodeType < 11 && ( targets ? + targets.index( cur ) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector( cur, selectors ) ) ) { + + matched.push( cur ); + break; + } + } + } + } + + return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched ); + }, + + // Determine the position of an element within the set + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; + } + + // Index in selector + if ( typeof elem === "string" ) { + return indexOf.call( jQuery( elem ), this[ 0 ] ); + } + + // Locate the position of the desired element + return indexOf.call( this, + + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[ 0 ] : elem + ); + }, + + add: function( selector, context ) { + return this.pushStack( + jQuery.uniqueSort( + jQuery.merge( this.get(), jQuery( selector, context ) ) + ) + ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + } +} ); + +function sibling( cur, dir ) { + while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {} + return cur; +} + +jQuery.each( { + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return siblings( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return siblings( elem.firstChild ); + }, + contents: function( elem ) { + if ( typeof elem.contentDocument !== "undefined" ) { + return elem.contentDocument; + } + + // Support: IE 9 - 11 only, iOS 7 only, Android Browser <=4.3 only + // Treat the template element as a regular one in browsers that + // don't support it. + if ( nodeName( elem, "template" ) ) { + elem = elem.content || elem; + } + + return jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var matched = jQuery.map( this, fn, until ); + + if ( name.slice( -5 ) !== "Until" ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + matched = jQuery.filter( selector, matched ); + } + + if ( this.length > 1 ) { + + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + jQuery.uniqueSort( matched ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + matched.reverse(); + } + } + + return this.pushStack( matched ); + }; +} ); +var rnothtmlwhite = ( /[^\x20\t\r\n\f]+/g ); + + + +// Convert String-formatted options into Object-formatted ones +function createOptions( options ) { + var object = {}; + jQuery.each( options.match( rnothtmlwhite ) || [], function( _, flag ) { + object[ flag ] = true; + } ); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + createOptions( options ) : + jQuery.extend( {}, options ); + + var // Flag to know if list is currently firing + firing, + + // Last fire value for non-forgettable lists + memory, + + // Flag to know if list was already fired + fired, + + // Flag to prevent firing + locked, + + // Actual callback list + list = [], + + // Queue of execution data for repeatable lists + queue = [], + + // Index of currently firing callback (modified by add/remove as needed) + firingIndex = -1, + + // Fire callbacks + fire = function() { + + // Enforce single-firing + locked = locked || options.once; + + // Execute callbacks for all pending executions, + // respecting firingIndex overrides and runtime changes + fired = firing = true; + for ( ; queue.length; firingIndex = -1 ) { + memory = queue.shift(); + while ( ++firingIndex < list.length ) { + + // Run callback and check for early termination + if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && + options.stopOnFalse ) { + + // Jump to end and forget the data so .add doesn't re-fire + firingIndex = list.length; + memory = false; + } + } + } + + // Forget the data if we're done with it + if ( !options.memory ) { + memory = false; + } + + firing = false; + + // Clean up if we're done firing for good + if ( locked ) { + + // Keep an empty list if we have data for future add calls + if ( memory ) { + list = []; + + // Otherwise, this object is spent + } else { + list = ""; + } + } + }, + + // Actual Callbacks object + self = { + + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + + // If we have memory from a past run, we should fire after adding + if ( memory && !firing ) { + firingIndex = list.length - 1; + queue.push( memory ); + } + + ( function add( args ) { + jQuery.each( args, function( _, arg ) { + if ( isFunction( arg ) ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && toType( arg ) !== "string" ) { + + // Inspect recursively + add( arg ); + } + } ); + } )( arguments ); + + if ( memory && !firing ) { + fire(); + } + } + return this; + }, + + // Remove a callback from the list + remove: function() { + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + + // Handle firing indexes + if ( index <= firingIndex ) { + firingIndex--; + } + } + } ); + return this; + }, + + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? + jQuery.inArray( fn, list ) > -1 : + list.length > 0; + }, + + // Remove all callbacks from the list + empty: function() { + if ( list ) { + list = []; + } + return this; + }, + + // Disable .fire and .add + // Abort any current/pending executions + // Clear all callbacks and values + disable: function() { + locked = queue = []; + list = memory = ""; + return this; + }, + disabled: function() { + return !list; + }, + + // Disable .fire + // Also disable .add unless we have memory (since it would have no effect) + // Abort any pending executions + lock: function() { + locked = queue = []; + if ( !memory && !firing ) { + list = memory = ""; + } + return this; + }, + locked: function() { + return !!locked; + }, + + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( !locked ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + queue.push( args ); + if ( !firing ) { + fire(); + } + } + return this; + }, + + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; + + +function Identity( v ) { + return v; +} +function Thrower( ex ) { + throw ex; +} + +function adoptValue( value, resolve, reject, noValue ) { + var method; + + try { + + // Check for promise aspect first to privilege synchronous behavior + if ( value && isFunction( ( method = value.promise ) ) ) { + method.call( value ).done( resolve ).fail( reject ); + + // Other thenables + } else if ( value && isFunction( ( method = value.then ) ) ) { + method.call( value, resolve, reject ); + + // Other non-thenables + } else { + + // Control `resolve` arguments by letting Array#slice cast boolean `noValue` to integer: + // * false: [ value ].slice( 0 ) => resolve( value ) + // * true: [ value ].slice( 1 ) => resolve() + resolve.apply( undefined, [ value ].slice( noValue ) ); + } + + // For Promises/A+, convert exceptions into rejections + // Since jQuery.when doesn't unwrap thenables, we can skip the extra checks appearing in + // Deferred#then to conditionally suppress rejection. + } catch ( value ) { + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + reject.apply( undefined, [ value ] ); + } +} + +jQuery.extend( { + + Deferred: function( func ) { + var tuples = [ + + // action, add listener, callbacks, + // ... .then handlers, argument index, [final state] + [ "notify", "progress", jQuery.Callbacks( "memory" ), + jQuery.Callbacks( "memory" ), 2 ], + [ "resolve", "done", jQuery.Callbacks( "once memory" ), + jQuery.Callbacks( "once memory" ), 0, "resolved" ], + [ "reject", "fail", jQuery.Callbacks( "once memory" ), + jQuery.Callbacks( "once memory" ), 1, "rejected" ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + "catch": function( fn ) { + return promise.then( null, fn ); + }, + + // Keep pipe for back-compat + pipe: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + + return jQuery.Deferred( function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + + // Map tuples (progress, done, fail) to arguments (done, fail, progress) + var fn = isFunction( fns[ tuple[ 4 ] ] ) && fns[ tuple[ 4 ] ]; + + // deferred.progress(function() { bind to newDefer or newDefer.notify }) + // deferred.done(function() { bind to newDefer or newDefer.resolve }) + // deferred.fail(function() { bind to newDefer or newDefer.reject }) + deferred[ tuple[ 1 ] ]( function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && isFunction( returned.promise ) ) { + returned.promise() + .progress( newDefer.notify ) + .done( newDefer.resolve ) + .fail( newDefer.reject ); + } else { + newDefer[ tuple[ 0 ] + "With" ]( + this, + fn ? [ returned ] : arguments + ); + } + } ); + } ); + fns = null; + } ).promise(); + }, + then: function( onFulfilled, onRejected, onProgress ) { + var maxDepth = 0; + function resolve( depth, deferred, handler, special ) { + return function() { + var that = this, + args = arguments, + mightThrow = function() { + var returned, then; + + // Support: Promises/A+ section 2.3.3.3.3 + // https://promisesaplus.com/#point-59 + // Ignore double-resolution attempts + if ( depth < maxDepth ) { + return; + } + + returned = handler.apply( that, args ); + + // Support: Promises/A+ section 2.3.1 + // https://promisesaplus.com/#point-48 + if ( returned === deferred.promise() ) { + throw new TypeError( "Thenable self-resolution" ); + } + + // Support: Promises/A+ sections 2.3.3.1, 3.5 + // https://promisesaplus.com/#point-54 + // https://promisesaplus.com/#point-75 + // Retrieve `then` only once + then = returned && + + // Support: Promises/A+ section 2.3.4 + // https://promisesaplus.com/#point-64 + // Only check objects and functions for thenability + ( typeof returned === "object" || + typeof returned === "function" ) && + returned.then; + + // Handle a returned thenable + if ( isFunction( then ) ) { + + // Special processors (notify) just wait for resolution + if ( special ) { + then.call( + returned, + resolve( maxDepth, deferred, Identity, special ), + resolve( maxDepth, deferred, Thrower, special ) + ); + + // Normal processors (resolve) also hook into progress + } else { + + // ...and disregard older resolution values + maxDepth++; + + then.call( + returned, + resolve( maxDepth, deferred, Identity, special ), + resolve( maxDepth, deferred, Thrower, special ), + resolve( maxDepth, deferred, Identity, + deferred.notifyWith ) + ); + } + + // Handle all other returned values + } else { + + // Only substitute handlers pass on context + // and multiple values (non-spec behavior) + if ( handler !== Identity ) { + that = undefined; + args = [ returned ]; + } + + // Process the value(s) + // Default process is resolve + ( special || deferred.resolveWith )( that, args ); + } + }, + + // Only normal processors (resolve) catch and reject exceptions + process = special ? + mightThrow : + function() { + try { + mightThrow(); + } catch ( e ) { + + if ( jQuery.Deferred.exceptionHook ) { + jQuery.Deferred.exceptionHook( e, + process.stackTrace ); + } + + // Support: Promises/A+ section 2.3.3.3.4.1 + // https://promisesaplus.com/#point-61 + // Ignore post-resolution exceptions + if ( depth + 1 >= maxDepth ) { + + // Only substitute handlers pass on context + // and multiple values (non-spec behavior) + if ( handler !== Thrower ) { + that = undefined; + args = [ e ]; + } + + deferred.rejectWith( that, args ); + } + } + }; + + // Support: Promises/A+ section 2.3.3.3.1 + // https://promisesaplus.com/#point-57 + // Re-resolve promises immediately to dodge false rejection from + // subsequent errors + if ( depth ) { + process(); + } else { + + // Call an optional hook to record the stack, in case of exception + // since it's otherwise lost when execution goes async + if ( jQuery.Deferred.getStackHook ) { + process.stackTrace = jQuery.Deferred.getStackHook(); + } + window.setTimeout( process ); + } + }; + } + + return jQuery.Deferred( function( newDefer ) { + + // progress_handlers.add( ... ) + tuples[ 0 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onProgress ) ? + onProgress : + Identity, + newDefer.notifyWith + ) + ); + + // fulfilled_handlers.add( ... ) + tuples[ 1 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onFulfilled ) ? + onFulfilled : + Identity + ) + ); + + // rejected_handlers.add( ... ) + tuples[ 2 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onRejected ) ? + onRejected : + Thrower + ) + ); + } ).promise(); + }, + + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 5 ]; + + // promise.progress = list.add + // promise.done = list.add + // promise.fail = list.add + promise[ tuple[ 1 ] ] = list.add; + + // Handle state + if ( stateString ) { + list.add( + function() { + + // state = "resolved" (i.e., fulfilled) + // state = "rejected" + state = stateString; + }, + + // rejected_callbacks.disable + // fulfilled_callbacks.disable + tuples[ 3 - i ][ 2 ].disable, + + // rejected_handlers.disable + // fulfilled_handlers.disable + tuples[ 3 - i ][ 3 ].disable, + + // progress_callbacks.lock + tuples[ 0 ][ 2 ].lock, + + // progress_handlers.lock + tuples[ 0 ][ 3 ].lock + ); + } + + // progress_handlers.fire + // fulfilled_handlers.fire + // rejected_handlers.fire + list.add( tuple[ 3 ].fire ); + + // deferred.notify = function() { deferred.notifyWith(...) } + // deferred.resolve = function() { deferred.resolveWith(...) } + // deferred.reject = function() { deferred.rejectWith(...) } + deferred[ tuple[ 0 ] ] = function() { + deferred[ tuple[ 0 ] + "With" ]( this === deferred ? undefined : this, arguments ); + return this; + }; + + // deferred.notifyWith = list.fireWith + // deferred.resolveWith = list.fireWith + // deferred.rejectWith = list.fireWith + deferred[ tuple[ 0 ] + "With" ] = list.fireWith; + } ); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( singleValue ) { + var + + // count of uncompleted subordinates + remaining = arguments.length, + + // count of unprocessed arguments + i = remaining, + + // subordinate fulfillment data + resolveContexts = Array( i ), + resolveValues = slice.call( arguments ), + + // the master Deferred + master = jQuery.Deferred(), + + // subordinate callback factory + updateFunc = function( i ) { + return function( value ) { + resolveContexts[ i ] = this; + resolveValues[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; + if ( !( --remaining ) ) { + master.resolveWith( resolveContexts, resolveValues ); + } + }; + }; + + // Single- and empty arguments are adopted like Promise.resolve + if ( remaining <= 1 ) { + adoptValue( singleValue, master.done( updateFunc( i ) ).resolve, master.reject, + !remaining ); + + // Use .then() to unwrap secondary thenables (cf. gh-3000) + if ( master.state() === "pending" || + isFunction( resolveValues[ i ] && resolveValues[ i ].then ) ) { + + return master.then(); + } + } + + // Multiple arguments are aggregated like Promise.all array elements + while ( i-- ) { + adoptValue( resolveValues[ i ], updateFunc( i ), master.reject ); + } + + return master.promise(); + } +} ); + + +// These usually indicate a programmer mistake during development, +// warn about them ASAP rather than swallowing them by default. +var rerrorNames = /^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/; + +jQuery.Deferred.exceptionHook = function( error, stack ) { + + // Support: IE 8 - 9 only + // Console exists when dev tools are open, which can happen at any time + if ( window.console && window.console.warn && error && rerrorNames.test( error.name ) ) { + window.console.warn( "jQuery.Deferred exception: " + error.message, error.stack, stack ); + } +}; + + + + +jQuery.readyException = function( error ) { + window.setTimeout( function() { + throw error; + } ); +}; + + + + +// The deferred used on DOM ready +var readyList = jQuery.Deferred(); + +jQuery.fn.ready = function( fn ) { + + readyList + .then( fn ) + + // Wrap jQuery.readyException in a function so that the lookup + // happens at the time of error handling instead of callback + // registration. + .catch( function( error ) { + jQuery.readyException( error ); + } ); + + return this; +}; + +jQuery.extend( { + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + } +} ); + +jQuery.ready.then = readyList.then; + +// The ready event handler and self cleanup method +function completed() { + document.removeEventListener( "DOMContentLoaded", completed ); + window.removeEventListener( "load", completed ); + jQuery.ready(); +} + +// Catch cases where $(document).ready() is called +// after the browser event has already occurred. +// Support: IE <=9 - 10 only +// Older IE sometimes signals "interactive" too soon +if ( document.readyState === "complete" || + ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) { + + // Handle it asynchronously to allow scripts the opportunity to delay ready + window.setTimeout( jQuery.ready ); + +} else { + + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed ); +} + + + + +// Multifunctional method to get and set values of a collection +// The value/s can optionally be executed if it's a function +var access = function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + len = elems.length, + bulk = key == null; + + // Sets many values + if ( toType( key ) === "object" ) { + chainable = true; + for ( i in key ) { + access( elems, fn, i, key[ i ], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < len; i++ ) { + fn( + elems[ i ], key, raw ? + value : + value.call( elems[ i ], i, fn( elems[ i ], key ) ) + ); + } + } + } + + if ( chainable ) { + return elems; + } + + // Gets + if ( bulk ) { + return fn.call( elems ); + } + + return len ? fn( elems[ 0 ], key ) : emptyGet; +}; + + +// Matches dashed string for camelizing +var rmsPrefix = /^-ms-/, + rdashAlpha = /-([a-z])/g; + +// Used by camelCase as callback to replace() +function fcamelCase( all, letter ) { + return letter.toUpperCase(); +} + +// Convert dashed to camelCase; used by the css and data modules +// Support: IE <=9 - 11, Edge 12 - 15 +// Microsoft forgot to hump their vendor prefix (#9572) +function camelCase( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); +} +var acceptData = function( owner ) { + + // Accepts only: + // - Node + // - Node.ELEMENT_NODE + // - Node.DOCUMENT_NODE + // - Object + // - Any + return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType ); +}; + + + + +function Data() { + this.expando = jQuery.expando + Data.uid++; +} + +Data.uid = 1; + +Data.prototype = { + + cache: function( owner ) { + + // Check if the owner object already has a cache + var value = owner[ this.expando ]; + + // If not, create one + if ( !value ) { + value = {}; + + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return an empty object. + if ( acceptData( owner ) ) { + + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable property + // configurable must be true to allow the property to be + // deleted when data is removed + } else { + Object.defineProperty( owner, this.expando, { + value: value, + configurable: true + } ); + } + } + } + + return value; + }, + set: function( owner, data, value ) { + var prop, + cache = this.cache( owner ); + + // Handle: [ owner, key, value ] args + // Always use camelCase key (gh-2257) + if ( typeof data === "string" ) { + cache[ camelCase( data ) ] = value; + + // Handle: [ owner, { properties } ] args + } else { + + // Copy the properties one-by-one to the cache object + for ( prop in data ) { + cache[ camelCase( prop ) ] = data[ prop ]; + } + } + return cache; + }, + get: function( owner, key ) { + return key === undefined ? + this.cache( owner ) : + + // Always use camelCase key (gh-2257) + owner[ this.expando ] && owner[ this.expando ][ camelCase( key ) ]; + }, + access: function( owner, key, value ) { + + // In cases where either: + // + // 1. No key was specified + // 2. A string key was specified, but no value provided + // + // Take the "read" path and allow the get method to determine + // which value to return, respectively either: + // + // 1. The entire cache object + // 2. The data stored at the key + // + if ( key === undefined || + ( ( key && typeof key === "string" ) && value === undefined ) ) { + + return this.get( owner, key ); + } + + // When the key is not a string, or both a key and value + // are specified, set or extend (existing objects) with either: + // + // 1. An object of properties + // 2. A key and value + // + this.set( owner, key, value ); + + // Since the "set" path can have two possible entry points + // return the expected data based on which path was taken[*] + return value !== undefined ? value : key; + }, + remove: function( owner, key ) { + var i, + cache = owner[ this.expando ]; + + if ( cache === undefined ) { + return; + } + + if ( key !== undefined ) { + + // Support array or space separated string of keys + if ( Array.isArray( key ) ) { + + // If key is an array of keys... + // We always set camelCase keys, so remove that. + key = key.map( camelCase ); + } else { + key = camelCase( key ); + + // If a key with the spaces exists, use it. + // Otherwise, create an array by matching non-whitespace + key = key in cache ? + [ key ] : + ( key.match( rnothtmlwhite ) || [] ); + } + + i = key.length; + + while ( i-- ) { + delete cache[ key[ i ] ]; + } + } + + // Remove the expando if there's no more data + if ( key === undefined || jQuery.isEmptyObject( cache ) ) { + + // Support: Chrome <=35 - 45 + // Webkit & Blink performance suffers when deleting properties + // from DOM nodes, so set to undefined instead + // https://bugs.chromium.org/p/chromium/issues/detail?id=378607 (bug restricted) + if ( owner.nodeType ) { + owner[ this.expando ] = undefined; + } else { + delete owner[ this.expando ]; + } + } + }, + hasData: function( owner ) { + var cache = owner[ this.expando ]; + return cache !== undefined && !jQuery.isEmptyObject( cache ); + } +}; +var dataPriv = new Data(); + +var dataUser = new Data(); + + + +// Implementation Summary +// +// 1. Enforce API surface and semantic compatibility with 1.9.x branch +// 2. Improve the module's maintainability by reducing the storage +// paths to a single mechanism. +// 3. Use the same single mechanism to support "private" and "user" data. +// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData) +// 5. Avoid exposing implementation details on user objects (eg. expando properties) +// 6. Provide a clear path for implementation upgrade to WeakMap in 2014 + +var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, + rmultiDash = /[A-Z]/g; + +function getData( data ) { + if ( data === "true" ) { + return true; + } + + if ( data === "false" ) { + return false; + } + + if ( data === "null" ) { + return null; + } + + // Only convert to a number if it doesn't change the string + if ( data === +data + "" ) { + return +data; + } + + if ( rbrace.test( data ) ) { + return JSON.parse( data ); + } + + return data; +} + +function dataAttr( elem, key, data ) { + var name; + + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + name = "data-" + key.replace( rmultiDash, "-$&" ).toLowerCase(); + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = getData( data ); + } catch ( e ) {} + + // Make sure we set the data so it isn't changed later + dataUser.set( elem, key, data ); + } else { + data = undefined; + } + } + return data; +} + +jQuery.extend( { + hasData: function( elem ) { + return dataUser.hasData( elem ) || dataPriv.hasData( elem ); + }, + + data: function( elem, name, data ) { + return dataUser.access( elem, name, data ); + }, + + removeData: function( elem, name ) { + dataUser.remove( elem, name ); + }, + + // TODO: Now that all calls to _data and _removeData have been replaced + // with direct calls to dataPriv methods, these can be deprecated. + _data: function( elem, name, data ) { + return dataPriv.access( elem, name, data ); + }, + + _removeData: function( elem, name ) { + dataPriv.remove( elem, name ); + } +} ); + +jQuery.fn.extend( { + data: function( key, value ) { + var i, name, data, + elem = this[ 0 ], + attrs = elem && elem.attributes; + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = dataUser.get( elem ); + + if ( elem.nodeType === 1 && !dataPriv.get( elem, "hasDataAttrs" ) ) { + i = attrs.length; + while ( i-- ) { + + // Support: IE 11 only + // The attrs elements can be null (#14894) + if ( attrs[ i ] ) { + name = attrs[ i ].name; + if ( name.indexOf( "data-" ) === 0 ) { + name = camelCase( name.slice( 5 ) ); + dataAttr( elem, name, data[ name ] ); + } + } + } + dataPriv.set( elem, "hasDataAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each( function() { + dataUser.set( this, key ); + } ); + } + + return access( this, function( value ) { + var data; + + // The calling jQuery object (element matches) is not empty + // (and therefore has an element appears at this[ 0 ]) and the + // `value` parameter was not undefined. An empty jQuery object + // will result in `undefined` for elem = this[ 0 ] which will + // throw an exception if an attempt to read a data cache is made. + if ( elem && value === undefined ) { + + // Attempt to get data from the cache + // The key will always be camelCased in Data + data = dataUser.get( elem, key ); + if ( data !== undefined ) { + return data; + } + + // Attempt to "discover" the data in + // HTML5 custom data-* attrs + data = dataAttr( elem, key ); + if ( data !== undefined ) { + return data; + } + + // We tried really hard, but the data doesn't exist. + return; + } + + // Set the data... + this.each( function() { + + // We always store the camelCased key + dataUser.set( this, key, value ); + } ); + }, null, value, arguments.length > 1, null, true ); + }, + + removeData: function( key ) { + return this.each( function() { + dataUser.remove( this, key ); + } ); + } +} ); + + +jQuery.extend( { + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = dataPriv.get( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || Array.isArray( data ) ) { + queue = dataPriv.access( elem, type, jQuery.makeArray( data ) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // Clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // Not public - generate a queueHooks object, or return the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return dataPriv.get( elem, key ) || dataPriv.access( elem, key, { + empty: jQuery.Callbacks( "once memory" ).add( function() { + dataPriv.remove( elem, [ type + "queue", key ] ); + } ) + } ); + } +} ); + +jQuery.fn.extend( { + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[ 0 ], type ); + } + + return data === undefined ? + this : + this.each( function() { + var queue = jQuery.queue( this, type, data ); + + // Ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[ 0 ] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + } ); + }, + dequeue: function( type ) { + return this.each( function() { + jQuery.dequeue( this, type ); + } ); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while ( i-- ) { + tmp = dataPriv.get( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +} ); +var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source; + +var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); + + +var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; + +var documentElement = document.documentElement; + + + + var isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ); + }, + composed = { composed: true }; + + // Support: IE 9 - 11+, Edge 12 - 18+, iOS 10.0 - 10.2 only + // Check attachment across shadow DOM boundaries when possible (gh-3504) + // Support: iOS 10.0-10.2 only + // Early iOS 10 versions support `attachShadow` but not `getRootNode`, + // leading to errors. We need to check for `getRootNode`. + if ( documentElement.getRootNode ) { + isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ) || + elem.getRootNode( composed ) === elem.ownerDocument; + }; + } +var isHiddenWithinTree = function( elem, el ) { + + // isHiddenWithinTree might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + + // Inline style trumps all + return elem.style.display === "none" || + elem.style.display === "" && + + // Otherwise, check computed style + // Support: Firefox <=43 - 45 + // Disconnected elements can have computed display: none, so first confirm that elem is + // in the document. + isAttached( elem ) && + + jQuery.css( elem, "display" ) === "none"; + }; + +var swap = function( elem, options, callback, args ) { + var ret, name, + old = {}; + + // Remember the old values, and insert the new ones + for ( name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + ret = callback.apply( elem, args || [] ); + + // Revert the old values + for ( name in options ) { + elem.style[ name ] = old[ name ]; + } + + return ret; +}; + + + + +function adjustCSS( elem, prop, valueParts, tween ) { + var adjusted, scale, + maxIterations = 20, + currentValue = tween ? + function() { + return tween.cur(); + } : + function() { + return jQuery.css( elem, prop, "" ); + }, + initial = currentValue(), + unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), + + // Starting value computation is required for potential unit mismatches + initialInUnit = elem.nodeType && + ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + rcssNum.exec( jQuery.css( elem, prop ) ); + + if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { + + // Support: Firefox <=54 + // Halve the iteration target value to prevent interference from CSS upper bounds (gh-2144) + initial = initial / 2; + + // Trust units reported by jQuery.css + unit = unit || initialInUnit[ 3 ]; + + // Iteratively approximate from a nonzero starting point + initialInUnit = +initial || 1; + + while ( maxIterations-- ) { + + // Evaluate and update our best guess (doubling guesses that zero out). + // Finish if the scale equals or crosses 1 (making the old*new product non-positive). + jQuery.style( elem, prop, initialInUnit + unit ); + if ( ( 1 - scale ) * ( 1 - ( scale = currentValue() / initial || 0.5 ) ) <= 0 ) { + maxIterations = 0; + } + initialInUnit = initialInUnit / scale; + + } + + initialInUnit = initialInUnit * 2; + jQuery.style( elem, prop, initialInUnit + unit ); + + // Make sure we update the tween properties later on + valueParts = valueParts || []; + } + + if ( valueParts ) { + initialInUnit = +initialInUnit || +initial || 0; + + // Apply relative offset (+=/-=) if specified + adjusted = valueParts[ 1 ] ? + initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] : + +valueParts[ 2 ]; + if ( tween ) { + tween.unit = unit; + tween.start = initialInUnit; + tween.end = adjusted; + } + } + return adjusted; +} + + +var defaultDisplayMap = {}; + +function getDefaultDisplay( elem ) { + var temp, + doc = elem.ownerDocument, + nodeName = elem.nodeName, + display = defaultDisplayMap[ nodeName ]; + + if ( display ) { + return display; + } + + temp = doc.body.appendChild( doc.createElement( nodeName ) ); + display = jQuery.css( temp, "display" ); + + temp.parentNode.removeChild( temp ); + + if ( display === "none" ) { + display = "block"; + } + defaultDisplayMap[ nodeName ] = display; + + return display; +} + +function showHide( elements, show ) { + var display, elem, + values = [], + index = 0, + length = elements.length; + + // Determine new display value for elements that need to change + for ( ; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + + display = elem.style.display; + if ( show ) { + + // Since we force visibility upon cascade-hidden elements, an immediate (and slow) + // check is required in this first loop unless we have a nonempty display value (either + // inline or about-to-be-restored) + if ( display === "none" ) { + values[ index ] = dataPriv.get( elem, "display" ) || null; + if ( !values[ index ] ) { + elem.style.display = ""; + } + } + if ( elem.style.display === "" && isHiddenWithinTree( elem ) ) { + values[ index ] = getDefaultDisplay( elem ); + } + } else { + if ( display !== "none" ) { + values[ index ] = "none"; + + // Remember what we're overwriting + dataPriv.set( elem, "display", display ); + } + } + } + + // Set the display of the elements in a second loop to avoid constant reflow + for ( index = 0; index < length; index++ ) { + if ( values[ index ] != null ) { + elements[ index ].style.display = values[ index ]; + } + } + + return elements; +} + +jQuery.fn.extend( { + show: function() { + return showHide( this, true ); + }, + hide: function() { + return showHide( this ); + }, + toggle: function( state ) { + if ( typeof state === "boolean" ) { + return state ? this.show() : this.hide(); + } + + return this.each( function() { + if ( isHiddenWithinTree( this ) ) { + jQuery( this ).show(); + } else { + jQuery( this ).hide(); + } + } ); + } +} ); +var rcheckableType = ( /^(?:checkbox|radio)$/i ); + +var rtagName = ( /<([a-z][^\/\0>\x20\t\r\n\f]*)/i ); + +var rscriptType = ( /^$|^module$|\/(?:java|ecma)script/i ); + + + +// We have to close these tags to support XHTML (#13200) +var wrapMap = { + + // Support: IE <=9 only + option: [ 1, "" ], + + // XHTML parsers do not magically insert elements in the + // same way that tag soup parsers do. So we cannot shorten + // this by omitting or other required elements. + thead: [ 1, "", "
" ], + col: [ 2, "", "
" ], + tr: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + _default: [ 0, "", "" ] +}; + +// Support: IE <=9 only +wrapMap.optgroup = wrapMap.option; + +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + + +function getAll( context, tag ) { + + // Support: IE <=9 - 11 only + // Use typeof to avoid zero-argument method invocation on host objects (#15151) + var ret; + + if ( typeof context.getElementsByTagName !== "undefined" ) { + ret = context.getElementsByTagName( tag || "*" ); + + } else if ( typeof context.querySelectorAll !== "undefined" ) { + ret = context.querySelectorAll( tag || "*" ); + + } else { + ret = []; + } + + if ( tag === undefined || tag && nodeName( context, tag ) ) { + return jQuery.merge( [ context ], ret ); + } + + return ret; +} + + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + dataPriv.set( + elems[ i ], + "globalEval", + !refElements || dataPriv.get( refElements[ i ], "globalEval" ) + ); + } +} + + +var rhtml = /<|&#?\w+;/; + +function buildFragment( elems, context, scripts, selection, ignored ) { + var elem, tmp, tag, wrap, attached, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( toType( elem ) === "object" ) { + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement( "div" ) ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( ( elem = nodes[ i++ ] ) ) { + + // Skip elements already in the context collection (trac-4087) + if ( selection && jQuery.inArray( elem, selection ) > -1 ) { + if ( ignored ) { + ignored.push( elem ); + } + continue; + } + + attached = isAttached( elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( attached ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( ( elem = tmp[ j++ ] ) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; +} + + +( function() { + var fragment = document.createDocumentFragment(), + div = fragment.appendChild( document.createElement( "div" ) ), + input = document.createElement( "input" ); + + // Support: Android 4.0 - 4.3 only + // Check state lost if the name is set (#11217) + // Support: Windows Web Apps (WWA) + // `name` and `type` must use .setAttribute for WWA (#14901) + input.setAttribute( "type", "radio" ); + input.setAttribute( "checked", "checked" ); + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + + // Support: Android <=4.1 only + // Older WebKit doesn't clone checked state correctly in fragments + support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE <=11 only + // Make sure textarea (and checkbox) defaultValue is properly cloned + div.innerHTML = ""; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; +} )(); + + +var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +// Support: IE <=9 - 11+ +// focus() and blur() are asynchronous, except when they are no-op. +// So expect focus to be synchronous when the element is already active, +// and blur to be synchronous when the element is not already active. +// (focus and blur are always synchronous in other supported browsers, +// this just defines when we can count on it). +function expectSync( elem, type ) { + return ( elem === safeActiveElement() ) === ( type === "focus" ); +} + +// Support: IE <=9 only +// Accessing document.activeElement can throw unexpectedly +// https://bugs.jquery.com/ticket/13393 +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return elem; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Ensure that invalid selectors throw exceptions at attach time + // Evaluate against documentElement in case elem is a non-element node (e.g., document) + if ( selector ) { + jQuery.find.matchesSelector( documentElement, selector ); + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !( events = elemData.events ) ) { + events = elemData.events = {}; + } + if ( !( eventHandle = elemData.handle ) ) { + eventHandle = elemData.handle = function( e ) { + + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnothtmlwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend( { + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join( "." ) + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !( handlers = events[ type ] ) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.hasData( elem ) && dataPriv.get( elem ); + + if ( !elemData || !( events = elemData.events ) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnothtmlwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove data and the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + dataPriv.remove( elem, "handle events" ); + } + }, + + dispatch: function( nativeEvent ) { + + // Make a writable jQuery.Event from the native event object + var event = jQuery.event.fix( nativeEvent ); + + var i, j, ret, matched, handleObj, handlerQueue, + args = new Array( arguments.length ), + handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[ 0 ] = event; + + for ( i = 1; i < arguments.length; i++ ) { + args[ i ] = arguments[ i ]; + } + + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { + + // If the event is namespaced, then each handler is only invoked if it is + // specially universal or its namespaces are a superset of the event's. + if ( !event.rnamespace || handleObj.namespace === false || + event.rnamespace.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( ( event.result = ret ) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, handleObj, sel, matchedHandlers, matchedSelectors, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Find delegate handlers + if ( delegateCount && + + // Support: IE <=9 + // Black-hole SVG instance trees (trac-13180) + cur.nodeType && + + // Support: Firefox <=42 + // Suppress spec-violating clicks indicating a non-primary pointer button (trac-3861) + // https://www.w3.org/TR/DOM-Level-3-Events/#event-type-click + // Support: IE 11 only + // ...but not arrow key "clicks" of radio inputs, which can have `button` -1 (gh-2343) + !( event.type === "click" && event.button >= 1 ) ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && !( event.type === "click" && cur.disabled === true ) ) { + matchedHandlers = []; + matchedSelectors = {}; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matchedSelectors[ sel ] === undefined ) { + matchedSelectors[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) > -1 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matchedSelectors[ sel ] ) { + matchedHandlers.push( handleObj ); + } + } + if ( matchedHandlers.length ) { + handlerQueue.push( { elem: cur, handlers: matchedHandlers } ); + } + } + } + } + + // Add the remaining (directly-bound) handlers + cur = this; + if ( delegateCount < handlers.length ) { + handlerQueue.push( { elem: cur, handlers: handlers.slice( delegateCount ) } ); + } + + return handlerQueue; + }, + + addProp: function( name, hook ) { + Object.defineProperty( jQuery.Event.prototype, name, { + enumerable: true, + configurable: true, + + get: isFunction( hook ) ? + function() { + if ( this.originalEvent ) { + return hook( this.originalEvent ); + } + } : + function() { + if ( this.originalEvent ) { + return this.originalEvent[ name ]; + } + }, + + set: function( value ) { + Object.defineProperty( this, name, { + enumerable: true, + configurable: true, + writable: true, + value: value + } ); + } + } ); + }, + + fix: function( originalEvent ) { + return originalEvent[ jQuery.expando ] ? + originalEvent : + new jQuery.Event( originalEvent ); + }, + + special: { + load: { + + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + click: { + + // Utilize native event to ensure correct state for checkable inputs + setup: function( data ) { + + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Claim the first handler + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + // dataPriv.set( el, "click", ... ) + leverageNative( el, "click", returnTrue ); + } + + // Return false to allow normal processing in the caller + return false; + }, + trigger: function( data ) { + + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Force setup before triggering a click + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + leverageNative( el, "click" ); + } + + // Return non-false to allow normal event-path propagation + return true; + }, + + // For cross-browser consistency, suppress native .click() on links + // Also prevent it if we're currently inside a leveraged native-event stack + _default: function( event ) { + var target = event.target; + return rcheckableType.test( target.type ) && + target.click && nodeName( target, "input" ) && + dataPriv.get( target, "click" ) || + nodeName( target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + } +}; + +// Ensure the presence of an event listener that handles manually-triggered +// synthetic events by interrupting progress until reinvoked in response to +// *native* events that it fires directly, ensuring that state changes have +// already occurred before other listeners are invoked. +function leverageNative( el, type, expectSync ) { + + // Missing expectSync indicates a trigger call, which must force setup through jQuery.event.add + if ( !expectSync ) { + if ( dataPriv.get( el, type ) === undefined ) { + jQuery.event.add( el, type, returnTrue ); + } + return; + } + + // Register the controller as a special universal handler for all event namespaces + dataPriv.set( el, type, false ); + jQuery.event.add( el, type, { + namespace: false, + handler: function( event ) { + var notAsync, result, + saved = dataPriv.get( this, type ); + + if ( ( event.isTrigger & 1 ) && this[ type ] ) { + + // Interrupt processing of the outer synthetic .trigger()ed event + // Saved data should be false in such cases, but might be a leftover capture object + // from an async native handler (gh-4350) + if ( !saved.length ) { + + // Store arguments for use when handling the inner native event + // There will always be at least one argument (an event object), so this array + // will not be confused with a leftover capture object. + saved = slice.call( arguments ); + dataPriv.set( this, type, saved ); + + // Trigger the native event and capture its result + // Support: IE <=9 - 11+ + // focus() and blur() are asynchronous + notAsync = expectSync( this, type ); + this[ type ](); + result = dataPriv.get( this, type ); + if ( saved !== result || notAsync ) { + dataPriv.set( this, type, false ); + } else { + result = {}; + } + if ( saved !== result ) { + + // Cancel the outer synthetic event + event.stopImmediatePropagation(); + event.preventDefault(); + return result.value; + } + + // If this is an inner synthetic event for an event with a bubbling surrogate + // (focus or blur), assume that the surrogate already propagated from triggering the + // native event and prevent that from happening again here. + // This technically gets the ordering wrong w.r.t. to `.trigger()` (in which the + // bubbling surrogate propagates *after* the non-bubbling base), but that seems + // less bad than duplication. + } else if ( ( jQuery.event.special[ type ] || {} ).delegateType ) { + event.stopPropagation(); + } + + // If this is a native event triggered above, everything is now in order + // Fire an inner synthetic event with the original arguments + } else if ( saved.length ) { + + // ...and capture the result + dataPriv.set( this, type, { + value: jQuery.event.trigger( + + // Support: IE <=9 - 11+ + // Extend with the prototype to reset the above stopImmediatePropagation() + jQuery.extend( saved[ 0 ], jQuery.Event.prototype ), + saved.slice( 1 ), + this + ) + } ); + + // Abort handling of the native event + event.stopImmediatePropagation(); + } + } + } ); +} + +jQuery.removeEvent = function( elem, type, handle ) { + + // This "if" is needed for plain objects + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle ); + } +}; + +jQuery.Event = function( src, props ) { + + // Allow instantiation without the 'new' keyword + if ( !( this instanceof jQuery.Event ) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + + // Support: Android <=2.3 only + src.returnValue === false ? + returnTrue : + returnFalse; + + // Create target properties + // Support: Safari <=6 - 7 only + // Target should not be a text node (#504, #13143) + this.target = ( src.target && src.target.nodeType === 3 ) ? + src.target.parentNode : + src.target; + + this.currentTarget = src.currentTarget; + this.relatedTarget = src.relatedTarget; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || Date.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// https://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + constructor: jQuery.Event, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + isSimulated: false, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e && !this.isSimulated ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Includes all common event props including KeyEvent and MouseEvent specific props +jQuery.each( { + altKey: true, + bubbles: true, + cancelable: true, + changedTouches: true, + ctrlKey: true, + detail: true, + eventPhase: true, + metaKey: true, + pageX: true, + pageY: true, + shiftKey: true, + view: true, + "char": true, + code: true, + charCode: true, + key: true, + keyCode: true, + button: true, + buttons: true, + clientX: true, + clientY: true, + offsetX: true, + offsetY: true, + pointerId: true, + pointerType: true, + screenX: true, + screenY: true, + targetTouches: true, + toElement: true, + touches: true, + + which: function( event ) { + var button = event.button; + + // Add which for key events + if ( event.which == null && rkeyEvent.test( event.type ) ) { + return event.charCode != null ? event.charCode : event.keyCode; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + if ( !event.which && button !== undefined && rmouseEvent.test( event.type ) ) { + if ( button & 1 ) { + return 1; + } + + if ( button & 2 ) { + return 3; + } + + if ( button & 4 ) { + return 2; + } + + return 0; + } + + return event.which; + } +}, jQuery.event.addProp ); + +jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateType ) { + jQuery.event.special[ type ] = { + + // Utilize native event if possible so blur/focus sequence is correct + setup: function() { + + // Claim the first handler + // dataPriv.set( this, "focus", ... ) + // dataPriv.set( this, "blur", ... ) + leverageNative( this, type, expectSync ); + + // Return false to allow normal processing in the caller + return false; + }, + trigger: function() { + + // Force setup before trigger + leverageNative( this, type ); + + // Return non-false to allow normal event-path propagation + return true; + }, + + delegateType: delegateType + }; +} ); + +// Create mouseenter/leave events using mouseover/out and event-time checks +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://bugs.chromium.org/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mouseenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +} ); + +jQuery.fn.extend( { + + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); + }, + one: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each( function() { + jQuery.event.remove( this, types, fn, selector ); + } ); + } +} ); + + +var + + /* eslint-disable max-len */ + + // See https://github.com/eslint/eslint/issues/3229 + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi, + + /* eslint-enable */ + + // Support: IE <=10 - 11, Edge 12 - 13 only + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /\s*$/g; + +// Prefer a tbody over its parent table for containing new rows +function manipulationTarget( elem, content ) { + if ( nodeName( elem, "table" ) && + nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ) { + + return jQuery( elem ).children( "tbody" )[ 0 ] || elem; + } + + return elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = ( elem.getAttribute( "type" ) !== null ) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + if ( ( elem.type || "" ).slice( 0, 5 ) === "true/" ) { + elem.type = elem.type.slice( 5 ); + } else { + elem.removeAttribute( "type" ); + } + + return elem; +} + +function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( dataPriv.hasData( src ) ) { + pdataOld = dataPriv.access( src ); + pdataCur = dataPriv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( dataUser.hasData( src ) ) { + udataOld = dataUser.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + dataUser.set( dest, udataCur ); + } +} + +// Fix IE bugs, see support tests +function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +function domManip( collection, args, callback, ignored ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + valueIsFunction = isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( valueIsFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( valueIsFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !dataPriv.access( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src && ( node.type || "" ).toLowerCase() !== "module" ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl && !node.noModule ) { + jQuery._evalUrl( node.src, { + nonce: node.nonce || node.getAttribute( "nonce" ) + } ); + } + } else { + DOMEval( node.textContent.replace( rcleanScript, "" ), node, doc ); + } + } + } + } + } + } + + return collection; +} + +function remove( elem, selector, keepData ) { + var node, + nodes = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = nodes[ i ] ) != null; i++ ) { + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && isAttached( node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } + } + + return elem; +} + +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1>" ); + }, + + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = isAttached( elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: https://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + cleanData: function( elems ) { + var data, elem, type, + special = jQuery.event.special, + i = 0; + + for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) { + if ( acceptData( elem ) ) { + if ( ( data = elem[ dataPriv.expando ] ) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataPriv.expando ] = undefined; + } + if ( elem[ dataUser.expando ] ) { + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataUser.expando ] = undefined; + } + } + } + } +} ); + +jQuery.fn.extend( { + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, + + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each( function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + } ); + }, null, value, arguments.length ); + }, + + append: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + } ); + }, + + prepend: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + } ); + }, + + before: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + } ); + }, + + after: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + } ); + }, + + empty: function() { + var elem, + i = 0; + + for ( ; ( elem = this[ i ] ) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + } ); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = jQuery.htmlPrefilter( value ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch ( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var ignored = []; + + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; + + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); + } + } + + // Force callback invocation + }, ignored ); + } +} ); + +jQuery.each( { + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: Android <=4.0 only, PhantomJS 1 only + // .get() because push.apply(_, arraylike) throws on ancient WebKit + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +} ); +var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" ); + +var getStyles = function( elem ) { + + // Support: IE <=11 only, Firefox <=30 (#15098, #14150) + // IE throws on elements created in popups + // FF meanwhile throws on frame elements through "defaultView.getComputedStyle" + var view = elem.ownerDocument.defaultView; + + if ( !view || !view.opener ) { + view = window; + } + + return view.getComputedStyle( elem ); + }; + +var rboxStyle = new RegExp( cssExpand.join( "|" ), "i" ); + + + +( function() { + + // Executing both pixelPosition & boxSizingReliable tests require only one layout + // so they're executed at the same time to save the second computation. + function computeStyleTests() { + + // This is a singleton, we need to execute it only once + if ( !div ) { + return; + } + + container.style.cssText = "position:absolute;left:-11111px;width:60px;" + + "margin-top:1px;padding:0;border:0"; + div.style.cssText = + "position:relative;display:block;box-sizing:border-box;overflow:scroll;" + + "margin:auto;border:1px;padding:1px;" + + "width:60%;top:1%"; + documentElement.appendChild( container ).appendChild( div ); + + var divStyle = window.getComputedStyle( div ); + pixelPositionVal = divStyle.top !== "1%"; + + // Support: Android 4.0 - 4.3 only, Firefox <=3 - 44 + reliableMarginLeftVal = roundPixelMeasures( divStyle.marginLeft ) === 12; + + // Support: Android 4.0 - 4.3 only, Safari <=9.1 - 10.1, iOS <=7.0 - 9.3 + // Some styles come back with percentage values, even though they shouldn't + div.style.right = "60%"; + pixelBoxStylesVal = roundPixelMeasures( divStyle.right ) === 36; + + // Support: IE 9 - 11 only + // Detect misreporting of content dimensions for box-sizing:border-box elements + boxSizingReliableVal = roundPixelMeasures( divStyle.width ) === 36; + + // Support: IE 9 only + // Detect overflow:scroll screwiness (gh-3699) + // Support: Chrome <=64 + // Don't get tricked when zoom affects offsetWidth (gh-4029) + div.style.position = "absolute"; + scrollboxSizeVal = roundPixelMeasures( div.offsetWidth / 3 ) === 12; + + documentElement.removeChild( container ); + + // Nullify the div so it wouldn't be stored in the memory and + // it will also be a sign that checks already performed + div = null; + } + + function roundPixelMeasures( measure ) { + return Math.round( parseFloat( measure ) ); + } + + var pixelPositionVal, boxSizingReliableVal, scrollboxSizeVal, pixelBoxStylesVal, + reliableMarginLeftVal, + container = document.createElement( "div" ), + div = document.createElement( "div" ); + + // Finish early in limited (non-browser) environments + if ( !div.style ) { + return; + } + + // Support: IE <=9 - 11 only + // Style of cloned element affects source element cloned (#8908) + div.style.backgroundClip = "content-box"; + div.cloneNode( true ).style.backgroundClip = ""; + support.clearCloneStyle = div.style.backgroundClip === "content-box"; + + jQuery.extend( support, { + boxSizingReliable: function() { + computeStyleTests(); + return boxSizingReliableVal; + }, + pixelBoxStyles: function() { + computeStyleTests(); + return pixelBoxStylesVal; + }, + pixelPosition: function() { + computeStyleTests(); + return pixelPositionVal; + }, + reliableMarginLeft: function() { + computeStyleTests(); + return reliableMarginLeftVal; + }, + scrollboxSize: function() { + computeStyleTests(); + return scrollboxSizeVal; + } + } ); +} )(); + + +function curCSS( elem, name, computed ) { + var width, minWidth, maxWidth, ret, + + // Support: Firefox 51+ + // Retrieving style before computed somehow + // fixes an issue with getting wrong values + // on detached elements + style = elem.style; + + computed = computed || getStyles( elem ); + + // getPropertyValue is needed for: + // .css('filter') (IE 9 only, #12537) + // .css('--customProperty) (#3144) + if ( computed ) { + ret = computed.getPropertyValue( name ) || computed[ name ]; + + if ( ret === "" && !isAttached( elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Android Browser returns percentage for some values, + // but width seems to be reliably pixels. + // This is against the CSSOM draft spec: + // https://drafts.csswg.org/cssom/#resolved-values + if ( !support.pixelBoxStyles() && rnumnonpx.test( ret ) && rboxStyle.test( name ) ) { + + // Remember the original values + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + // Put in the new values to get a computed value out + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + // Revert the changed values + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret !== undefined ? + + // Support: IE <=9 - 11 only + // IE returns zIndex value as an integer. + ret + "" : + ret; +} + + +function addGetHookIf( conditionFn, hookFn ) { + + // Define the hook, we'll check on the first run if it's really needed. + return { + get: function() { + if ( conditionFn() ) { + + // Hook not needed (or it's not possible to use it due + // to missing dependency), remove it. + delete this.get; + return; + } + + // Hook needed; redefine it so that the support test is not executed again. + return ( this.get = hookFn ).apply( this, arguments ); + } + }; +} + + +var cssPrefixes = [ "Webkit", "Moz", "ms" ], + emptyStyle = document.createElement( "div" ).style, + vendorProps = {}; + +// Return a vendor-prefixed property or undefined +function vendorPropName( name ) { + + // Check for vendor prefixed names + var capName = name[ 0 ].toUpperCase() + name.slice( 1 ), + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in emptyStyle ) { + return name; + } + } +} + +// Return a potentially-mapped jQuery.cssProps or vendor prefixed property +function finalPropName( name ) { + var final = jQuery.cssProps[ name ] || vendorProps[ name ]; + + if ( final ) { + return final; + } + if ( name in emptyStyle ) { + return name; + } + return vendorProps[ name ] = vendorPropName( name ) || name; +} + + +var + + // Swappable if display is none or starts with table + // except "table", "table-cell", or "table-caption" + // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + rcustomProp = /^--/, + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: "0", + fontWeight: "400" + }; + +function setPositiveNumber( elem, value, subtract ) { + + // Any relative (+/-) values have already been + // normalized at this point + var matches = rcssNum.exec( value ); + return matches ? + + // Guard against undefined "subtract", e.g., when used as in cssHooks + Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || "px" ) : + value; +} + +function boxModelAdjustment( elem, dimension, box, isBorderBox, styles, computedVal ) { + var i = dimension === "width" ? 1 : 0, + extra = 0, + delta = 0; + + // Adjustment may not be necessary + if ( box === ( isBorderBox ? "border" : "content" ) ) { + return 0; + } + + for ( ; i < 4; i += 2 ) { + + // Both box models exclude margin + if ( box === "margin" ) { + delta += jQuery.css( elem, box + cssExpand[ i ], true, styles ); + } + + // If we get here with a content-box, we're seeking "padding" or "border" or "margin" + if ( !isBorderBox ) { + + // Add padding + delta += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + + // For "border" or "margin", add border + if ( box !== "padding" ) { + delta += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + + // But still keep track of it otherwise + } else { + extra += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + + // If we get here with a border-box (content + padding + border), we're seeking "content" or + // "padding" or "margin" + } else { + + // For "content", subtract padding + if ( box === "content" ) { + delta -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + } + + // For "content" or "padding", subtract border + if ( box !== "margin" ) { + delta -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } + } + + // Account for positive content-box scroll gutter when requested by providing computedVal + if ( !isBorderBox && computedVal >= 0 ) { + + // offsetWidth/offsetHeight is a rounded sum of content, padding, scroll gutter, and border + // Assuming integer scroll gutter, subtract the rest and round down + delta += Math.max( 0, Math.ceil( + elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] - + computedVal - + delta - + extra - + 0.5 + + // If offsetWidth/offsetHeight is unknown, then we can't determine content-box scroll gutter + // Use an explicit zero to avoid NaN (gh-3964) + ) ) || 0; + } + + return delta; +} + +function getWidthOrHeight( elem, dimension, extra ) { + + // Start with computed style + var styles = getStyles( elem ), + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-4322). + // Fake content-box until we know it's needed to know the true value. + boxSizingNeeded = !support.boxSizingReliable() || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + valueIsBorderBox = isBorderBox, + + val = curCSS( elem, dimension, styles ), + offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ); + + // Support: Firefox <=54 + // Return a confounding non-pixel value or feign ignorance, as appropriate. + if ( rnumnonpx.test( val ) ) { + if ( !extra ) { + return val; + } + val = "auto"; + } + + + // Fall back to offsetWidth/offsetHeight when value is "auto" + // This happens for inline elements with no explicit setting (gh-3571) + // Support: Android <=4.1 - 4.3 only + // Also use offsetWidth/offsetHeight for misreported inline dimensions (gh-3602) + // Support: IE 9-11 only + // Also use offsetWidth/offsetHeight for when box sizing is unreliable + // We use getClientRects() to check for hidden/disconnected. + // In those cases, the computed value can be trusted to be border-box + if ( ( !support.boxSizingReliable() && isBorderBox || + val === "auto" || + !parseFloat( val ) && jQuery.css( elem, "display", false, styles ) === "inline" ) && + elem.getClientRects().length ) { + + isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; + + // Where available, offsetWidth/offsetHeight approximate border box dimensions. + // Where not available (e.g., SVG), assume unreliable box-sizing and interpret the + // retrieved value as a content box dimension. + valueIsBorderBox = offsetProp in elem; + if ( valueIsBorderBox ) { + val = elem[ offsetProp ]; + } + } + + // Normalize "" and auto + val = parseFloat( val ) || 0; + + // Adjust for the element's box model + return ( val + + boxModelAdjustment( + elem, + dimension, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox, + styles, + + // Provide the current computed size to request scroll gutter calculation (gh-3589) + val + ) + ) + "px"; +} + +jQuery.extend( { + + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + } + } + } + }, + + // Don't automatically add "px" to these possibly-unitless properties + cssNumber: { + "animationIterationCount": true, + "columnCount": true, + "fillOpacity": true, + "flexGrow": true, + "flexShrink": true, + "fontWeight": true, + "gridArea": true, + "gridColumn": true, + "gridColumnEnd": true, + "gridColumnStart": true, + "gridRow": true, + "gridRowEnd": true, + "gridRowStart": true, + "lineHeight": true, + "opacity": true, + "order": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: {}, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = camelCase( name ), + isCustomProp = rcustomProp.test( name ), + style = elem.style; + + // Make sure that we're working with the right name. We don't + // want to query the value if it is a CSS custom property + // since they are user-defined. + if ( !isCustomProp ) { + name = finalPropName( origName ); + } + + // Gets hook for the prefixed version, then unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // Convert "+=" or "-=" to relative numbers (#7345) + if ( type === "string" && ( ret = rcssNum.exec( value ) ) && ret[ 1 ] ) { + value = adjustCSS( elem, name, ret ); + + // Fixes bug #9237 + type = "number"; + } + + // Make sure that null and NaN values aren't set (#7116) + if ( value == null || value !== value ) { + return; + } + + // If a number was passed in, add the unit (except for certain CSS properties) + // The isCustomProp check can be removed in jQuery 4.0 when we only auto-append + // "px" to a few hardcoded values. + if ( type === "number" && !isCustomProp ) { + value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? "" : "px" ); + } + + // background-* props affect original clone's values + if ( !support.clearCloneStyle && value === "" && name.indexOf( "background" ) === 0 ) { + style[ name ] = "inherit"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !( "set" in hooks ) || + ( value = hooks.set( elem, value, extra ) ) !== undefined ) { + + if ( isCustomProp ) { + style.setProperty( name, value ); + } else { + style[ name ] = value; + } + } + + } else { + + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && + ( ret = hooks.get( elem, false, extra ) ) !== undefined ) { + + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, extra, styles ) { + var val, num, hooks, + origName = camelCase( name ), + isCustomProp = rcustomProp.test( name ); + + // Make sure that we're working with the right name. We don't + // want to modify the value if it is a CSS custom property + // since they are user-defined. + if ( !isCustomProp ) { + name = finalPropName( origName ); + } + + // Try prefixed name followed by the unprefixed name + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name, styles ); + } + + // Convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Make numeric if forced or a qualifier was provided and val looks numeric + if ( extra === "" || extra ) { + num = parseFloat( val ); + return extra === true || isFinite( num ) ? num || 0 : val; + } + + return val; + } +} ); + +jQuery.each( [ "height", "width" ], function( i, dimension ) { + jQuery.cssHooks[ dimension ] = { + get: function( elem, computed, extra ) { + if ( computed ) { + + // Certain elements can have dimension info if we invisibly show them + // but it must have a current display style that would benefit + return rdisplayswap.test( jQuery.css( elem, "display" ) ) && + + // Support: Safari 8+ + // Table columns in Safari have non-zero offsetWidth & zero + // getBoundingClientRect().width unless display is changed. + // Support: IE <=11 only + // Running getBoundingClientRect on a disconnected node + // in IE throws an error. + ( !elem.getClientRects().length || !elem.getBoundingClientRect().width ) ? + swap( elem, cssShow, function() { + return getWidthOrHeight( elem, dimension, extra ); + } ) : + getWidthOrHeight( elem, dimension, extra ); + } + }, + + set: function( elem, value, extra ) { + var matches, + styles = getStyles( elem ), + + // Only read styles.position if the test has a chance to fail + // to avoid forcing a reflow. + scrollboxSizeBuggy = !support.scrollboxSize() && + styles.position === "absolute", + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-3991) + boxSizingNeeded = scrollboxSizeBuggy || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + subtract = extra ? + boxModelAdjustment( + elem, + dimension, + extra, + isBorderBox, + styles + ) : + 0; + + // Account for unreliable border-box dimensions by comparing offset* to computed and + // faking a content-box to get border and padding (gh-3699) + if ( isBorderBox && scrollboxSizeBuggy ) { + subtract -= Math.ceil( + elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] - + parseFloat( styles[ dimension ] ) - + boxModelAdjustment( elem, dimension, "border", false, styles ) - + 0.5 + ); + } + + // Convert to pixels if value adjustment is needed + if ( subtract && ( matches = rcssNum.exec( value ) ) && + ( matches[ 3 ] || "px" ) !== "px" ) { + + elem.style[ dimension ] = value; + value = jQuery.css( elem, dimension ); + } + + return setPositiveNumber( elem, value, subtract ); + } + }; +} ); + +jQuery.cssHooks.marginLeft = addGetHookIf( support.reliableMarginLeft, + function( elem, computed ) { + if ( computed ) { + return ( parseFloat( curCSS( elem, "marginLeft" ) ) || + elem.getBoundingClientRect().left - + swap( elem, { marginLeft: 0 }, function() { + return elem.getBoundingClientRect().left; + } ) + ) + "px"; + } + } +); + +// These hooks are used by animate to expand properties +jQuery.each( { + margin: "", + padding: "", + border: "Width" +}, function( prefix, suffix ) { + jQuery.cssHooks[ prefix + suffix ] = { + expand: function( value ) { + var i = 0, + expanded = {}, + + // Assumes a single number if not a string + parts = typeof value === "string" ? value.split( " " ) : [ value ]; + + for ( ; i < 4; i++ ) { + expanded[ prefix + cssExpand[ i ] + suffix ] = + parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; + } + + return expanded; + } + }; + + if ( prefix !== "margin" ) { + jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; + } +} ); + +jQuery.fn.extend( { + css: function( name, value ) { + return access( this, function( elem, name, value ) { + var styles, len, + map = {}, + i = 0; + + if ( Array.isArray( name ) ) { + styles = getStyles( elem ); + len = name.length; + + for ( ; i < len; i++ ) { + map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); + } + + return map; + } + + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + } +} ); + + +function Tween( elem, options, prop, end, easing ) { + return new Tween.prototype.init( elem, options, prop, end, easing ); +} +jQuery.Tween = Tween; + +Tween.prototype = { + constructor: Tween, + init: function( elem, options, prop, end, easing, unit ) { + this.elem = elem; + this.prop = prop; + this.easing = easing || jQuery.easing._default; + this.options = options; + this.start = this.now = this.cur(); + this.end = end; + this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + }, + cur: function() { + var hooks = Tween.propHooks[ this.prop ]; + + return hooks && hooks.get ? + hooks.get( this ) : + Tween.propHooks._default.get( this ); + }, + run: function( percent ) { + var eased, + hooks = Tween.propHooks[ this.prop ]; + + if ( this.options.duration ) { + this.pos = eased = jQuery.easing[ this.easing ]( + percent, this.options.duration * percent, 0, 1, this.options.duration + ); + } else { + this.pos = eased = percent; + } + this.now = ( this.end - this.start ) * eased + this.start; + + if ( this.options.step ) { + this.options.step.call( this.elem, this.now, this ); + } + + if ( hooks && hooks.set ) { + hooks.set( this ); + } else { + Tween.propHooks._default.set( this ); + } + return this; + } +}; + +Tween.prototype.init.prototype = Tween.prototype; + +Tween.propHooks = { + _default: { + get: function( tween ) { + var result; + + // Use a property on the element directly when it is not a DOM element, + // or when there is no matching style property that exists. + if ( tween.elem.nodeType !== 1 || + tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) { + return tween.elem[ tween.prop ]; + } + + // Passing an empty string as a 3rd parameter to .css will automatically + // attempt a parseFloat and fallback to a string if the parse fails. + // Simple values such as "10px" are parsed to Float; + // complex values such as "rotate(1rad)" are returned as-is. + result = jQuery.css( tween.elem, tween.prop, "" ); + + // Empty strings, null, undefined and "auto" are converted to 0. + return !result || result === "auto" ? 0 : result; + }, + set: function( tween ) { + + // Use step hook for back compat. + // Use cssHook if its there. + // Use .style if available and use plain properties where available. + if ( jQuery.fx.step[ tween.prop ] ) { + jQuery.fx.step[ tween.prop ]( tween ); + } else if ( tween.elem.nodeType === 1 && ( + jQuery.cssHooks[ tween.prop ] || + tween.elem.style[ finalPropName( tween.prop ) ] != null ) ) { + jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); + } else { + tween.elem[ tween.prop ] = tween.now; + } + } + } +}; + +// Support: IE <=9 only +// Panic based approach to setting things on disconnected nodes +Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { + set: function( tween ) { + if ( tween.elem.nodeType && tween.elem.parentNode ) { + tween.elem[ tween.prop ] = tween.now; + } + } +}; + +jQuery.easing = { + linear: function( p ) { + return p; + }, + swing: function( p ) { + return 0.5 - Math.cos( p * Math.PI ) / 2; + }, + _default: "swing" +}; + +jQuery.fx = Tween.prototype.init; + +// Back compat <1.8 extension point +jQuery.fx.step = {}; + + + + +var + fxNow, inProgress, + rfxtypes = /^(?:toggle|show|hide)$/, + rrun = /queueHooks$/; + +function schedule() { + if ( inProgress ) { + if ( document.hidden === false && window.requestAnimationFrame ) { + window.requestAnimationFrame( schedule ); + } else { + window.setTimeout( schedule, jQuery.fx.interval ); + } + + jQuery.fx.tick(); + } +} + +// Animations created synchronously will run synchronously +function createFxNow() { + window.setTimeout( function() { + fxNow = undefined; + } ); + return ( fxNow = Date.now() ); +} + +// Generate parameters to create a standard animation +function genFx( type, includeWidth ) { + var which, + i = 0, + attrs = { height: type }; + + // If we include width, step value is 1 to do all cssExpand values, + // otherwise step value is 2 to skip over Left and Right + includeWidth = includeWidth ? 1 : 0; + for ( ; i < 4; i += 2 - includeWidth ) { + which = cssExpand[ i ]; + attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; + } + + if ( includeWidth ) { + attrs.opacity = attrs.width = type; + } + + return attrs; +} + +function createTween( value, prop, animation ) { + var tween, + collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ "*" ] ), + index = 0, + length = collection.length; + for ( ; index < length; index++ ) { + if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) { + + // We're done with this property + return tween; + } + } +} + +function defaultPrefilter( elem, props, opts ) { + var prop, value, toggle, hooks, oldfire, propTween, restoreDisplay, display, + isBox = "width" in props || "height" in props, + anim = this, + orig = {}, + style = elem.style, + hidden = elem.nodeType && isHiddenWithinTree( elem ), + dataShow = dataPriv.get( elem, "fxshow" ); + + // Queue-skipping animations hijack the fx hooks + if ( !opts.queue ) { + hooks = jQuery._queueHooks( elem, "fx" ); + if ( hooks.unqueued == null ) { + hooks.unqueued = 0; + oldfire = hooks.empty.fire; + hooks.empty.fire = function() { + if ( !hooks.unqueued ) { + oldfire(); + } + }; + } + hooks.unqueued++; + + anim.always( function() { + + // Ensure the complete handler is called before this completes + anim.always( function() { + hooks.unqueued--; + if ( !jQuery.queue( elem, "fx" ).length ) { + hooks.empty.fire(); + } + } ); + } ); + } + + // Detect show/hide animations + for ( prop in props ) { + value = props[ prop ]; + if ( rfxtypes.test( value ) ) { + delete props[ prop ]; + toggle = toggle || value === "toggle"; + if ( value === ( hidden ? "hide" : "show" ) ) { + + // Pretend to be hidden if this is a "show" and + // there is still data from a stopped show/hide + if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) { + hidden = true; + + // Ignore all other no-op show/hide data + } else { + continue; + } + } + orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop ); + } + } + + // Bail out if this is a no-op like .hide().hide() + propTween = !jQuery.isEmptyObject( props ); + if ( !propTween && jQuery.isEmptyObject( orig ) ) { + return; + } + + // Restrict "overflow" and "display" styles during box animations + if ( isBox && elem.nodeType === 1 ) { + + // Support: IE <=9 - 11, Edge 12 - 15 + // Record all 3 overflow attributes because IE does not infer the shorthand + // from identically-valued overflowX and overflowY and Edge just mirrors + // the overflowX value there. + opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; + + // Identify a display type, preferring old show/hide data over the CSS cascade + restoreDisplay = dataShow && dataShow.display; + if ( restoreDisplay == null ) { + restoreDisplay = dataPriv.get( elem, "display" ); + } + display = jQuery.css( elem, "display" ); + if ( display === "none" ) { + if ( restoreDisplay ) { + display = restoreDisplay; + } else { + + // Get nonempty value(s) by temporarily forcing visibility + showHide( [ elem ], true ); + restoreDisplay = elem.style.display || restoreDisplay; + display = jQuery.css( elem, "display" ); + showHide( [ elem ] ); + } + } + + // Animate inline elements as inline-block + if ( display === "inline" || display === "inline-block" && restoreDisplay != null ) { + if ( jQuery.css( elem, "float" ) === "none" ) { + + // Restore the original display value at the end of pure show/hide animations + if ( !propTween ) { + anim.done( function() { + style.display = restoreDisplay; + } ); + if ( restoreDisplay == null ) { + display = style.display; + restoreDisplay = display === "none" ? "" : display; + } + } + style.display = "inline-block"; + } + } + } + + if ( opts.overflow ) { + style.overflow = "hidden"; + anim.always( function() { + style.overflow = opts.overflow[ 0 ]; + style.overflowX = opts.overflow[ 1 ]; + style.overflowY = opts.overflow[ 2 ]; + } ); + } + + // Implement show/hide animations + propTween = false; + for ( prop in orig ) { + + // General show/hide setup for this element animation + if ( !propTween ) { + if ( dataShow ) { + if ( "hidden" in dataShow ) { + hidden = dataShow.hidden; + } + } else { + dataShow = dataPriv.access( elem, "fxshow", { display: restoreDisplay } ); + } + + // Store hidden/visible for toggle so `.stop().toggle()` "reverses" + if ( toggle ) { + dataShow.hidden = !hidden; + } + + // Show elements before animating them + if ( hidden ) { + showHide( [ elem ], true ); + } + + /* eslint-disable no-loop-func */ + + anim.done( function() { + + /* eslint-enable no-loop-func */ + + // The final step of a "hide" animation is actually hiding the element + if ( !hidden ) { + showHide( [ elem ] ); + } + dataPriv.remove( elem, "fxshow" ); + for ( prop in orig ) { + jQuery.style( elem, prop, orig[ prop ] ); + } + } ); + } + + // Per-property setup + propTween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim ); + if ( !( prop in dataShow ) ) { + dataShow[ prop ] = propTween.start; + if ( hidden ) { + propTween.end = propTween.start; + propTween.start = 0; + } + } + } +} + +function propFilter( props, specialEasing ) { + var index, name, easing, value, hooks; + + // camelCase, specialEasing and expand cssHook pass + for ( index in props ) { + name = camelCase( index ); + easing = specialEasing[ name ]; + value = props[ index ]; + if ( Array.isArray( value ) ) { + easing = value[ 1 ]; + value = props[ index ] = value[ 0 ]; + } + + if ( index !== name ) { + props[ name ] = value; + delete props[ index ]; + } + + hooks = jQuery.cssHooks[ name ]; + if ( hooks && "expand" in hooks ) { + value = hooks.expand( value ); + delete props[ name ]; + + // Not quite $.extend, this won't overwrite existing keys. + // Reusing 'index' because we have the correct "name" + for ( index in value ) { + if ( !( index in props ) ) { + props[ index ] = value[ index ]; + specialEasing[ index ] = easing; + } + } + } else { + specialEasing[ name ] = easing; + } + } +} + +function Animation( elem, properties, options ) { + var result, + stopped, + index = 0, + length = Animation.prefilters.length, + deferred = jQuery.Deferred().always( function() { + + // Don't match elem in the :animated selector + delete tick.elem; + } ), + tick = function() { + if ( stopped ) { + return false; + } + var currentTime = fxNow || createFxNow(), + remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), + + // Support: Android 2.3 only + // Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (#12497) + temp = remaining / animation.duration || 0, + percent = 1 - temp, + index = 0, + length = animation.tweens.length; + + for ( ; index < length; index++ ) { + animation.tweens[ index ].run( percent ); + } + + deferred.notifyWith( elem, [ animation, percent, remaining ] ); + + // If there's more to do, yield + if ( percent < 1 && length ) { + return remaining; + } + + // If this was an empty animation, synthesize a final progress notification + if ( !length ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + } + + // Resolve the animation and report its conclusion + deferred.resolveWith( elem, [ animation ] ); + return false; + }, + animation = deferred.promise( { + elem: elem, + props: jQuery.extend( {}, properties ), + opts: jQuery.extend( true, { + specialEasing: {}, + easing: jQuery.easing._default + }, options ), + originalProperties: properties, + originalOptions: options, + startTime: fxNow || createFxNow(), + duration: options.duration, + tweens: [], + createTween: function( prop, end ) { + var tween = jQuery.Tween( elem, animation.opts, prop, end, + animation.opts.specialEasing[ prop ] || animation.opts.easing ); + animation.tweens.push( tween ); + return tween; + }, + stop: function( gotoEnd ) { + var index = 0, + + // If we are going to the end, we want to run all the tweens + // otherwise we skip this part + length = gotoEnd ? animation.tweens.length : 0; + if ( stopped ) { + return this; + } + stopped = true; + for ( ; index < length; index++ ) { + animation.tweens[ index ].run( 1 ); + } + + // Resolve when we played the last frame; otherwise, reject + if ( gotoEnd ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + deferred.resolveWith( elem, [ animation, gotoEnd ] ); + } else { + deferred.rejectWith( elem, [ animation, gotoEnd ] ); + } + return this; + } + } ), + props = animation.props; + + propFilter( props, animation.opts.specialEasing ); + + for ( ; index < length; index++ ) { + result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts ); + if ( result ) { + if ( isFunction( result.stop ) ) { + jQuery._queueHooks( animation.elem, animation.opts.queue ).stop = + result.stop.bind( result ); + } + return result; + } + } + + jQuery.map( props, createTween, animation ); + + if ( isFunction( animation.opts.start ) ) { + animation.opts.start.call( elem, animation ); + } + + // Attach callbacks from options + animation + .progress( animation.opts.progress ) + .done( animation.opts.done, animation.opts.complete ) + .fail( animation.opts.fail ) + .always( animation.opts.always ); + + jQuery.fx.timer( + jQuery.extend( tick, { + elem: elem, + anim: animation, + queue: animation.opts.queue + } ) + ); + + return animation; +} + +jQuery.Animation = jQuery.extend( Animation, { + + tweeners: { + "*": [ function( prop, value ) { + var tween = this.createTween( prop, value ); + adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween ); + return tween; + } ] + }, + + tweener: function( props, callback ) { + if ( isFunction( props ) ) { + callback = props; + props = [ "*" ]; + } else { + props = props.match( rnothtmlwhite ); + } + + var prop, + index = 0, + length = props.length; + + for ( ; index < length; index++ ) { + prop = props[ index ]; + Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || []; + Animation.tweeners[ prop ].unshift( callback ); + } + }, + + prefilters: [ defaultPrefilter ], + + prefilter: function( callback, prepend ) { + if ( prepend ) { + Animation.prefilters.unshift( callback ); + } else { + Animation.prefilters.push( callback ); + } + } +} ); + +jQuery.speed = function( speed, easing, fn ) { + var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { + complete: fn || !fn && easing || + isFunction( speed ) && speed, + duration: speed, + easing: fn && easing || easing && !isFunction( easing ) && easing + }; + + // Go to the end state if fx are off + if ( jQuery.fx.off ) { + opt.duration = 0; + + } else { + if ( typeof opt.duration !== "number" ) { + if ( opt.duration in jQuery.fx.speeds ) { + opt.duration = jQuery.fx.speeds[ opt.duration ]; + + } else { + opt.duration = jQuery.fx.speeds._default; + } + } + } + + // Normalize opt.queue - true/undefined/null -> "fx" + if ( opt.queue == null || opt.queue === true ) { + opt.queue = "fx"; + } + + // Queueing + opt.old = opt.complete; + + opt.complete = function() { + if ( isFunction( opt.old ) ) { + opt.old.call( this ); + } + + if ( opt.queue ) { + jQuery.dequeue( this, opt.queue ); + } + }; + + return opt; +}; + +jQuery.fn.extend( { + fadeTo: function( speed, to, easing, callback ) { + + // Show any hidden elements after setting opacity to 0 + return this.filter( isHiddenWithinTree ).css( "opacity", 0 ).show() + + // Animate to the value specified + .end().animate( { opacity: to }, speed, easing, callback ); + }, + animate: function( prop, speed, easing, callback ) { + var empty = jQuery.isEmptyObject( prop ), + optall = jQuery.speed( speed, easing, callback ), + doAnimation = function() { + + // Operate on a copy of prop so per-property easing won't be lost + var anim = Animation( this, jQuery.extend( {}, prop ), optall ); + + // Empty animations, or finishing resolves immediately + if ( empty || dataPriv.get( this, "finish" ) ) { + anim.stop( true ); + } + }; + doAnimation.finish = doAnimation; + + return empty || optall.queue === false ? + this.each( doAnimation ) : + this.queue( optall.queue, doAnimation ); + }, + stop: function( type, clearQueue, gotoEnd ) { + var stopQueue = function( hooks ) { + var stop = hooks.stop; + delete hooks.stop; + stop( gotoEnd ); + }; + + if ( typeof type !== "string" ) { + gotoEnd = clearQueue; + clearQueue = type; + type = undefined; + } + if ( clearQueue && type !== false ) { + this.queue( type || "fx", [] ); + } + + return this.each( function() { + var dequeue = true, + index = type != null && type + "queueHooks", + timers = jQuery.timers, + data = dataPriv.get( this ); + + if ( index ) { + if ( data[ index ] && data[ index ].stop ) { + stopQueue( data[ index ] ); + } + } else { + for ( index in data ) { + if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { + stopQueue( data[ index ] ); + } + } + } + + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && + ( type == null || timers[ index ].queue === type ) ) { + + timers[ index ].anim.stop( gotoEnd ); + dequeue = false; + timers.splice( index, 1 ); + } + } + + // Start the next in the queue if the last step wasn't forced. + // Timers currently will call their complete callbacks, which + // will dequeue but only if they were gotoEnd. + if ( dequeue || !gotoEnd ) { + jQuery.dequeue( this, type ); + } + } ); + }, + finish: function( type ) { + if ( type !== false ) { + type = type || "fx"; + } + return this.each( function() { + var index, + data = dataPriv.get( this ), + queue = data[ type + "queue" ], + hooks = data[ type + "queueHooks" ], + timers = jQuery.timers, + length = queue ? queue.length : 0; + + // Enable finishing flag on private data + data.finish = true; + + // Empty the queue first + jQuery.queue( this, type, [] ); + + if ( hooks && hooks.stop ) { + hooks.stop.call( this, true ); + } + + // Look for any active animations, and finish them + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && timers[ index ].queue === type ) { + timers[ index ].anim.stop( true ); + timers.splice( index, 1 ); + } + } + + // Look for any animations in the old queue and finish them + for ( index = 0; index < length; index++ ) { + if ( queue[ index ] && queue[ index ].finish ) { + queue[ index ].finish.call( this ); + } + } + + // Turn off finishing flag + delete data.finish; + } ); + } +} ); + +jQuery.each( [ "toggle", "show", "hide" ], function( i, name ) { + var cssFn = jQuery.fn[ name ]; + jQuery.fn[ name ] = function( speed, easing, callback ) { + return speed == null || typeof speed === "boolean" ? + cssFn.apply( this, arguments ) : + this.animate( genFx( name, true ), speed, easing, callback ); + }; +} ); + +// Generate shortcuts for custom animations +jQuery.each( { + slideDown: genFx( "show" ), + slideUp: genFx( "hide" ), + slideToggle: genFx( "toggle" ), + fadeIn: { opacity: "show" }, + fadeOut: { opacity: "hide" }, + fadeToggle: { opacity: "toggle" } +}, function( name, props ) { + jQuery.fn[ name ] = function( speed, easing, callback ) { + return this.animate( props, speed, easing, callback ); + }; +} ); + +jQuery.timers = []; +jQuery.fx.tick = function() { + var timer, + i = 0, + timers = jQuery.timers; + + fxNow = Date.now(); + + for ( ; i < timers.length; i++ ) { + timer = timers[ i ]; + + // Run the timer and safely remove it when done (allowing for external removal) + if ( !timer() && timers[ i ] === timer ) { + timers.splice( i--, 1 ); + } + } + + if ( !timers.length ) { + jQuery.fx.stop(); + } + fxNow = undefined; +}; + +jQuery.fx.timer = function( timer ) { + jQuery.timers.push( timer ); + jQuery.fx.start(); +}; + +jQuery.fx.interval = 13; +jQuery.fx.start = function() { + if ( inProgress ) { + return; + } + + inProgress = true; + schedule(); +}; + +jQuery.fx.stop = function() { + inProgress = null; +}; + +jQuery.fx.speeds = { + slow: 600, + fast: 200, + + // Default speed + _default: 400 +}; + + +// Based off of the plugin by Clint Helfers, with permission. +// https://web.archive.org/web/20100324014747/http://blindsignals.com/index.php/2009/07/jquery-delay/ +jQuery.fn.delay = function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = window.setTimeout( next, time ); + hooks.stop = function() { + window.clearTimeout( timeout ); + }; + } ); +}; + + +( function() { + var input = document.createElement( "input" ), + select = document.createElement( "select" ), + opt = select.appendChild( document.createElement( "option" ) ); + + input.type = "checkbox"; + + // Support: Android <=4.3 only + // Default value for a checkbox should be "on" + support.checkOn = input.value !== ""; + + // Support: IE <=11 only + // Must access selectedIndex to make default options select + support.optSelected = opt.selected; + + // Support: IE <=11 only + // An input loses its value after becoming a radio + input = document.createElement( "input" ); + input.value = "t"; + input.type = "radio"; + support.radioValue = input.value === "t"; +} )(); + + +var boolHook, + attrHandle = jQuery.expr.attrHandle; + +jQuery.fn.extend( { + attr: function( name, value ) { + return access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each( function() { + jQuery.removeAttr( this, name ); + } ); + } +} ); + +jQuery.extend( { + attr: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set attributes on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === "undefined" ) { + return jQuery.prop( elem, name, value ); + } + + // Attribute hooks are determined by the lowercase version + // Grab necessary hook if one is defined + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + hooks = jQuery.attrHooks[ name.toLowerCase() ] || + ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined ); + } + + if ( value !== undefined ) { + if ( value === null ) { + jQuery.removeAttr( elem, name ); + return; + } + + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + elem.setAttribute( name, value + "" ); + return value; + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + ret = jQuery.find.attr( elem, name ); + + // Non-existent attributes return null, we normalize to undefined + return ret == null ? undefined : ret; + }, + + attrHooks: { + type: { + set: function( elem, value ) { + if ( !support.radioValue && value === "radio" && + nodeName( elem, "input" ) ) { + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + } + }, + + removeAttr: function( elem, value ) { + var name, + i = 0, + + // Attribute names can contain non-HTML whitespace characters + // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 + attrNames = value && value.match( rnothtmlwhite ); + + if ( attrNames && elem.nodeType === 1 ) { + while ( ( name = attrNames[ i++ ] ) ) { + elem.removeAttribute( name ); + } + } + } +} ); + +// Hooks for boolean attributes +boolHook = { + set: function( elem, value, name ) { + if ( value === false ) { + + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else { + elem.setAttribute( name, name ); + } + return name; + } +}; + +jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { + var getter = attrHandle[ name ] || jQuery.find.attr; + + attrHandle[ name ] = function( elem, name, isXML ) { + var ret, handle, + lowercaseName = name.toLowerCase(); + + if ( !isXML ) { + + // Avoid an infinite loop by temporarily removing this function from the getter + handle = attrHandle[ lowercaseName ]; + attrHandle[ lowercaseName ] = ret; + ret = getter( elem, name, isXML ) != null ? + lowercaseName : + null; + attrHandle[ lowercaseName ] = handle; + } + return ret; + }; +} ); + + + + +var rfocusable = /^(?:input|select|textarea|button)$/i, + rclickable = /^(?:a|area)$/i; + +jQuery.fn.extend( { + prop: function( name, value ) { + return access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + return this.each( function() { + delete this[ jQuery.propFix[ name ] || name ]; + } ); + } +} ); + +jQuery.extend( { + prop: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set properties on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + return ( elem[ name ] = value ); + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + return elem[ name ]; + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + + // Support: IE <=9 - 11 only + // elem.tabIndex doesn't always return the + // correct value when it hasn't been explicitly set + // https://web.archive.org/web/20141116233347/http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + // Use proper attribute retrieval(#12072) + var tabindex = jQuery.find.attr( elem, "tabindex" ); + + if ( tabindex ) { + return parseInt( tabindex, 10 ); + } + + if ( + rfocusable.test( elem.nodeName ) || + rclickable.test( elem.nodeName ) && + elem.href + ) { + return 0; + } + + return -1; + } + } + }, + + propFix: { + "for": "htmlFor", + "class": "className" + } +} ); + +// Support: IE <=11 only +// Accessing the selectedIndex property +// forces the browser to respect setting selected +// on the option +// The getter ensures a default option is selected +// when in an optgroup +// eslint rule "no-unused-expressions" is disabled for this code +// since it considers such accessions noop +if ( !support.optSelected ) { + jQuery.propHooks.selected = { + get: function( elem ) { + + /* eslint no-unused-expressions: "off" */ + + var parent = elem.parentNode; + if ( parent && parent.parentNode ) { + parent.parentNode.selectedIndex; + } + return null; + }, + set: function( elem ) { + + /* eslint no-unused-expressions: "off" */ + + var parent = elem.parentNode; + if ( parent ) { + parent.selectedIndex; + + if ( parent.parentNode ) { + parent.parentNode.selectedIndex; + } + } + } + }; +} + +jQuery.each( [ + "tabIndex", + "readOnly", + "maxLength", + "cellSpacing", + "cellPadding", + "rowSpan", + "colSpan", + "useMap", + "frameBorder", + "contentEditable" +], function() { + jQuery.propFix[ this.toLowerCase() ] = this; +} ); + + + + + // Strip and collapse whitespace according to HTML spec + // https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace + function stripAndCollapse( value ) { + var tokens = value.match( rnothtmlwhite ) || []; + return tokens.join( " " ); + } + + +function getClass( elem ) { + return elem.getAttribute && elem.getAttribute( "class" ) || ""; +} + +function classesToArray( value ) { + if ( Array.isArray( value ) ) { + return value; + } + if ( typeof value === "string" ) { + return value.match( rnothtmlwhite ) || []; + } + return []; +} + +jQuery.fn.extend( { + addClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).addClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + classes = classesToArray( value ); + + if ( classes.length ) { + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + cur = elem.nodeType === 1 && ( " " + stripAndCollapse( curValue ) + " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + if ( cur.indexOf( " " + clazz + " " ) < 0 ) { + cur += clazz + " "; + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = stripAndCollapse( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + removeClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).removeClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( !arguments.length ) { + return this.attr( "class", "" ); + } + + classes = classesToArray( value ); + + if ( classes.length ) { + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + + // This expression is here for better compressibility (see addClass) + cur = elem.nodeType === 1 && ( " " + stripAndCollapse( curValue ) + " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + + // Remove *all* instances + while ( cur.indexOf( " " + clazz + " " ) > -1 ) { + cur = cur.replace( " " + clazz + " ", " " ); + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = stripAndCollapse( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value, + isValidValue = type === "string" || Array.isArray( value ); + + if ( typeof stateVal === "boolean" && isValidValue ) { + return stateVal ? this.addClass( value ) : this.removeClass( value ); + } + + if ( isFunction( value ) ) { + return this.each( function( i ) { + jQuery( this ).toggleClass( + value.call( this, i, getClass( this ), stateVal ), + stateVal + ); + } ); + } + + return this.each( function() { + var className, i, self, classNames; + + if ( isValidValue ) { + + // Toggle individual class names + i = 0; + self = jQuery( this ); + classNames = classesToArray( value ); + + while ( ( className = classNames[ i++ ] ) ) { + + // Check each className given, space separated list + if ( self.hasClass( className ) ) { + self.removeClass( className ); + } else { + self.addClass( className ); + } + } + + // Toggle whole class name + } else if ( value === undefined || type === "boolean" ) { + className = getClass( this ); + if ( className ) { + + // Store className if set + dataPriv.set( this, "__className__", className ); + } + + // If the element has a class name or if we're passed `false`, + // then remove the whole classname (if there was one, the above saved it). + // Otherwise bring back whatever was previously saved (if anything), + // falling back to the empty string if nothing was stored. + if ( this.setAttribute ) { + this.setAttribute( "class", + className || value === false ? + "" : + dataPriv.get( this, "__className__" ) || "" + ); + } + } + } ); + }, + + hasClass: function( selector ) { + var className, elem, + i = 0; + + className = " " + selector + " "; + while ( ( elem = this[ i++ ] ) ) { + if ( elem.nodeType === 1 && + ( " " + stripAndCollapse( getClass( elem ) ) + " " ).indexOf( className ) > -1 ) { + return true; + } + } + + return false; + } +} ); + + + + +var rreturn = /\r/g; + +jQuery.fn.extend( { + val: function( value ) { + var hooks, ret, valueIsFunction, + elem = this[ 0 ]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || + jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && + "get" in hooks && + ( ret = hooks.get( elem, "value" ) ) !== undefined + ) { + return ret; + } + + ret = elem.value; + + // Handle most common string cases + if ( typeof ret === "string" ) { + return ret.replace( rreturn, "" ); + } + + // Handle cases where value is null/undef or number + return ret == null ? "" : ret; + } + + return; + } + + valueIsFunction = isFunction( value ); + + return this.each( function( i ) { + var val; + + if ( this.nodeType !== 1 ) { + return; + } + + if ( valueIsFunction ) { + val = value.call( this, i, jQuery( this ).val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + + } else if ( typeof val === "number" ) { + val += ""; + + } else if ( Array.isArray( val ) ) { + val = jQuery.map( val, function( value ) { + return value == null ? "" : value + ""; + } ); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !( "set" in hooks ) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + } ); + } +} ); + +jQuery.extend( { + valHooks: { + option: { + get: function( elem ) { + + var val = jQuery.find.attr( elem, "value" ); + return val != null ? + val : + + // Support: IE <=10 - 11 only + // option.text throws exceptions (#14686, #14858) + // Strip and collapse whitespace + // https://html.spec.whatwg.org/#strip-and-collapse-whitespace + stripAndCollapse( jQuery.text( elem ) ); + } + }, + select: { + get: function( elem ) { + var value, option, i, + options = elem.options, + index = elem.selectedIndex, + one = elem.type === "select-one", + values = one ? null : [], + max = one ? index + 1 : options.length; + + if ( index < 0 ) { + i = max; + + } else { + i = one ? index : 0; + } + + // Loop through all the selected options + for ( ; i < max; i++ ) { + option = options[ i ]; + + // Support: IE <=9 only + // IE8-9 doesn't update selected after form reset (#2551) + if ( ( option.selected || i === index ) && + + // Don't return options that are disabled or in a disabled optgroup + !option.disabled && + ( !option.parentNode.disabled || + !nodeName( option.parentNode, "optgroup" ) ) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + }, + + set: function( elem, value ) { + var optionSet, option, + options = elem.options, + values = jQuery.makeArray( value ), + i = options.length; + + while ( i-- ) { + option = options[ i ]; + + /* eslint-disable no-cond-assign */ + + if ( option.selected = + jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1 + ) { + optionSet = true; + } + + /* eslint-enable no-cond-assign */ + } + + // Force browsers to behave consistently when non-matching value is set + if ( !optionSet ) { + elem.selectedIndex = -1; + } + return values; + } + } + } +} ); + +// Radios and checkboxes getter/setter +jQuery.each( [ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + set: function( elem, value ) { + if ( Array.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery( elem ).val(), value ) > -1 ); + } + } + }; + if ( !support.checkOn ) { + jQuery.valHooks[ this ].get = function( elem ) { + return elem.getAttribute( "value" ) === null ? "on" : elem.value; + }; + } +} ); + + + + +// Return jQuery for attributes-only inclusion + + +support.focusin = "onfocusin" in window; + + +var rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + stopPropagationCallback = function( e ) { + e.stopPropagation(); + }; + +jQuery.extend( jQuery.event, { + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, lastElement, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split( "." ) : []; + + cur = lastElement = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "." ) > -1 ) { + + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split( "." ); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf( ":" ) < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join( "." ); + event.rnamespace = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === ( elem.ownerDocument || document ) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) { + lastElement = cur; + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( dataPriv.get( cur, "events" ) || {} )[ event.type ] && + dataPriv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( ( !special._default || + special._default.apply( eventPath.pop(), data ) === false ) && + acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && isFunction( elem[ type ] ) && !isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + + if ( event.isPropagationStopped() ) { + lastElement.addEventListener( type, stopPropagationCallback ); + } + + elem[ type ](); + + if ( event.isPropagationStopped() ) { + lastElement.removeEventListener( type, stopPropagationCallback ); + } + + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + // Piggyback on a donor event to simulate a different one + // Used only for `focus(in | out)` events + simulate: function( type, elem, event ) { + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true + } + ); + + jQuery.event.trigger( e, null, elem ); + } + +} ); + +jQuery.fn.extend( { + + trigger: function( type, data ) { + return this.each( function() { + jQuery.event.trigger( type, data, this ); + } ); + }, + triggerHandler: function( type, data ) { + var elem = this[ 0 ]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +} ); + + +// Support: Firefox <=44 +// Firefox doesn't have focus(in | out) events +// Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787 +// +// Support: Chrome <=48 - 49, Safari <=9.0 - 9.1 +// focus(in | out) events fire after focus & blur events, +// which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order +// Related ticket - https://bugs.chromium.org/p/chromium/issues/detail?id=449857 +if ( !support.focusin ) { + jQuery.each( { focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ) ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + dataPriv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + dataPriv.remove( doc, fix ); + + } else { + dataPriv.access( doc, fix, attaches ); + } + } + }; + } ); +} +var location = window.location; + +var nonce = Date.now(); + +var rquery = ( /\?/ ); + + + +// Cross-browser xml parsing +jQuery.parseXML = function( data ) { + var xml; + if ( !data || typeof data !== "string" ) { + return null; + } + + // Support: IE 9 - 11 only + // IE throws on parseFromString with invalid input. + try { + xml = ( new window.DOMParser() ).parseFromString( data, "text/xml" ); + } catch ( e ) { + xml = undefined; + } + + if ( !xml || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; +}; + + +var + rbracket = /\[\]$/, + rCRLF = /\r?\n/g, + rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, + rsubmittable = /^(?:input|select|textarea|keygen)/i; + +function buildParams( prefix, obj, traditional, add ) { + var name; + + if ( Array.isArray( obj ) ) { + + // Serialize array item. + jQuery.each( obj, function( i, v ) { + if ( traditional || rbracket.test( prefix ) ) { + + // Treat each array item as a scalar. + add( prefix, v ); + + } else { + + // Item is non-scalar (array or object), encode its numeric index. + buildParams( + prefix + "[" + ( typeof v === "object" && v != null ? i : "" ) + "]", + v, + traditional, + add + ); + } + } ); + + } else if ( !traditional && toType( obj ) === "object" ) { + + // Serialize object item. + for ( name in obj ) { + buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); + } + + } else { + + // Serialize scalar item. + add( prefix, obj ); + } +} + +// Serialize an array of form elements or a set of +// key/values into a query string +jQuery.param = function( a, traditional ) { + var prefix, + s = [], + add = function( key, valueOrFunction ) { + + // If value is a function, invoke it and use its return value + var value = isFunction( valueOrFunction ) ? + valueOrFunction() : + valueOrFunction; + + s[ s.length ] = encodeURIComponent( key ) + "=" + + encodeURIComponent( value == null ? "" : value ); + }; + + if ( a == null ) { + return ""; + } + + // If an array was passed in, assume that it is an array of form elements. + if ( Array.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { + + // Serialize the form elements + jQuery.each( a, function() { + add( this.name, this.value ); + } ); + + } else { + + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for ( prefix in a ) { + buildParams( prefix, a[ prefix ], traditional, add ); + } + } + + // Return the resulting serialization + return s.join( "&" ); +}; + +jQuery.fn.extend( { + serialize: function() { + return jQuery.param( this.serializeArray() ); + }, + serializeArray: function() { + return this.map( function() { + + // Can add propHook for "elements" to filter or add form elements + var elements = jQuery.prop( this, "elements" ); + return elements ? jQuery.makeArray( elements ) : this; + } ) + .filter( function() { + var type = this.type; + + // Use .is( ":disabled" ) so that fieldset[disabled] works + return this.name && !jQuery( this ).is( ":disabled" ) && + rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) && + ( this.checked || !rcheckableType.test( type ) ); + } ) + .map( function( i, elem ) { + var val = jQuery( this ).val(); + + if ( val == null ) { + return null; + } + + if ( Array.isArray( val ) ) { + return jQuery.map( val, function( val ) { + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ); + } + + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ).get(); + } +} ); + + +var + r20 = /%20/g, + rhash = /#.*$/, + rantiCache = /([?&])_=[^&]*/, + rheaders = /^(.*?):[ \t]*([^\r\n]*)$/mg, + + // #7653, #8125, #8152: local protocol detection + rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/, + rnoContent = /^(?:GET|HEAD)$/, + rprotocol = /^\/\//, + + /* Prefilters + * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) + * 2) These are called: + * - BEFORE asking for a transport + * - AFTER param serialization (s.data is a string if s.processData is true) + * 3) key is the dataType + * 4) the catchall symbol "*" can be used + * 5) execution will start with transport dataType and THEN continue down to "*" if needed + */ + prefilters = {}, + + /* Transports bindings + * 1) key is the dataType + * 2) the catchall symbol "*" can be used + * 3) selection will start with transport dataType and THEN go to "*" if needed + */ + transports = {}, + + // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression + allTypes = "*/".concat( "*" ), + + // Anchor tag for parsing the document origin + originAnchor = document.createElement( "a" ); + originAnchor.href = location.href; + +// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport +function addToPrefiltersOrTransports( structure ) { + + // dataTypeExpression is optional and defaults to "*" + return function( dataTypeExpression, func ) { + + if ( typeof dataTypeExpression !== "string" ) { + func = dataTypeExpression; + dataTypeExpression = "*"; + } + + var dataType, + i = 0, + dataTypes = dataTypeExpression.toLowerCase().match( rnothtmlwhite ) || []; + + if ( isFunction( func ) ) { + + // For each dataType in the dataTypeExpression + while ( ( dataType = dataTypes[ i++ ] ) ) { + + // Prepend if requested + if ( dataType[ 0 ] === "+" ) { + dataType = dataType.slice( 1 ) || "*"; + ( structure[ dataType ] = structure[ dataType ] || [] ).unshift( func ); + + // Otherwise append + } else { + ( structure[ dataType ] = structure[ dataType ] || [] ).push( func ); + } + } + } + }; +} + +// Base inspection function for prefilters and transports +function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) { + + var inspected = {}, + seekingTransport = ( structure === transports ); + + function inspect( dataType ) { + var selected; + inspected[ dataType ] = true; + jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) { + var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR ); + if ( typeof dataTypeOrTransport === "string" && + !seekingTransport && !inspected[ dataTypeOrTransport ] ) { + + options.dataTypes.unshift( dataTypeOrTransport ); + inspect( dataTypeOrTransport ); + return false; + } else if ( seekingTransport ) { + return !( selected = dataTypeOrTransport ); + } + } ); + return selected; + } + + return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" ); +} + +// A special extend for ajax options +// that takes "flat" options (not to be deep extended) +// Fixes #9887 +function ajaxExtend( target, src ) { + var key, deep, + flatOptions = jQuery.ajaxSettings.flatOptions || {}; + + for ( key in src ) { + if ( src[ key ] !== undefined ) { + ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; + } + } + if ( deep ) { + jQuery.extend( true, target, deep ); + } + + return target; +} + +/* Handles responses to an ajax request: + * - finds the right dataType (mediates between content-type and expected dataType) + * - returns the corresponding response + */ +function ajaxHandleResponses( s, jqXHR, responses ) { + + var ct, type, finalDataType, firstDataType, + contents = s.contents, + dataTypes = s.dataTypes; + + // Remove auto dataType and get content-type in the process + while ( dataTypes[ 0 ] === "*" ) { + dataTypes.shift(); + if ( ct === undefined ) { + ct = s.mimeType || jqXHR.getResponseHeader( "Content-Type" ); + } + } + + // Check if we're dealing with a known content-type + if ( ct ) { + for ( type in contents ) { + if ( contents[ type ] && contents[ type ].test( ct ) ) { + dataTypes.unshift( type ); + break; + } + } + } + + // Check to see if we have a response for the expected dataType + if ( dataTypes[ 0 ] in responses ) { + finalDataType = dataTypes[ 0 ]; + } else { + + // Try convertible dataTypes + for ( type in responses ) { + if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[ 0 ] ] ) { + finalDataType = type; + break; + } + if ( !firstDataType ) { + firstDataType = type; + } + } + + // Or just use first one + finalDataType = finalDataType || firstDataType; + } + + // If we found a dataType + // We add the dataType to the list if needed + // and return the corresponding response + if ( finalDataType ) { + if ( finalDataType !== dataTypes[ 0 ] ) { + dataTypes.unshift( finalDataType ); + } + return responses[ finalDataType ]; + } +} + +/* Chain conversions given the request and the original response + * Also sets the responseXXX fields on the jqXHR instance + */ +function ajaxConvert( s, response, jqXHR, isSuccess ) { + var conv2, current, conv, tmp, prev, + converters = {}, + + // Work with a copy of dataTypes in case we need to modify it for conversion + dataTypes = s.dataTypes.slice(); + + // Create converters map with lowercased keys + if ( dataTypes[ 1 ] ) { + for ( conv in s.converters ) { + converters[ conv.toLowerCase() ] = s.converters[ conv ]; + } + } + + current = dataTypes.shift(); + + // Convert to each sequential dataType + while ( current ) { + + if ( s.responseFields[ current ] ) { + jqXHR[ s.responseFields[ current ] ] = response; + } + + // Apply the dataFilter if provided + if ( !prev && isSuccess && s.dataFilter ) { + response = s.dataFilter( response, s.dataType ); + } + + prev = current; + current = dataTypes.shift(); + + if ( current ) { + + // There's only work to do if current dataType is non-auto + if ( current === "*" ) { + + current = prev; + + // Convert response if prev dataType is non-auto and differs from current + } else if ( prev !== "*" && prev !== current ) { + + // Seek a direct converter + conv = converters[ prev + " " + current ] || converters[ "* " + current ]; + + // If none found, seek a pair + if ( !conv ) { + for ( conv2 in converters ) { + + // If conv2 outputs current + tmp = conv2.split( " " ); + if ( tmp[ 1 ] === current ) { + + // If prev can be converted to accepted input + conv = converters[ prev + " " + tmp[ 0 ] ] || + converters[ "* " + tmp[ 0 ] ]; + if ( conv ) { + + // Condense equivalence converters + if ( conv === true ) { + conv = converters[ conv2 ]; + + // Otherwise, insert the intermediate dataType + } else if ( converters[ conv2 ] !== true ) { + current = tmp[ 0 ]; + dataTypes.unshift( tmp[ 1 ] ); + } + break; + } + } + } + } + + // Apply converter (if not an equivalence) + if ( conv !== true ) { + + // Unless errors are allowed to bubble, catch and return them + if ( conv && s.throws ) { + response = conv( response ); + } else { + try { + response = conv( response ); + } catch ( e ) { + return { + state: "parsererror", + error: conv ? e : "No conversion from " + prev + " to " + current + }; + } + } + } + } + } + } + + return { state: "success", data: response }; +} + +jQuery.extend( { + + // Counter for holding the number of active queries + active: 0, + + // Last-Modified header cache for next request + lastModified: {}, + etag: {}, + + ajaxSettings: { + url: location.href, + type: "GET", + isLocal: rlocalProtocol.test( location.protocol ), + global: true, + processData: true, + async: true, + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + + /* + timeout: 0, + data: null, + dataType: null, + username: null, + password: null, + cache: null, + throws: false, + traditional: false, + headers: {}, + */ + + accepts: { + "*": allTypes, + text: "text/plain", + html: "text/html", + xml: "application/xml, text/xml", + json: "application/json, text/javascript" + }, + + contents: { + xml: /\bxml\b/, + html: /\bhtml/, + json: /\bjson\b/ + }, + + responseFields: { + xml: "responseXML", + text: "responseText", + json: "responseJSON" + }, + + // Data converters + // Keys separate source (or catchall "*") and destination types with a single space + converters: { + + // Convert anything to text + "* text": String, + + // Text to html (true = no transformation) + "text html": true, + + // Evaluate text as a json expression + "text json": JSON.parse, + + // Parse text as xml + "text xml": jQuery.parseXML + }, + + // For options that shouldn't be deep extended: + // you can add your own custom options here if + // and when you create one that shouldn't be + // deep extended (see ajaxExtend) + flatOptions: { + url: true, + context: true + } + }, + + // Creates a full fledged settings object into target + // with both ajaxSettings and settings fields. + // If target is omitted, writes into ajaxSettings. + ajaxSetup: function( target, settings ) { + return settings ? + + // Building a settings object + ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) : + + // Extending ajaxSettings + ajaxExtend( jQuery.ajaxSettings, target ); + }, + + ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), + ajaxTransport: addToPrefiltersOrTransports( transports ), + + // Main method + ajax: function( url, options ) { + + // If url is an object, simulate pre-1.5 signature + if ( typeof url === "object" ) { + options = url; + url = undefined; + } + + // Force options to be an object + options = options || {}; + + var transport, + + // URL without anti-cache param + cacheURL, + + // Response headers + responseHeadersString, + responseHeaders, + + // timeout handle + timeoutTimer, + + // Url cleanup var + urlAnchor, + + // Request state (becomes false upon send and true upon completion) + completed, + + // To know if global events are to be dispatched + fireGlobals, + + // Loop variable + i, + + // uncached part of the url + uncached, + + // Create the final options object + s = jQuery.ajaxSetup( {}, options ), + + // Callbacks context + callbackContext = s.context || s, + + // Context for global events is callbackContext if it is a DOM node or jQuery collection + globalEventContext = s.context && + ( callbackContext.nodeType || callbackContext.jquery ) ? + jQuery( callbackContext ) : + jQuery.event, + + // Deferreds + deferred = jQuery.Deferred(), + completeDeferred = jQuery.Callbacks( "once memory" ), + + // Status-dependent callbacks + statusCode = s.statusCode || {}, + + // Headers (they are sent all at once) + requestHeaders = {}, + requestHeadersNames = {}, + + // Default abort message + strAbort = "canceled", + + // Fake xhr + jqXHR = { + readyState: 0, + + // Builds headers hashtable if needed + getResponseHeader: function( key ) { + var match; + if ( completed ) { + if ( !responseHeaders ) { + responseHeaders = {}; + while ( ( match = rheaders.exec( responseHeadersString ) ) ) { + responseHeaders[ match[ 1 ].toLowerCase() + " " ] = + ( responseHeaders[ match[ 1 ].toLowerCase() + " " ] || [] ) + .concat( match[ 2 ] ); + } + } + match = responseHeaders[ key.toLowerCase() + " " ]; + } + return match == null ? null : match.join( ", " ); + }, + + // Raw string + getAllResponseHeaders: function() { + return completed ? responseHeadersString : null; + }, + + // Caches the header + setRequestHeader: function( name, value ) { + if ( completed == null ) { + name = requestHeadersNames[ name.toLowerCase() ] = + requestHeadersNames[ name.toLowerCase() ] || name; + requestHeaders[ name ] = value; + } + return this; + }, + + // Overrides response content-type header + overrideMimeType: function( type ) { + if ( completed == null ) { + s.mimeType = type; + } + return this; + }, + + // Status-dependent callbacks + statusCode: function( map ) { + var code; + if ( map ) { + if ( completed ) { + + // Execute the appropriate callbacks + jqXHR.always( map[ jqXHR.status ] ); + } else { + + // Lazy-add the new callbacks in a way that preserves old ones + for ( code in map ) { + statusCode[ code ] = [ statusCode[ code ], map[ code ] ]; + } + } + } + return this; + }, + + // Cancel the request + abort: function( statusText ) { + var finalText = statusText || strAbort; + if ( transport ) { + transport.abort( finalText ); + } + done( 0, finalText ); + return this; + } + }; + + // Attach deferreds + deferred.promise( jqXHR ); + + // Add protocol if not provided (prefilters might expect it) + // Handle falsy url in the settings object (#10093: consistency with old signature) + // We also use the url parameter if available + s.url = ( ( url || s.url || location.href ) + "" ) + .replace( rprotocol, location.protocol + "//" ); + + // Alias method option to type as per ticket #12004 + s.type = options.method || options.type || s.method || s.type; + + // Extract dataTypes list + s.dataTypes = ( s.dataType || "*" ).toLowerCase().match( rnothtmlwhite ) || [ "" ]; + + // A cross-domain request is in order when the origin doesn't match the current origin. + if ( s.crossDomain == null ) { + urlAnchor = document.createElement( "a" ); + + // Support: IE <=8 - 11, Edge 12 - 15 + // IE throws exception on accessing the href property if url is malformed, + // e.g. http://example.com:80x/ + try { + urlAnchor.href = s.url; + + // Support: IE <=8 - 11 only + // Anchor's host property isn't correctly set when s.url is relative + urlAnchor.href = urlAnchor.href; + s.crossDomain = originAnchor.protocol + "//" + originAnchor.host !== + urlAnchor.protocol + "//" + urlAnchor.host; + } catch ( e ) { + + // If there is an error parsing the URL, assume it is crossDomain, + // it can be rejected by the transport if it is invalid + s.crossDomain = true; + } + } + + // Convert data if not already a string + if ( s.data && s.processData && typeof s.data !== "string" ) { + s.data = jQuery.param( s.data, s.traditional ); + } + + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + + // If request was aborted inside a prefilter, stop there + if ( completed ) { + return jqXHR; + } + + // We can fire global events as of now if asked to + // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118) + fireGlobals = jQuery.event && s.global; + + // Watch for a new set of requests + if ( fireGlobals && jQuery.active++ === 0 ) { + jQuery.event.trigger( "ajaxStart" ); + } + + // Uppercase the type + s.type = s.type.toUpperCase(); + + // Determine if request has content + s.hasContent = !rnoContent.test( s.type ); + + // Save the URL in case we're toying with the If-Modified-Since + // and/or If-None-Match header later on + // Remove hash to simplify url manipulation + cacheURL = s.url.replace( rhash, "" ); + + // More options handling for requests with no content + if ( !s.hasContent ) { + + // Remember the hash so we can put it back + uncached = s.url.slice( cacheURL.length ); + + // If data is available and should be processed, append data to url + if ( s.data && ( s.processData || typeof s.data === "string" ) ) { + cacheURL += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data; + + // #9682: remove data so that it's not used in an eventual retry + delete s.data; + } + + // Add or update anti-cache param if needed + if ( s.cache === false ) { + cacheURL = cacheURL.replace( rantiCache, "$1" ); + uncached = ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + ( nonce++ ) + uncached; + } + + // Put hash and anti-cache on the URL that will be requested (gh-1732) + s.url = cacheURL + uncached; + + // Change '%20' to '+' if this is encoded form body content (gh-2658) + } else if ( s.data && s.processData && + ( s.contentType || "" ).indexOf( "application/x-www-form-urlencoded" ) === 0 ) { + s.data = s.data.replace( r20, "+" ); + } + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + if ( jQuery.lastModified[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] ); + } + if ( jQuery.etag[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] ); + } + } + + // Set the correct header, if data is being sent + if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { + jqXHR.setRequestHeader( "Content-Type", s.contentType ); + } + + // Set the Accepts header for the server, depending on the dataType + jqXHR.setRequestHeader( + "Accept", + s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[ 0 ] ] ? + s.accepts[ s.dataTypes[ 0 ] ] + + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : + s.accepts[ "*" ] + ); + + // Check for headers option + for ( i in s.headers ) { + jqXHR.setRequestHeader( i, s.headers[ i ] ); + } + + // Allow custom headers/mimetypes and early abort + if ( s.beforeSend && + ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || completed ) ) { + + // Abort if not done already and return + return jqXHR.abort(); + } + + // Aborting is no longer a cancellation + strAbort = "abort"; + + // Install callbacks on deferreds + completeDeferred.add( s.complete ); + jqXHR.done( s.success ); + jqXHR.fail( s.error ); + + // Get transport + transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); + + // If no transport, we auto-abort + if ( !transport ) { + done( -1, "No Transport" ); + } else { + jqXHR.readyState = 1; + + // Send global event + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); + } + + // If request was aborted inside ajaxSend, stop there + if ( completed ) { + return jqXHR; + } + + // Timeout + if ( s.async && s.timeout > 0 ) { + timeoutTimer = window.setTimeout( function() { + jqXHR.abort( "timeout" ); + }, s.timeout ); + } + + try { + completed = false; + transport.send( requestHeaders, done ); + } catch ( e ) { + + // Rethrow post-completion exceptions + if ( completed ) { + throw e; + } + + // Propagate others as results + done( -1, e ); + } + } + + // Callback for when everything is done + function done( status, nativeStatusText, responses, headers ) { + var isSuccess, success, error, response, modified, + statusText = nativeStatusText; + + // Ignore repeat invocations + if ( completed ) { + return; + } + + completed = true; + + // Clear timeout if it exists + if ( timeoutTimer ) { + window.clearTimeout( timeoutTimer ); + } + + // Dereference transport for early garbage collection + // (no matter how long the jqXHR object will be used) + transport = undefined; + + // Cache response headers + responseHeadersString = headers || ""; + + // Set readyState + jqXHR.readyState = status > 0 ? 4 : 0; + + // Determine if successful + isSuccess = status >= 200 && status < 300 || status === 304; + + // Get response data + if ( responses ) { + response = ajaxHandleResponses( s, jqXHR, responses ); + } + + // Convert no matter what (that way responseXXX fields are always set) + response = ajaxConvert( s, response, jqXHR, isSuccess ); + + // If successful, handle type chaining + if ( isSuccess ) { + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + modified = jqXHR.getResponseHeader( "Last-Modified" ); + if ( modified ) { + jQuery.lastModified[ cacheURL ] = modified; + } + modified = jqXHR.getResponseHeader( "etag" ); + if ( modified ) { + jQuery.etag[ cacheURL ] = modified; + } + } + + // if no content + if ( status === 204 || s.type === "HEAD" ) { + statusText = "nocontent"; + + // if not modified + } else if ( status === 304 ) { + statusText = "notmodified"; + + // If we have data, let's convert it + } else { + statusText = response.state; + success = response.data; + error = response.error; + isSuccess = !error; + } + } else { + + // Extract error from statusText and normalize for non-aborts + error = statusText; + if ( status || !statusText ) { + statusText = "error"; + if ( status < 0 ) { + status = 0; + } + } + } + + // Set data for the fake xhr object + jqXHR.status = status; + jqXHR.statusText = ( nativeStatusText || statusText ) + ""; + + // Success/Error + if ( isSuccess ) { + deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); + } else { + deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); + } + + // Status-dependent callbacks + jqXHR.statusCode( statusCode ); + statusCode = undefined; + + if ( fireGlobals ) { + globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError", + [ jqXHR, s, isSuccess ? success : error ] ); + } + + // Complete + completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); + + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); + + // Handle the global AJAX counter + if ( !( --jQuery.active ) ) { + jQuery.event.trigger( "ajaxStop" ); + } + } + } + + return jqXHR; + }, + + getJSON: function( url, data, callback ) { + return jQuery.get( url, data, callback, "json" ); + }, + + getScript: function( url, callback ) { + return jQuery.get( url, undefined, callback, "script" ); + } +} ); + +jQuery.each( [ "get", "post" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + + // Shift arguments if data argument was omitted + if ( isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + // The url can be an options object (which then must have .url) + return jQuery.ajax( jQuery.extend( { + url: url, + type: method, + dataType: type, + data: data, + success: callback + }, jQuery.isPlainObject( url ) && url ) ); + }; +} ); + + +jQuery._evalUrl = function( url, options ) { + return jQuery.ajax( { + url: url, + + // Make this explicit, since user can override this through ajaxSetup (#11264) + type: "GET", + dataType: "script", + cache: true, + async: false, + global: false, + + // Only evaluate the response if it is successful (gh-4126) + // dataFilter is not invoked for failure responses, so using it instead + // of the default converter is kludgy but it works. + converters: { + "text script": function() {} + }, + dataFilter: function( response ) { + jQuery.globalEval( response, options ); + } + } ); +}; + + +jQuery.fn.extend( { + wrapAll: function( html ) { + var wrap; + + if ( this[ 0 ] ) { + if ( isFunction( html ) ) { + html = html.call( this[ 0 ] ); + } + + // The elements to wrap the target around + wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true ); + + if ( this[ 0 ].parentNode ) { + wrap.insertBefore( this[ 0 ] ); + } + + wrap.map( function() { + var elem = this; + + while ( elem.firstElementChild ) { + elem = elem.firstElementChild; + } + + return elem; + } ).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( isFunction( html ) ) { + return this.each( function( i ) { + jQuery( this ).wrapInner( html.call( this, i ) ); + } ); + } + + return this.each( function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + } ); + }, + + wrap: function( html ) { + var htmlIsFunction = isFunction( html ); + + return this.each( function( i ) { + jQuery( this ).wrapAll( htmlIsFunction ? html.call( this, i ) : html ); + } ); + }, + + unwrap: function( selector ) { + this.parent( selector ).not( "body" ).each( function() { + jQuery( this ).replaceWith( this.childNodes ); + } ); + return this; + } +} ); + + +jQuery.expr.pseudos.hidden = function( elem ) { + return !jQuery.expr.pseudos.visible( elem ); +}; +jQuery.expr.pseudos.visible = function( elem ) { + return !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length ); +}; + + + + +jQuery.ajaxSettings.xhr = function() { + try { + return new window.XMLHttpRequest(); + } catch ( e ) {} +}; + +var xhrSuccessStatus = { + + // File protocol always yields status code 0, assume 200 + 0: 200, + + // Support: IE <=9 only + // #1450: sometimes IE returns 1223 when it should be 204 + 1223: 204 + }, + xhrSupported = jQuery.ajaxSettings.xhr(); + +support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported ); +support.ajax = xhrSupported = !!xhrSupported; + +jQuery.ajaxTransport( function( options ) { + var callback, errorCallback; + + // Cross domain only allowed if supported through XMLHttpRequest + if ( support.cors || xhrSupported && !options.crossDomain ) { + return { + send: function( headers, complete ) { + var i, + xhr = options.xhr(); + + xhr.open( + options.type, + options.url, + options.async, + options.username, + options.password + ); + + // Apply custom fields if provided + if ( options.xhrFields ) { + for ( i in options.xhrFields ) { + xhr[ i ] = options.xhrFields[ i ]; + } + } + + // Override mime type if needed + if ( options.mimeType && xhr.overrideMimeType ) { + xhr.overrideMimeType( options.mimeType ); + } + + // X-Requested-With header + // For cross-domain requests, seeing as conditions for a preflight are + // akin to a jigsaw puzzle, we simply never set it to be sure. + // (it can always be set on a per-request basis or even using ajaxSetup) + // For same-domain requests, won't change header if already provided. + if ( !options.crossDomain && !headers[ "X-Requested-With" ] ) { + headers[ "X-Requested-With" ] = "XMLHttpRequest"; + } + + // Set headers + for ( i in headers ) { + xhr.setRequestHeader( i, headers[ i ] ); + } + + // Callback + callback = function( type ) { + return function() { + if ( callback ) { + callback = errorCallback = xhr.onload = + xhr.onerror = xhr.onabort = xhr.ontimeout = + xhr.onreadystatechange = null; + + if ( type === "abort" ) { + xhr.abort(); + } else if ( type === "error" ) { + + // Support: IE <=9 only + // On a manual native abort, IE9 throws + // errors on any property access that is not readyState + if ( typeof xhr.status !== "number" ) { + complete( 0, "error" ); + } else { + complete( + + // File: protocol always yields status 0; see #8605, #14207 + xhr.status, + xhr.statusText + ); + } + } else { + complete( + xhrSuccessStatus[ xhr.status ] || xhr.status, + xhr.statusText, + + // Support: IE <=9 only + // IE9 has no XHR2 but throws on binary (trac-11426) + // For XHR2 non-text, let the caller handle it (gh-2498) + ( xhr.responseType || "text" ) !== "text" || + typeof xhr.responseText !== "string" ? + { binary: xhr.response } : + { text: xhr.responseText }, + xhr.getAllResponseHeaders() + ); + } + } + }; + }; + + // Listen to events + xhr.onload = callback(); + errorCallback = xhr.onerror = xhr.ontimeout = callback( "error" ); + + // Support: IE 9 only + // Use onreadystatechange to replace onabort + // to handle uncaught aborts + if ( xhr.onabort !== undefined ) { + xhr.onabort = errorCallback; + } else { + xhr.onreadystatechange = function() { + + // Check readyState before timeout as it changes + if ( xhr.readyState === 4 ) { + + // Allow onerror to be called first, + // but that will not handle a native abort + // Also, save errorCallback to a variable + // as xhr.onerror cannot be accessed + window.setTimeout( function() { + if ( callback ) { + errorCallback(); + } + } ); + } + }; + } + + // Create the abort callback + callback = callback( "abort" ); + + try { + + // Do send the request (this may raise an exception) + xhr.send( options.hasContent && options.data || null ); + } catch ( e ) { + + // #14683: Only rethrow if this hasn't been notified as an error yet + if ( callback ) { + throw e; + } + } + }, + + abort: function() { + if ( callback ) { + callback(); + } + } + }; + } +} ); + + + + +// Prevent auto-execution of scripts when no explicit dataType was provided (See gh-2432) +jQuery.ajaxPrefilter( function( s ) { + if ( s.crossDomain ) { + s.contents.script = false; + } +} ); + +// Install script dataType +jQuery.ajaxSetup( { + accepts: { + script: "text/javascript, application/javascript, " + + "application/ecmascript, application/x-ecmascript" + }, + contents: { + script: /\b(?:java|ecma)script\b/ + }, + converters: { + "text script": function( text ) { + jQuery.globalEval( text ); + return text; + } + } +} ); + +// Handle cache's special case and crossDomain +jQuery.ajaxPrefilter( "script", function( s ) { + if ( s.cache === undefined ) { + s.cache = false; + } + if ( s.crossDomain ) { + s.type = "GET"; + } +} ); + +// Bind script tag hack transport +jQuery.ajaxTransport( "script", function( s ) { + + // This transport only deals with cross domain or forced-by-attrs requests + if ( s.crossDomain || s.scriptAttrs ) { + var script, callback; + return { + send: function( _, complete ) { + script = jQuery( " + + + + +
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+

Store your Sync content at <cloud folder>/Apps/Scrapyard/Sync

+
+
+
+
+ + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..945a15e4 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/l2/albitron/scrapyard/MainActivity.kt b/android/app/src/main/java/l2/albitron/scrapyard/MainActivity.kt new file mode 100644 index 00000000..a369c1ea --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/MainActivity.kt @@ -0,0 +1,95 @@ +package l2.albitron.scrapyard + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.Menu +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.navigation.NavigationView +import l2.albitron.scrapyard.cloud.providers.OneDriveProvider +import l2.albitron.scrapyard.cloud.sharing.SharingService +import l2.albitron.scrapyard.databinding.ActivityMainBinding + + +class MainActivity : AppCompatActivity() { + + private lateinit var appBarConfiguration: AppBarConfiguration + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.appBarMain.toolbar) + + val drawerLayout: DrawerLayout = binding.drawerLayout + val navView: NavigationView = binding.navView + val navController = findNavController(R.id.nav_host_fragment_content_main) + // Passing each menu ID as a set of Ids because each + // menu should be considered as top level destinations. + appBarConfiguration = AppBarConfiguration( + setOf( + R.id.nav_cloud_shelf, R.id.nav_sync, R.id.nav_providers, R.id.nav_settings + ), drawerLayout + ) + setupActionBarWithNavController(navController, appBarConfiguration) + navView.setupWithNavController(navController) + + setupStartDestination(navController) + + //OneDriveProvider.getSignature(this) + + askNotificationPermission() + } + + private fun setupStartDestination(navController: NavController) { + val navGraph = navController.graph + + if (intent.extras?.getBoolean(SharingService.EXTRA_CONFIGURE_PROVIDERS) == true) + navGraph.startDestination = R.id.nav_providers + else { + val settings = Settings(this) + navGraph.startDestination = + when (settings.startupScreen) { + Settings.STARTUP_SCREEN_CLOUD -> R.id.nav_cloud_shelf + Settings.STARTUP_SCREEN_SYNC -> R.id.nav_sync + else -> R.id.nav_settings + } + + navController.graph = navGraph + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + //menuInflater.inflate(R.menu.main, menu) + return true + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment_content_main) + return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() + } + + private fun askNotificationPermission() { + if (Build.VERSION.SDK_INT >= 33) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) + } + } + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/Scrapyard.kt b/android/app/src/main/java/l2/albitron/scrapyard/Scrapyard.kt new file mode 100644 index 00000000..65cde2a9 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/Scrapyard.kt @@ -0,0 +1,39 @@ +package l2.albitron.scrapyard + +import java.util.* + +interface Scrapyard { + companion object { + const val FORMAT_NAME: String = "JSON Scrapbook" + const val FORMAT_VERSION: Long = 1 + const val FORMAT_TYPE_CLOUD: String = "cloud" + const val FORMAT_TYPE_INDEX: String = "index" + const val FORMAT_CONTAINS_FOLDERS: String = "folders" + const val FORMAT_CONTAINS_SHELVES = "shelves" + const val ARCHIVE_CONTAINS_FILES: String = "files" + const val NODE_TYPE_SHELF: String = "shelf" + const val NODE_TYPE_FOLDER: String = "folder" + const val NODE_TYPE_BOOKMARK: String = "bookmark" + const val NODE_TYPE_ARCHIVE: String = "archive" + const val NODE_TYPE_SEPARATOR: String = "separator" + const val NODE_TYPE_NOTES: String = "notes" + const val NOTES_FORMAT_TEXT: String = "text" + const val ARCHIVE_TYPE_TEXT: String = "text" + const val DEFAULT_SHELF_UUID = "default" + const val CLOUD_SHELF_UUID = "cloud" + const val CLOUD_EXTERNAL_TYPE = "cloud" + const val DEFAULT_POSITION = 2147483647L + const val TODO_STATE_TODO: Long = 1 + const val TODO_STATE_DONE: Long = 4 + const val TODO_STATE_WAITING: Long = 2 + const val TODO_STATE_POSTPONED: Long = 3 + const val TODO_STATE_CANCELLED: Long = 5 + + fun genUUID(): String { + return UUID.randomUUID() + .toString() + .replace("-", "") + .uppercase() + } + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/Settings.kt b/android/app/src/main/java/l2/albitron/scrapyard/Settings.kt new file mode 100644 index 00000000..6c80bdf8 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/Settings.kt @@ -0,0 +1,99 @@ +package l2.albitron.scrapyard + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import java.lang.ref.WeakReference + +class Settings(context: Context) { + private val _contextRef = WeakReference(context) + private val _context: Context + get() = _contextRef.get()!! + + var dropboxAuthToken: String? + get() = this.getString(PREF_DROPBOX_AUTH_TOKEN) + set(value) = this.setString(PREF_DROPBOX_AUTH_TOKEN, value) + + fun clearDropboxAuthToken() { + removeValue(PREF_DROPBOX_AUTH_TOKEN) + } + + val startupScreen: String + get() = this.getString(PREF_STARTUP_SCREEN, STARTUP_SCREEN_SETTINGS)!! + + val rememberTreeState: Boolean + get() = this.getBoolean(PREF_REMEMBER_TREE_STATE, false) + + val cloudShelfProvider: String + get() = this.getString(PREF_CLOUD_SHELF_PROVIDER, CLOUD_PROVIDER_DROPBOX)!! + + val syncBookmarksProvider: String + get() = this.getString(PREF_SYNC_BOOKMARKS_PROVIDER, CLOUD_PROVIDER_DROPBOX)!! + + val askForAdditionalBookmarkProperties: Boolean + get() = this.getBoolean(PREF_ASK_ADDITIONAL_PROPERTIES, false) + + val sharedFolderName: String + get() = this.getString(PREF_DEFAULT_BOOKMARK_FOLDER, _context.getString(R.string.default_shared_folder_name))!! + + val shareToShelf: String + get() = this.getString(PREF_SHARE_TO_SHELF, SHARE_TO_CLOUD_SHELF)!! + + var isOneDriveSignedIn: Boolean + get() = this.getBoolean(PREF_ONEDRIVE_SIGNED_IN, false) + set(value) = this.setBoolean(PREF_ONEDRIVE_SIGNED_IN, value) + + private fun getString(key: String, default: String? = null): String? { + val prefs = _context.getSharedPreferences(MAIN_PREFERENCES, MODE_PRIVATE) + return prefs.getString(key, default) + } + + private fun setString(key: String, value: String?) { + val editor = _context.getSharedPreferences(MAIN_PREFERENCES, MODE_PRIVATE).edit() + editor.putString(key, value) + editor.apply() + } + + private fun removeValue(key: String) { + val editor = _context.getSharedPreferences(MAIN_PREFERENCES, MODE_PRIVATE).edit() + editor.remove(key) + editor.apply() + } + + private fun getBoolean(key: String, default: Boolean = false): Boolean { + val prefs = _context.getSharedPreferences(MAIN_PREFERENCES, MODE_PRIVATE) + return prefs.getBoolean(key, default) + } + + private fun setBoolean(key: String, value: Boolean) { + val editor = _context.getSharedPreferences(MAIN_PREFERENCES, MODE_PRIVATE).edit() + editor.putBoolean(key, value) + editor.apply() + } + + companion object { + const val STARTUP_SCREEN_CLOUD = "cloud" + const val STARTUP_SCREEN_SYNC = "sync" + const val STARTUP_SCREEN_SETTINGS = "settings" + + const val CLOUD_PROVIDER_DROPBOX = "dropbox" + const val CLOUD_PROVIDER_ONEDRIVE = "onedrive" + + const val SHARE_TO_CLOUD_SHELF = "cloud" + const val SHARE_TO_SYNC_SHELF = "sync" + + const val MAIN_PREFERENCES = "ScrapyardMainPreferences" + + const val PREF_DROPBOX_AUTH_TOKEN = "PREF_DROPBOX_AUTH_TOKEN" + const val PREF_ONEDRIVE_SIGNED_IN = "PREF_ONEDRIVE_SIGNED_IN" + + const val PREF_STARTUP_SCREEN = "PREF_STARTUP_SCREEN" + const val PREF_REMEMBER_TREE_STATE = "PREF_REMEMBER_TREE_STATE" + + const val PREF_CLOUD_SHELF_PROVIDER = "PREF_CLOUD_SHELF_PROVIDER" + const val PREF_SYNC_BOOKMARKS_PROVIDER = "PREF_SYNC_BOOKMARKS_PROVIDER" + + const val PREF_SHARE_TO_SHELF = "PREF_SHARE_TO_SHELF" + const val PREF_DEFAULT_BOOKMARK_FOLDER = "PREF_DEFAULT_BOOKMARK_FOLDER" + const val PREF_ASK_ADDITIONAL_PROPERTIES = "PREF_ASK_ADDITIONAL_PROPERTIES" + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/bookmarks/StorageService.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/bookmarks/StorageService.kt new file mode 100644 index 00000000..3288392a --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/bookmarks/StorageService.kt @@ -0,0 +1,91 @@ +package l2.albitron.scrapyard.cloud.bookmarks + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi +import java.io.File +import java.io.FileOutputStream +import java.lang.Exception + +class StorageService(context: Context) { + var _context = context + + private fun openIntent(uri: Uri?, type: String) { + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY + intent.setDataAndType(uri, type) + _context.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun writeToDiskAndOpen(bytes: ByteArray?, name: String, type: String): Boolean { + try { + val mimeTypeMap = MimeTypeMap.getSingleton() + val extension = mimeTypeMap.getExtensionFromMimeType(type) + val fileName = if (name.endsWith(extension!!)) name else "$name.$extension" + var uri: Uri? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + saveForAndroidQAndLatter(bytes, fileName, type) + else + saveForAndroidLessThanQ(bytes, fileName) + openIntent(uri, type) + } catch (e: Exception) { + e.printStackTrace() + } + return false + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveForAndroidQAndLatter(bytes: ByteArray?, fileName: String, type: String): Uri? { + var values = ContentValues() + values.put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) + values.put(MediaStore.Files.FileColumns.MIME_TYPE, type) + values.put(MediaStore.MediaColumns.RELATIVE_PATH, "Download") + values.put(MediaStore.MediaColumns.IS_PENDING, true) + + val resolver = _context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + + try { + resolver.openOutputStream(uri!!).use { output -> + output!!.write(bytes) + output.flush() + } + } + finally { + values = ContentValues() + values.put(MediaStore.Images.ImageColumns.IS_PENDING, false) + resolver.update(uri!!, values, null, null) + } + + return uri + } + + @Suppress("DEPRECATION") + private fun saveForAndroidLessThanQ(bytes: ByteArray?, fileName: String): Uri? { + val directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(directory, fileName) + val uri = Uri.fromFile(file) + + file.parentFile?.mkdirs() + + if (!file.exists()) { + file.createNewFile() + FileOutputStream(file).use { fos -> + fos.write(bytes) + fos.flush() + } + } + + return uri + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/AbstractCloudDB.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/AbstractCloudDB.kt new file mode 100644 index 00000000..5f8421ab --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/AbstractCloudDB.kt @@ -0,0 +1,360 @@ +package l2.albitron.scrapyard.cloud.db + +import com.fasterxml.jackson.core.JsonProcessingException +import l2.albitron.scrapyard.Scrapyard +import l2.albitron.scrapyard.cloud.db.model.* +import l2.albitron.scrapyard.cloud.providers.CloudProvider +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudItemNotFoundException +import l2.albitron.scrapyard.isContentNode +import java.io.IOException +import java.io.InputStream +import java.lang.Exception +import java.text.SimpleDateFormat +import java.util.* + +abstract class AbstractCloudDB { + protected val OBJECTS_DIRECTORY = "objects" + protected val ARCHIVE_DIRECTORY = "archive" + protected val ICON_OBJECT_FILE = "icon.json" + protected val NOTES_OBJECT_FILE = "notes.json" + protected val NOTES_INDEX_FILE = "notes_index.json" + protected val ARCHIVE_CONTENT_FILE = "archive_content.blob" + protected val ARCHIVE_INDEX_FILE = "archive_index.json" + + protected var _bookmarks: MutableList = ArrayList() + protected abstract var _provider: CloudProvider + protected abstract var _meta: JSONScrapbookMeta + + protected abstract fun createTypeMeta(): JSONScrapbookMeta + protected abstract fun getDatabaseFile(): String + protected abstract fun getCloudPath(file: String): String + protected abstract fun getSharingShelfUUID(): String + + val size: Int + get() = _bookmarks.size + + protected fun createMeta(type: String?, contains: String?): JSONScrapbookMeta { + val meta = JSONScrapbookMeta() + + meta.format = Scrapyard.FORMAT_NAME + meta.version = Scrapyard.FORMAT_VERSION + meta.type = type + meta.contains = contains + meta.uuid = Scrapyard.genUUID() + meta.entities = 0 + meta.timestamp = System.currentTimeMillis() + meta.date = getISOTimestamp() + + return meta + } + + protected fun getObjectDirectory(uuid: String): String = "$OBJECTS_DIRECTORY/$uuid" + protected fun getObjectPath(uuid: String, asset: String): String = "$OBJECTS_DIRECTORY/$uuid/$asset" + + protected fun getISOTimestamp(): String { + val df = SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss.SSS") + return df.format(Calendar.getInstance().time) + } + + fun getBookmarks(): MutableList { + return _bookmarks + } + + fun setBookmarks(bookmarks: MutableList) { + _bookmarks = bookmarks + } + + protected fun findFolder(name: String, parentUUID: String): Node? { + val folderFilter = { b: Node -> b.type != null + && b.type == Scrapyard.NODE_TYPE_FOLDER + && parentUUID == b.parent + && name.equals(b.title, ignoreCase = true) + } + return getBookmarks().firstOrNull(folderFilter) + } + + protected fun newFolderNode(name: String, parentUUID: String): Node { + val folderNode = Node() + folderNode.title = name + folderNode.parent = parentUUID + folderNode.uuid = Scrapyard.genUUID() + folderNode.type = Scrapyard.NODE_TYPE_FOLDER + folderNode.dateAdded = System.currentTimeMillis() + folderNode.dateModified = folderNode.dateAdded + + return folderNode + } + + protected fun initializeNode(node: Node, parent: Node): Node { + node.uuid = Scrapyard.genUUID() + node.dateAdded = System.currentTimeMillis() + node.dateModified = node.dateAdded + node.parent = parent.uuid + + if (node.type == Scrapyard.NODE_TYPE_ARCHIVE || node.type == Scrapyard.NODE_TYPE_NOTES) + node.contentModified = node.dateModified + + return node + } + + fun deleteNode(uuid: String?) { + if (uuid != Scrapyard.DEFAULT_SHELF_UUID) { + + val bookmarks = getBookmarks() + val subtree = queryFullSubtree(uuid, bookmarks) + val subtreeUUIDs: Set = subtree.map { n -> n?.uuid }.toSet() + val subtreeFilter = { b: Node -> subtreeUUIDs.contains(b.uuid) } + val nonSubtreeFilter = { b: Node -> !subtreeUUIDs.contains(b.uuid) } + val subtreeBookmarks = bookmarks.filter(subtreeFilter) + + setBookmarks(bookmarks.filter(nonSubtreeFilter).toMutableList()) + for (bookmark in subtreeBookmarks) + if (isContentNode(bookmark)) + deleteBookmarkAssets(bookmark) + } + } + + private fun deleteBookmarkAssets(node: Node) { + val objectDirectory = getObjectDirectory(node.uuid!!) + deleteCloudFile(objectDirectory) + } + + private fun queryFullSubtree(uuid: String?, bookmarks: MutableList): List { + val root = bookmarks.firstOrNull { uuid?.equals( it.uuid, ignoreCase = true) == true } + val result: MutableList = ArrayList() + if (root != null) { + val node = root + result.add(node) + if (node.type == Scrapyard.NODE_TYPE_SHELF || node.type == Scrapyard.NODE_TYPE_FOLDER) + getChildren(root, bookmarks, result) + } + return result + } + + private fun getChildren(node: Node, bookmarks: MutableList, outNodes: MutableList) { + val children = bookmarks.filter { node.uuid.equals(it.parent, ignoreCase = true) } + for (bookmark in children) { + val childNode = bookmark + outNodes.add(childNode) + if (childNode.type == Scrapyard.NODE_TYPE_SHELF || childNode.type == Scrapyard.NODE_TYPE_FOLDER) + getChildren(childNode, bookmarks, outNodes) + } + } + + private fun readCloudBinaryFile(file: String): ByteArray? { + val path = getCloudPath(file) + + return try { + _provider.downloadBinaryFile(path) + } catch (e: CloudItemNotFoundException) { + null + } + } + + private fun readCloudFile(file: String): String? { + val path = getCloudPath(file) + + return try { + _provider.downloadTextFile(path) + } catch (e: CloudItemNotFoundException) { + null + } + } + + private fun writeCloudFile(file: String, content: String) { + val path = getCloudPath(file) + _provider.writeTextFile(path, content) + } + + private fun deleteCloudFile(file: String) { + try { + val path = getCloudPath(file) + _provider.deleteFile(path) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun deserialize(content: String) { + try { + val lines = content.split("\n".toRegex()) + + if (lines.isNotEmpty()) + _meta = lines[0].fromJSON() + + if (lines.size > 1) { + for (line in lines.subList(1, lines.size)) { + val node = line.fromJSON() + + _bookmarks.add(node) + } + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + private fun serialize(): String? { + _meta.timestamp = System.currentTimeMillis() + _meta.date = getISOTimestamp() + _meta.entities = _bookmarks.size.toLong() + + var result: String? = null + try { + val lines = ArrayList(_bookmarks.size + 1) + + lines.add(_meta.toJSON()) + + for (node in _bookmarks) + lines.add(node.toJSON()) + + result = lines.joinToString("\n") + } catch (e: JsonProcessingException) { + e.printStackTrace() + } + + return result + } + + fun isEmpty(): Boolean { + return _bookmarks.size == 0 + } + + fun download() { + val dbFile = getDatabaseFile() + val content = readCloudFile(dbFile) + + if (content != null) + deserialize(content) + } + + fun persist() { + val dbFile = getDatabaseFile() + val content = serialize() + + if (content != null) + writeCloudFile(dbFile, content) + } + + fun reset() { + _meta = createTypeMeta() + _bookmarks = ArrayList() + } + + fun downloadRaw(): String? { + val dbFile = getDatabaseFile() + return readCloudFile(dbFile) + } + + fun downloadAsset(uuid: String, asset: String): String? { + val objectPath = getObjectPath(uuid, asset) + return readCloudFile(objectPath) + } + + fun downloadUnpackedIndex(uuid: String): String? { + val objectPath = getObjectPath(uuid, "$ARCHIVE_DIRECTORY/index.html") + return readCloudFile(objectPath) + } + + fun downloadUnpackedAsset(uuid: String, assetPath: String): InputStream? { + val objectPath = getObjectPath(uuid, "$ARCHIVE_DIRECTORY/$assetPath") + val cloudPath = getCloudPath(objectPath) + + try { + return _provider.downloadInputStream(cloudPath) + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + fun downloadIcon(uuid: String): String? { + val path = getObjectPath(uuid, ICON_OBJECT_FILE) + val content = readCloudFile(path) + + return extractIconURL(content) + } + + private fun extractIconURL(content: String?): String? { + var result: String? = null + + if (content != null) { + try { + val icon: Icon = content.fromJSON() + result = icon.dataURL + } + catch (e: Exception) { + e.printStackTrace() + } + } + + return result + } + + fun storeIcon(node: Node, dataURL: String) { + val objectPath = getObjectPath(node.uuid!!, ICON_OBJECT_FILE) + val icon = Icon() + + icon.dataURL = dataURL + + val json = icon.toJSON() + + writeCloudFile(objectPath, json) + } + + fun getOrCreateSharingFolder(name: String): Node { + val sharingShelfUUID = getSharingShelfUUID() + var folder = findFolder(name, sharingShelfUUID) + + if (folder == null) { + folder = newFolderNode(name, sharingShelfUUID) + _bookmarks.add(folder) + } + + return folder + } + + fun addNode(node: Node, parent: Node): Node { + initializeNode(node, parent) + _bookmarks.add(node) + + return node + } + + fun storeNewBookmarkArchive(node: Node, text: String) { + val objectPath = getObjectPath(node.uuid!!, ARCHIVE_CONTENT_FILE) + writeCloudFile(objectPath, text) + } + + fun storeArchiveIndex(node: Node, text: String) { + val objectPath = getObjectPath(node.uuid!!, ARCHIVE_INDEX_FILE) + writeCloudFile(objectPath, text) + } + + fun storeNewBookmarkNotes(node: Node, text: String) { + val notes = Notes() + notes.content = text + notes.format = Scrapyard.NOTES_FORMAT_TEXT + + var json = "" + try { + json = notes.toJSON() + } catch (e: JsonProcessingException) { + e.printStackTrace() + } + + val objectPath = getObjectPath(node.uuid!!, NOTES_OBJECT_FILE) + writeCloudFile(objectPath, json) + } + + fun storeNotesIndex(node: Node, text: String) { + val objectPath = getObjectPath(node.uuid!!, NOTES_INDEX_FILE) + writeCloudFile(objectPath, text) + } + + fun getArchiveBytes(uuid: String): ByteArray? { + val objectPath = getObjectPath(uuid, ARCHIVE_CONTENT_FILE) + return readCloudBinaryFile(objectPath) + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudDB.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudDB.kt new file mode 100644 index 00000000..1b831460 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudDB.kt @@ -0,0 +1,51 @@ +package l2.albitron.scrapyard.cloud.db + +import android.content.Context +import l2.albitron.scrapyard.cloud.db.model.Node +import l2.albitron.scrapyard.cloud.providers.CloudProvider +import java.io.InputStream + +interface CloudDB { + val size: Int + fun isEmpty(): Boolean + fun download() + fun persist() + fun reset() + + fun downloadRaw(): String? + fun downloadAsset(uuid: String, asset: String): String? + fun downloadUnpackedIndex(uuid: String): String? + fun downloadUnpackedAsset(uuid: String, assetPath: String): InputStream? + + fun downloadIcon(uuid: String): String? + fun storeIcon(node: Node, dataURL: String) + + fun getOrCreateSharingFolder(name: String): Node + fun addNode(node: Node, parent: Node): Node + fun deleteNode(uuid: String?) + + fun storeNewBookmarkArchive(node: Node, text: String) + fun storeArchiveIndex(node: Node, text: String) + fun storeNewBookmarkNotes(node: Node, text: String) + fun storeNotesIndex(node: Node, text: String) + fun getArchiveBytes(uuid: String): ByteArray? + + fun getType(): String + + companion object { + suspend fun newInstance(context: Context, type: String?): CloudDB? { + val provider = + when (type) { + CloudShelfDB.DATABASE_TYPE -> CloudProvider.newCloudShelfProvider(context) + SyncStorageDB.DATABASE_TYPE -> CloudProvider.newSyncBookmarksProvider(context) + else -> null + } + + return when(type) { + CloudShelfDB.DATABASE_TYPE -> CloudShelfDB(provider!!) + SyncStorageDB.DATABASE_TYPE -> SyncStorageDB(provider!!) + else -> null + } + } + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudShelfDB.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudShelfDB.kt new file mode 100644 index 00000000..6e1a0490 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/CloudShelfDB.kt @@ -0,0 +1,34 @@ +package l2.albitron.scrapyard.cloud.db + +import l2.albitron.scrapyard.Scrapyard +import l2.albitron.scrapyard.cloud.db.model.* +import l2.albitron.scrapyard.cloud.providers.CloudProvider + +private const val CLOUD_SHELF_PATH = "/Cloud" +private const val CLOUD_DB_INDEX = "cloud.jsbk" + +class CloudShelfDB: AbstractCloudDB, CloudDB { + override var _provider: CloudProvider + override var _meta: JSONScrapbookMeta + + constructor(provider: CloudProvider) { + _provider = provider + _meta = createTypeMeta() + } + + override fun createTypeMeta(): JSONScrapbookMeta { + return super.createMeta(Scrapyard.FORMAT_TYPE_CLOUD, null) + } + + override fun getDatabaseFile(): String = CLOUD_DB_INDEX + override fun getCloudPath(file: String): String = "${CLOUD_SHELF_PATH}/$file" + override fun getSharingShelfUUID(): String = Scrapyard.CLOUD_SHELF_UUID + + override fun getType(): String { + return DATABASE_TYPE + } + + companion object { + const val DATABASE_TYPE = "cloud" + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/SyncStorageDB.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/SyncStorageDB.kt new file mode 100644 index 00000000..c6ecbac1 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/SyncStorageDB.kt @@ -0,0 +1,34 @@ +package l2.albitron.scrapyard.cloud.db + +import l2.albitron.scrapyard.Scrapyard +import l2.albitron.scrapyard.cloud.db.model.* +import l2.albitron.scrapyard.cloud.providers.CloudProvider + +private const val SYNC_STORAGE_PATH = "/Sync" +private const val SYNC_DB_INDEX = "index.jsbk" + +class SyncStorageDB: AbstractCloudDB, CloudDB { + override var _provider: CloudProvider + override var _meta: JSONScrapbookMeta + + constructor(provider: CloudProvider) { + _provider = provider + _meta = createTypeMeta() + } + + override fun createTypeMeta(): JSONScrapbookMeta { + return super.createMeta(Scrapyard.FORMAT_TYPE_INDEX, Scrapyard.FORMAT_CONTAINS_SHELVES) + } + + override fun getDatabaseFile(): String = SYNC_DB_INDEX + override fun getCloudPath(file: String): String = "${SYNC_STORAGE_PATH}/$file" + override fun getSharingShelfUUID(): String = Scrapyard.DEFAULT_SHELF_UUID + + override fun getType(): String { + return DATABASE_TYPE + } + + companion object { + const val DATABASE_TYPE = "sync" + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Archive.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Archive.kt new file mode 100644 index 00000000..cee1e66f --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Archive.kt @@ -0,0 +1,13 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonPropertyOrder +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonPropertyOrder("type", "content_type") +class Archive : JSONEntity() { + @JsonProperty("type") + var type: String? = null + + @JsonProperty("content_type") + var byteLength: Long? = null +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Icon.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Icon.kt new file mode 100644 index 00000000..7108434f --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Icon.kt @@ -0,0 +1,8 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonProperty + +class Icon : JSONEntity() { + @JsonProperty("url") + var dataURL: String? = null +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Index.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Index.kt new file mode 100644 index 00000000..b89239df --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Index.kt @@ -0,0 +1,8 @@ +package l2.albitron.scrapyard.cloud.db.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class Index : JSONEntity() { + @JsonProperty("content") + var content: List? = null +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONEntity.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONEntity.kt new file mode 100644 index 00000000..1c885a06 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONEntity.kt @@ -0,0 +1,34 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import java.util.HashMap + +@JsonInclude(JsonInclude.Include.NON_NULL) +open class JSONEntity { + @JsonIgnore + private var _additionalProperties: MutableMap = HashMap() + + @JsonAnyGetter + fun getAdditionalProperties(): Map { + return _additionalProperties + } + + @JsonAnySetter + fun setAdditionalProperty(name: String, value: Any) { + _additionalProperties[name] = value + } + + fun toJSON(): String { + val objectMapper = ObjectMapper() + return objectMapper.writeValueAsString(this) + } +} + +inline fun String.fromJSON(): T { + val objectMapper = ObjectMapper() + return objectMapper.readValue(this, T::class.java) +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONScrapbookMeta.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONScrapbookMeta.kt new file mode 100644 index 00000000..bb087ddc --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/JSONScrapbookMeta.kt @@ -0,0 +1,31 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonPropertyOrder +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonPropertyOrder("format", "version", "type", "contains", "uuid", "entities", "timestamp", "date") +class JSONScrapbookMeta: JSONEntity() { + @JsonProperty("format") + var format: String? = null + + @JsonProperty("version") + var version: Long? = null + + @JsonProperty("type") + var type: String? = null + + @JsonProperty("contains") + var contains: String? = null + + @JsonProperty("uuid") + var uuid: String? = null + + @JsonProperty("entities") + var entities: Long? = null + + @JsonProperty("timestamp") + var timestamp: Long? = null + + @JsonProperty("date") + var date: String? = null +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Node.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Node.kt new file mode 100644 index 00000000..38296973 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Node.kt @@ -0,0 +1,83 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonPropertyOrder +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder( + "type", + "uuid", + "parent", + "title", + "url", + "content_type", + "archive_type", + "size", + "tags", + "date_added", + "date_modified", + "content_modified", + "external", + "external_id", + "stored_icon", + "has_comments", + "has_notes", + "todo_state", + "todo_date", + "details", + "pos" +) +class Node : JSONEntity() { + @JsonProperty("title") + var title: String? = null + + @JsonProperty("url") + var url: String? = null + + @JsonProperty("tags") + var tags: String? = null + + @JsonProperty("icon") + var icon: String? = null + + @JsonProperty("parent") + var parent: String? = null + + @JsonProperty("type") + var type: String? = null + + @JsonProperty("archive_type") + var archiveType: String? = null + + @JsonProperty("pos") + var pos: Long? = null + + @JsonProperty("date_added") + var dateAdded: Long? = null + + @JsonProperty("date_modified") + var dateModified: Long? = null + + @JsonProperty("content_modified") + var contentModified: Long? = null + + @JsonProperty("uuid") + var uuid: String? = null + + @JsonProperty("todo_state") + var todoState: String? = null + + @JsonProperty("details") + var details: String? = null + + @JsonProperty("has_notes") + var hasNotes: Boolean? = null + + @JsonProperty("has_icon") + var hasIcon: Boolean? = null + + @JsonProperty("content_type") + var contentType: String? = null +} + diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Notes.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Notes.kt new file mode 100644 index 00000000..b59a7cde --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/db/model/Notes.kt @@ -0,0 +1,13 @@ +package l2.albitron.scrapyard.cloud.db.model + +import com.fasterxml.jackson.annotation.JsonPropertyOrder +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonPropertyOrder("content", "format") +class Notes : JSONEntity() { + @JsonProperty("content") + var content: String? = null + + @JsonProperty("format") + var format: String? = null +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/CloudProvider.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/CloudProvider.kt new file mode 100644 index 00000000..6c634300 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/CloudProvider.kt @@ -0,0 +1,43 @@ +package l2.albitron.scrapyard.cloud.providers + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import l2.albitron.scrapyard.Settings +import java.io.InputStream + +interface CloudProvider { + suspend fun initialize(context: Context) + fun downloadInputStream(path: String): InputStream? + fun downloadBinaryFile(path: String): ByteArray? + fun downloadTextFile(path: String): String? + fun downloadRange(path: String, start: Long, length: Long): String? + fun writeTextFile(path: String, content: String) + fun deleteFile(path: String) + + companion object { + suspend fun newCloudShelfProvider(context: Context): CloudProvider { + val settings = Settings(context) + return getProvider(context, settings.cloudShelfProvider) + } + + suspend fun newSyncBookmarksProvider(context: Context): CloudProvider { + val settings = Settings(context) + return getProvider(context, settings.syncBookmarksProvider) + } + + private suspend fun getProvider(context: Context, kind: String): CloudProvider { + val provider = when (kind) { + Settings.CLOUD_PROVIDER_ONEDRIVE -> OneDriveProvider() + else -> DropboxProvider() + } + + provider.initialize(context) + + return provider + } + + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/DropboxProvider.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/DropboxProvider.kt new file mode 100644 index 00000000..1068b000 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/DropboxProvider.kt @@ -0,0 +1,165 @@ +package l2.albitron.scrapyard.cloud.providers + +import android.content.Context +import com.dropbox.core.DbxRequestConfig +import com.dropbox.core.android.Auth +import l2.albitron.scrapyard.BuildConfig + +import com.dropbox.core.DbxException +import com.dropbox.core.NetworkIOException +import com.dropbox.core.oauth.DbxCredential +import com.dropbox.core.v2.DbxClientV2 +import com.dropbox.core.v2.files.WriteMode +import com.microsoft.graph.http.GraphServiceException +import com.microsoft.graph.models.DriveItem +import l2.albitron.scrapyard.Settings +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudItemNotFoundException +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudNetworkException +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudNotAuthorizedException +import java.io.* +import java.lang.Exception +import java.nio.charset.StandardCharsets + + +class DropboxProvider : CloudProvider { + private lateinit var _client: DbxClientV2 + + override suspend fun initialize(context: Context) { + val settings = Settings(context) + val authToken = settings.dropboxAuthToken + + if (authToken?.isNotEmpty() == true) { + val config = DbxRequestConfig("Scrapyard") + val credential = DbxCredential.Reader.readFully(authToken) + _client = DbxClientV2(config, credential) + } else + throw CloudNotAuthorizedException() + } + + private fun copyStream(source: InputStream, target: OutputStream) { + val buf = ByteArray(8192) + var length: Int + while (source.read(buf).also { length = it } > 0) + target.write(buf, 0, length) + } + + private fun downloadInputStreamInternal(path: String): Pair { + try { + val downloader = _client.files().download(path) + val fileSize = downloader.result.size.toInt() + + try { + return Pair(downloader.inputStream, fileSize) + } catch (e: IOException) { + e.printStackTrace() + } + } catch (e: DbxException) { + if (BuildConfig.DEBUG) + e.printStackTrace() + + if (e is NetworkIOException) + throw CloudNetworkException(e) + + throw CloudItemNotFoundException(e) + } + + return Pair(null, null) + } + + override fun downloadInputStream(path: String): InputStream? { + val (inputStream, driveItem) = downloadInputStreamInternal(path) + + return if (driveItem != null && inputStream != null) + inputStream + else + null + } + + override fun downloadBinaryFile(path: String): ByteArray? { + val (inputStream, fileSize) = downloadInputStreamInternal(path) + + return inputStream?.use { inputStream -> + ByteArrayOutputStream(fileSize!!).use { out -> + copyStream(inputStream, out) + return out.toByteArray() + } + } + } + + override fun downloadTextFile(path: String): String? { + val bytes = downloadBinaryFile(path) + + if (bytes != null) + return String(bytes, StandardCharsets.UTF_8) + + return null + } + + override fun downloadRange(path: String, start: Long, length: Long): String? { + try { + val downloader = _client.files().downloadBuilder(path).range(start, length).start() + try { + ByteArrayOutputStream(length.toInt()).use { out -> + copyStream(downloader.inputStream, out) + return String(out.toByteArray(), StandardCharsets.UTF_8) + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + downloader.close() + } + } catch (e: DbxException) { + if (BuildConfig.DEBUG) + e.printStackTrace() + } + return null + } + + override fun writeTextFile(path: String, content: String) { + try { + ByteArrayInputStream(content.toByteArray(StandardCharsets.UTF_8)).use { `in` -> + _client.files().uploadBuilder(path) + .withMode(WriteMode.OVERWRITE) + .uploadAndFinish(`in`) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun deleteFile(path: String) { + try { + _client.files().deleteV2(path) + } catch (e: Exception) { + e.printStackTrace() + } + } + + companion object { + fun startSignIn(context: Context): Boolean { + val settings = Settings(context) + + return if (settings.dropboxAuthToken?.isNotEmpty() == true) { + settings.clearDropboxAuthToken() + false + } else { + val requestConfig = DbxRequestConfig("Scrapyard") + Auth.startOAuth2PKCE(context, BuildConfig.DBX_API_KEY, requestConfig) + true + } + } + + fun finishSignIn(context: Context): Boolean { + val credential = Auth.getDbxCredential() + + return if (credential != null) { + Settings(context).dropboxAuthToken = credential.toString(); + true + } else + false + } + + fun isSignedIn(context: Context): Boolean = + Settings(context).dropboxAuthToken?.isNotEmpty() == true + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/OneDriveProvider.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/OneDriveProvider.kt new file mode 100644 index 00000000..9e35af00 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/OneDriveProvider.kt @@ -0,0 +1,338 @@ +package l2.albitron.scrapyard.cloud.providers + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.util.Base64 +import android.util.Log +import com.microsoft.graph.authentication.BaseAuthenticationProvider +import com.microsoft.graph.http.GraphServiceException +import com.microsoft.graph.models.DriveItem +import com.microsoft.graph.models.DriveItemCreateUploadSessionParameterSet +import com.microsoft.graph.models.DriveItemUploadableProperties +import com.microsoft.graph.requests.DriveItemRequestBuilder +import com.microsoft.graph.requests.GraphServiceClient +import com.microsoft.graph.tasks.LargeFileUploadTask +import com.microsoft.identity.client.* +import com.microsoft.identity.client.IPublicClientApplication.ISingleAccountApplicationCreatedListener +import com.microsoft.identity.client.exception.MsalClientException +import com.microsoft.identity.client.exception.MsalException +import com.microsoft.identity.client.exception.MsalUiRequiredException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import l2.albitron.scrapyard.BuildConfig +import l2.albitron.scrapyard.R +import l2.albitron.scrapyard.Settings +import l2.albitron.scrapyard.cloud.bookmarks.StorageService +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudItemNotFoundException +import l2.albitron.scrapyard.cloud.providers.exceptions.CloudNotAuthorizedException +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + + +private val ONEDRIVE_PERMISSIONS = arrayOf("User.Read", "Files.ReadWrite", "Files.ReadWrite.AppFolder") + +class OneDriveProvider : CloudProvider { + private lateinit var _application: ISingleAccountPublicClientApplication + private lateinit var _graphClient: GraphServiceClient + + override suspend fun initialize(context: Context) { + val settings = Settings(context) + + if (!settings.isOneDriveSignedIn) + throw CloudNotAuthorizedException() + + _application = createClientApplication(context) + + val authProvider = AuthenticationProvider(settings) + _graphClient = GraphServiceClient.builder().authenticationProvider(authProvider).buildClient() + } + + private fun copyStream(source: InputStream, target: OutputStream) { + val buf = ByteArray(8192) + var length: Int + while (source.read(buf).also { length = it } > 0) + target.write(buf, 0, length) + } + + private suspend fun acquireTokenSilently(): IAuthenticationResult? { + val authority: String = _application.configuration.defaultAuthority.authorityURL.toString() + + return suspendCoroutine { continuation -> + _application.acquireTokenSilentAsync(ONEDRIVE_PERMISSIONS, authority, object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult) { + continuation.resume(authenticationResult) + } + override fun onError(exception: MsalException) { + continuation.resumeWithException(exception) + } + override fun onCancel() { + continuation.resume(null) + } + }) + } + } + + private fun normalizePath(path: String): String { + return if (path.startsWith("/")) + path.substring(1) + else + path + } + + private fun itemWithPath(path: String): DriveItemRequestBuilder { + val normalizedPath = normalizePath(path) + + return _graphClient + .me() + .drive() + .special() + .appRoot() + .itemWithPath(normalizedPath) + } + + // GraphServiceException: Error code: itemNotFound + private fun downloadInputStreamInternal(path: String): Pair { + var driveItem: DriveItem? = null + var inputStream: InputStream? = null + + try { + driveItem = itemWithPath(path).buildRequest().get() + inputStream = itemWithPath(path).content().buildRequest().get() + } + catch(e: GraphServiceException) { + if (e.serviceError?.code == "itemNotFound") + throw CloudItemNotFoundException(e) + + e.printStackTrace() + } + + return if (driveItem != null && inputStream != null) + Pair(inputStream, driveItem?.size?.toInt()) + else + Pair(null, null) + } + + override fun downloadInputStream(path: String): InputStream? { + val (inputStream, driveItem) = downloadInputStreamInternal(path) + + return if (driveItem != null && inputStream != null) + inputStream + else + null + } + + override fun downloadBinaryFile(path: String): ByteArray? { + val (inputStream, fileSize) = downloadInputStreamInternal(path) + + return inputStream?.use { inputStream -> + ByteArrayOutputStream(fileSize!!).use { out -> + copyStream(inputStream, out) + return out.toByteArray() + } + } + } + + override fun downloadTextFile(path: String): String? { + val bytes = downloadBinaryFile(path) + + if (bytes != null) + return String(bytes, StandardCharsets.UTF_8) + + return null + } + + override fun downloadRange(path: String, start: Long, length: Long): String? { + val driveItem = itemWithPath(path).buildRequest().get() + val downloadURL = driveItem?.additionalDataManager()?.getValue("@microsoft.graph.downloadUrl")?.asString + var result: String? = null + + if (downloadURL != null) { + val request = Request.Builder() + .url(downloadURL) + .header("Range", "bytes=$start-${start + length - 1}") + .build() + + val client = OkHttpClient(); + client.newCall(request).execute().use { + val bytes = it.body?.bytes() + if (bytes != null) + result = String(bytes, StandardCharsets.UTF_8) + } + } + + return result + } + + override fun writeTextFile(path: String, content: String) { + val bytes = content.toByteArray(StandardCharsets.UTF_8) + + if (bytes.size > 4 * 1024 * 1024) + uploadLargeFile(path, bytes) + else + uploadSmallFile(path, bytes) + } + + private fun uploadSmallFile(path: String, bytes: ByteArray) { + itemWithPath(path).content().buildRequest().put(bytes) + } + + private fun uploadLargeFile(path: String, bytes: ByteArray) { + val uploadParams = DriveItemCreateUploadSessionParameterSet.newBuilder() + .withItem(DriveItemUploadableProperties()).build() + + val uploadSession = itemWithPath(path) + .createUploadSession(uploadParams) + .buildRequest() + .post() + + val inputStream = ByteArrayInputStream(bytes) + + if (uploadSession != null) { + val largeFileUploadTask = LargeFileUploadTask( + uploadSession, _graphClient, inputStream, bytes.size.toLong(), + DriveItem::class.java + ) + + largeFileUploadTask.upload(0, null) + } + } + + override fun deleteFile(path: String) { + itemWithPath(path).buildRequest().delete() + } + + private inner class AuthenticationProvider(settings: Settings) : BaseAuthenticationProvider() { + private val _settings = settings + + @SuppressLint("NewApi") + override fun getAuthorizationTokenAsync(requestUrl: URL): CompletableFuture { + return if (shouldAuthenticateRequestWithUrl(requestUrl)) { + val future = CompletableFuture() + + CoroutineScope(Dispatchers.IO).launch { + try { + val authResult: IAuthenticationResult? = acquireTokenSilently() + future.complete(authResult?.accessToken) + } + catch (e: MsalUiRequiredException) { + _settings.isOneDriveSignedIn = false + future.completeExceptionally(CloudNotAuthorizedException()) + } + catch (e: MsalClientException) { + if (e.getErrorCode() == "no_current_account" || e.getErrorCode() == "no_account_found") { + _settings.isOneDriveSignedIn = false + future.completeExceptionally(CloudNotAuthorizedException()) + } + else + future.completeExceptionally(e) + } + catch (e: Exception) { + future.completeExceptionally(e) + } + } + future + } else + CompletableFuture.completedFuture(null) + } + } + + companion object { + suspend fun createClientApplication(context: Context): ISingleAccountPublicClientApplication { + return suspendCoroutine { continuation -> + val configResource = + if (BuildConfig.DEBUG) + R.raw.msal_auth_config_debug + else + R.raw.msal_auth_config + + PublicClientApplication.createSingleAccountPublicClientApplication(context, configResource, + object : ISingleAccountApplicationCreatedListener { + override fun onCreated(application: ISingleAccountPublicClientApplication) { + continuation.resume(application) + } + override fun onError(exception: MsalException) { + continuation.resumeWithException(exception) + } + }) + } + } + + suspend fun signIn(context: Activity) { + val application = createClientApplication(context) + + return suspendCoroutine { continuation -> + application.signIn(context, "", ONEDRIVE_PERMISSIONS, + object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult) { + continuation.resume(Unit) + } + override fun onError(exception: MsalException) { + continuation.resumeWithException(exception) + } + override fun onCancel() { + continuation.resume(Unit) + } + }) + } + } + + + suspend fun signOut(context: Activity) { + val application = createClientApplication(context) + + return suspendCoroutine { continuation -> + application.signOut(object : ISingleAccountPublicClientApplication.SignOutCallback { + override fun onSignOut() { + continuation.resume(Unit) + } + + override fun onError(exception: MsalException) { + continuation.resumeWithException(exception) + } + }) + } + } + + @Suppress("DEPRECATION") + fun getSignature(context: Context) { + val packageName: String = context.getPackageName() + try { + val info: PackageInfo = context.getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + for (signature in info.signatures) { + var md: MessageDigest + md = MessageDigest.getInstance("SHA") + md.update(signature.toByteArray()) + //val sha1Singature = String(Base64.encode(md.digest(), 0)) + //String something = new String(Base64.encodeBytes(md.digest())); + //Log.e("TAG", sha1Singature) + + val sha1SingatureBytes = Base64.encode(md.digest(), 0) + val storageService = StorageService(context) + storageService.writeToDiskAndOpen(sha1SingatureBytes, "sig.txt", "text/plain") + } + } catch (e1: PackageManager.NameNotFoundException) { + Log.e("name not found", e1.toString()) + } catch (e: NoSuchAlgorithmException) { + Log.e("no such an algorithm", e.toString()) + } catch (e: Exception) { + Log.e("exception", e.toString()) + } + } + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudItemNotFoundException.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudItemNotFoundException.kt new file mode 100644 index 00000000..8a230375 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudItemNotFoundException.kt @@ -0,0 +1,6 @@ +package l2.albitron.scrapyard.cloud.providers.exceptions + +import java.lang.Exception + +class CloudItemNotFoundException(cause: Throwable) : Exception(cause) { +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNetworkException.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNetworkException.kt new file mode 100644 index 00000000..e5ae451d --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNetworkException.kt @@ -0,0 +1,4 @@ +package l2.albitron.scrapyard.cloud.providers.exceptions + +class CloudNetworkException(cause: Throwable) : Exception(cause) { +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNotAuthorizedException.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNotAuthorizedException.kt new file mode 100644 index 00000000..e080c57e --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/providers/exceptions/CloudNotAuthorizedException.kt @@ -0,0 +1,4 @@ +package l2.albitron.scrapyard.cloud.providers.exceptions + +class CloudNotAuthorizedException() : Exception() { +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/SharingService.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/SharingService.kt new file mode 100644 index 00000000..cb86e944 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/SharingService.kt @@ -0,0 +1,286 @@ +package l2.albitron.scrapyard.cloud.sharing + +import android.content.Context +import android.content.Intent +import android.text.Html +import kotlinx.coroutines.runBlocking +import l2.albitron.scrapyard.* +import l2.albitron.scrapyard.cloud.db.CloudDB +import l2.albitron.scrapyard.cloud.db.SyncStorageDB +import l2.albitron.scrapyard.cloud.db.model.Index +import l2.albitron.scrapyard.cloud.db.model.Node +import l2.albitron.scrapyard.cloud.sharing.exceptions.SharingException +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import java.lang.Exception +import java.lang.StringBuilder +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.* + +private const val MAX_TITLE_LENGTH = 60 + +class SharingService(context: Context) { + private val _context = context + + companion object { + const val EXTRA_REFERRER = "l2.albitron.scrapyard.sharing.extra.REFERRER" + const val EXTRA_TODO_STATE = "l2.albitron.scrapyard.sharing.extra.TODO_STATE" + const val EXTRA_TODO_DETAILS = "l2.albitron.scrapyard.sharing.extra.TODO_DETAILS" + const val EXTRA_CONTENT_TYPE = "l2.albitron.scrapyard.sharing.extra.CONTENT_TYPE" + const val EXTRA_CONFIGURE_PROVIDERS = "l2.albitron.scrapyard.sharing.extra.CONFIGURE_PROVIDERS" + } + + fun shareBookmark(params: Map) { + val settings = Settings(_context) + + val db = runBlocking { CloudDB.newInstance(_context, settings.shareToShelf)!! } + + db.download() + + if (SyncStorageDB.DATABASE_TYPE == db.getType() && db.isEmpty()) + throw SharingException(R.string.can_not_share_to_empty_sync) + + val sizeBeforeSharing = db.size + val targetFolder = db.getOrCreateSharingFolder(settings.sharedFolderName) + val bookmark = Node() + + val (title, text, url) = getBookmarkProperties(params) + + val iconURL = getFaviconFromContent(url) + + bookmark.title = title + bookmark.url = url + bookmark.todoState = params[EXTRA_TODO_STATE] as? String + bookmark.details = params[EXTRA_TODO_DETAILS] as? String + + if (iconURL != null) + bookmark.hasIcon = true + + bookmark.type = if (text != null) Scrapyard.NODE_TYPE_ARCHIVE else Scrapyard.NODE_TYPE_BOOKMARK + if (bookmark.type == Scrapyard.NODE_TYPE_ARCHIVE && url == null) { + bookmark.type = Scrapyard.NODE_TYPE_NOTES + bookmark.hasNotes = true + } + else if (bookmark.type == Scrapyard.NODE_TYPE_ARCHIVE) { + bookmark.contentType = "text/html" + bookmark.archiveType = Scrapyard.ARCHIVE_TYPE_TEXT + } + + db.addNode(bookmark, targetFolder) + + if (sizeBeforeSharing >= db.size) + throw SharingException(R.string.can_not_share_to_empty_sync) + + db.persist() + + if (bookmark.type == Scrapyard.NODE_TYPE_NOTES) { + val index = Index() + index.content = createIndex(text!!) + db.storeNotesIndex(bookmark, index.toJSON()) + } + else if (bookmark.type == Scrapyard.NODE_TYPE_ARCHIVE) { + val index = Index() + index.content = createIndex(text!!) + db.storeArchiveIndex(bookmark, index.toJSON()) + } + + if (iconURL != null && bookmark.uuid != null) + db.storeIcon(bookmark, iconURL) + + if (text != null) { + if (bookmark.type == Scrapyard.NODE_TYPE_ARCHIVE) + db.storeNewBookmarkArchive(bookmark, textToHTMLAttachment(text)) + else if (bookmark.type == Scrapyard.NODE_TYPE_NOTES) + db.storeNewBookmarkNotes(bookmark, text) + } + } + + fun getBookmarkProperties(params: Map): Triple { + val url = getSharedURL(params) + val text = getSharedText(params) + var title = getSharedTitle(params) + if (title == null && text != null) + title = shortenTitle(text) + if (title == null && url != null) + try { + title = URL(url).host + } + catch (e: Exception) {} + return Triple(title, text, url) + } + + private fun getSharedTitle(params: Map): String? { + val subject = params[Intent.EXTRA_SUBJECT] as? String + val title = params[Intent.EXTRA_TITLE] as? String + var result: String? = null + + if (title?.isNotBlank() == true) + result = title + + if (subject?.isNotBlank() == true) + result = subject + + if (result != null && result.length >= MAX_TITLE_LENGTH * 2) + result = shortenTitle(result) + else if ("Share via" == result) + result = shortenTitle(params[Intent.EXTRA_TEXT] as? String) + + return result + } + + private fun getSharedURL(params: Map): String? { + val text = params[Intent.EXTRA_TEXT] as? String + + if (text == null || text.isBlank()) + return null + + if (text.matches("^https?://(.*)".toRegex())) + return text + + val referrer = params[EXTRA_REFERRER] as? String + val pocket = referrer?.startsWith("com.ideashower.readitlater") == true + val chrome = referrer?.startsWith("com.android.chrome") == true + + if (pocket || chrome) { + val lines = text.split("\n").toTypedArray() + if (lines.size > 1) { + val url = lines[lines.size - 1].trim { it <= ' ' } + if (url.matches("^https?://(.*)".toRegex())) + return url + } + } + + return null + } + + private fun getSharedText(params: Map): String? { + var result: String? = params[Intent.EXTRA_TEXT] as? String + if (result == null || result.matches("^https?://(.*)".toRegex())) + return null + + val referrer = params[EXTRA_REFERRER] as? String + val pocket = referrer?.startsWith("com.ideashower.readitlater") == true + val chrome = referrer?.startsWith("com.android.chrome") == true + + if (pocket || chrome) { + val lines = result.split("\n").toTypedArray() + var textLines = lines + + if (lines.size > 1) { + val url = lines[lines.size - 1].trim { it <= ' ' } + if (url.matches("^https?://(.*)".toRegex())) + textLines = Arrays.copyOfRange(lines, 0, lines.size - 1) + } + + result = textLines.joinToString("\n") + + if (chrome) { + result = result.replace("^\"".toRegex(), "") + result = result.replace("\"$".toRegex(), "") + } + } + + return result + } + + private fun shortenTitle(text: String?): String { + val trimmed = text!!.length > MAX_TITLE_LENGTH + var title = text.substring(0, if (trimmed) MAX_TITLE_LENGTH else text.length - 1) + if (title.length > 0) { + val space = title.lastIndexOf(" ") + if (space > 0) + title = title.substring(0, space) + } + return title.trim { it <= ' ' } + if (trimmed) "..." else "" + } + + private fun textToHTMLAttachment(text: String): String { + val lines = text.split("\n") + val buffer = StringBuffer() + buffer.append("") + buffer.append("") + buffer.append("") + buffer.append("") + buffer.append("") + buffer.append("
") + for (line in lines) { + buffer.append("

" + Html.escapeHtml(line) + "

") + } + buffer.append("
") + buffer.append("") + buffer.append("") + return buffer.toString() + } + + private fun getFaviconFromContent(url: String?): String? { + try { + val doc = Jsoup.connect(url).get() + val links = doc.select("head link[rel*='icon'], head link[rel*='shortcut']") + val baseUrl = URL(url) + + if (baseUrl.host.endsWith("wikipedia.org")) + return createDataURL("https://en.wikipedia.org/favicon.ico") + + val favicon = if (links.size > 0) { + val faviconUrl = URL(baseUrl, links[0].attr("href")) + faviconUrl.toString() + } else { + val builder = StringBuilder() + val faviconUrl = URL(url) + builder.append(faviconUrl.protocol + "://") + .append(faviconUrl.host) + if (faviconUrl.port > 0) + builder.append(":" + faviconUrl.port) + builder.append("/favicon.ico") + builder.toString() + } + + return createDataURL(favicon) + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + private fun createDataURL(url: String): String? { + var result: String? = null + val client = OkHttpClient(); + val request = Request.Builder() + .url(url) + .build() + + client.newCall(request).execute().use { + var mimeType = it.headers["content-type"] + val bytes = it.body?.bytes() + + if (bytes != null) { + val b64Data = String( + android.util.Base64.encode(bytes, android.util.Base64.NO_WRAP), + StandardCharsets.UTF_8 + ) + + if (mimeType == null) + mimeType = getMimetypeFromExt(url) + + if (mimeType !== null) + result = "data:$mimeType;base64,$b64Data" + } + } + + return result + } + + private fun getTitleFromContent(url: String): String? { + try { + val doc = Jsoup.connect(url).get() + val title = doc.select("head title") + if (title.size > 0) return title[0].text() + } catch (e: Exception) { + e.printStackTrace() + } + return null + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/exceptions/SharingException.kt b/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/exceptions/SharingException.kt new file mode 100644 index 00000000..b4acec12 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/cloud/sharing/exceptions/SharingException.kt @@ -0,0 +1,4 @@ +package l2.albitron.scrapyard.cloud.sharing.exceptions + +class SharingException(val messageResource: Int) : Exception() { +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/ui/cloud_shelf/CloudShelfFragment.kt b/android/app/src/main/java/l2/albitron/scrapyard/ui/cloud_shelf/CloudShelfFragment.kt new file mode 100644 index 00000000..7f7668e5 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/ui/cloud_shelf/CloudShelfFragment.kt @@ -0,0 +1,12 @@ +package l2.albitron.scrapyard.ui.cloud_shelf + +import l2.albitron.scrapyard.cloud.db.CloudDB +import l2.albitron.scrapyard.cloud.db.CloudShelfDB +import l2.albitron.scrapyard.ui.webview.BookmarkBrowserFragment + +class CloudShelfFragment : BookmarkBrowserFragment() { + override suspend fun getCloudDB(): CloudDB { + val context = requireActivity() + return CloudDB.newInstance(context, CloudShelfDB.DATABASE_TYPE)!! + } +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/ui/providers/ProvidersFragment.kt b/android/app/src/main/java/l2/albitron/scrapyard/ui/providers/ProvidersFragment.kt new file mode 100644 index 00000000..ea43364b --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/ui/providers/ProvidersFragment.kt @@ -0,0 +1,119 @@ +package l2.albitron.scrapyard.ui.providers + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.* +import l2.albitron.scrapyard.databinding.FragmentProvidersBinding +import l2.albitron.scrapyard.R +import l2.albitron.scrapyard.Settings +import l2.albitron.scrapyard.cloud.providers.DropboxProvider +import l2.albitron.scrapyard.cloud.providers.OneDriveProvider +import l2.albitron.scrapyard.isOnline +import l2.albitron.scrapyard.showToast + + +class ProvidersFragment : Fragment() { + private var _binding: FragmentProvidersBinding? = null + private var _authorizingDropbox = false + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentProvidersBinding.inflate(inflater, container, false) + val root: View = binding.root + val activity = requireActivity() + val settings = Settings(activity) + + //binding.fragment = this + + if (DropboxProvider.isSignedIn(activity)) + binding.signInDropboxButtonText.text = getString(R.string.sign_out) + + if (settings.isOneDriveSignedIn) + binding.signInOnedriveButtonText.text = getString(R.string.sign_out) + + binding.signInDropboxButton.setOnClickListener { signInDropbox() } + binding.signInOnedriveButton.setOnClickListener { signInOneDrive() } + + return root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun signInDropbox() { + val context = requireActivity() + + if (!DropboxProvider.isSignedIn(context) && !isOnline(context)) { + showToast(R.string.no_internet) + return + } + + _authorizingDropbox = DropboxProvider.startSignIn(context) + + if (!_authorizingDropbox) + binding.signInDropboxButtonText.text = getString(R.string.sign_in) + } + + fun signInOneDrive() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + showToast(R.string.onedrive_requires_android_n, Toast.LENGTH_LONG) + return + } + + val context = requireActivity() + + if (isOnline(context)) { + val settings = Settings(context) + + lifecycleScope.launch(Dispatchers.IO) { + try { + //OneDriveProvider.getSignature(context) + + if (settings.isOneDriveSignedIn) + OneDriveProvider.signOut(context) + else + OneDriveProvider.signIn(context) + } catch (e: Exception) { + e.printStackTrace() + return@launch + } + + withContext(Dispatchers.Main) { + settings.isOneDriveSignedIn = !settings.isOneDriveSignedIn + binding.signInOnedriveButtonText.text = + getString(if (settings.isOneDriveSignedIn) R.string.sign_out else R.string.sign_in) + } + } + } + else + showToast(R.string.no_internet) + } + + override fun onResume() { + super.onResume() + + if (_authorizingDropbox) { + _authorizingDropbox = false + + if (DropboxProvider.finishSignIn(requireActivity())) + binding.signInDropboxButtonText.text = getString(R.string.sign_out) + else + binding.signInDropboxButtonText.text = getString(R.string.sign_in) + } + } +} diff --git a/android/app/src/main/java/l2/albitron/scrapyard/ui/settings/SettingsFragment.kt b/android/app/src/main/java/l2/albitron/scrapyard/ui/settings/SettingsFragment.kt new file mode 100644 index 00000000..1cdc5fb9 --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/ui/settings/SettingsFragment.kt @@ -0,0 +1,17 @@ +package l2.albitron.scrapyard.ui.settings + +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import l2.albitron.scrapyard.R +import l2.albitron.scrapyard.Settings + +class SettingsFragment : PreferenceFragmentCompat() { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val manager: PreferenceManager = preferenceManager + manager.sharedPreferencesName = Settings.MAIN_PREFERENCES + + setPreferencesFromResource(R.xml.root_preferences, rootKey) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/l2/albitron/scrapyard/ui/sharing/ShareBookmarkActivity.kt b/android/app/src/main/java/l2/albitron/scrapyard/ui/sharing/ShareBookmarkActivity.kt new file mode 100644 index 00000000..5b7db30c --- /dev/null +++ b/android/app/src/main/java/l2/albitron/scrapyard/ui/sharing/ShareBookmarkActivity.kt @@ -0,0 +1,95 @@ +package l2.albitron.scrapyard.ui.sharing + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.Spinner +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import l2.albitron.scrapyard.R +import l2.albitron.scrapyard.Settings +import l2.albitron.scrapyard.cloud.sharing.SharingService + +class ShareBookmarkActivity : AppCompatActivity() { + private val _settings = Settings(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (_settings.askForAdditionalBookmarkProperties) { + setTheme(R.style.Theme_UserDialog) + setContentView(R.layout.activity_share_bookmark) + setFinishOnTouchOutside(false) + + val params = getBookmarkData(intent).keyValueMap + val title = SharingService(this).getBookmarkProperties(params).component1() + val bookmarkTitle = findViewById(R.id.bookmarkTitle) + bookmarkTitle.setText(title) + + val btnCancel = findViewById