├── .eslintrc.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── devtools └── add-browser-reload.user.js ├── index.d.ts ├── jumpflowy.user.js ├── package.json ├── tests └── integration-tests.js ├── tsconfig.json └── types └── workflowy-api.d.ts /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | ecmaVersion: 2017 3 | env: 4 | browser: true 5 | es6: true 6 | greasemonkey: true 7 | extends: 'eslint:recommended' 8 | rules: 9 | indent: 10 | - error 11 | - 2 12 | - SwitchCase: 1 13 | linebreak-style: 14 | - error 15 | - unix 16 | quotes: 17 | - error 18 | - double 19 | - avoidEscape: true 20 | semi: 21 | - error 22 | - always 23 | no-template-curly-in-string: 24 | - error 25 | valid-jsdoc: 26 | - error 27 | eqeqeq: 28 | - error 29 | dot-notation: 30 | - error 31 | comma-dangle: 32 | - error 33 | - only-multiline 34 | no-trailing-spaces: 35 | - error 36 | no-warning-comments: 37 | - warn 38 | no-console: 39 | - off 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /yarn.lock 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matt Hutton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JumpFlowy 2 | 3 | A Chrome/Firefox user script which adds search, navigation, and keyboard shortcut features to WorkFlowy. 4 | 5 | 6 | 7 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 8 | 9 | - [Target audience](#target-audience) 10 | - [Target platforms](#target-platforms) 11 | - [Documentation and examples](#documentation-and-examples) 12 | - [Getting started](#getting-started) 13 | - [Install the JumpFlowy user script](#install-the-jumpflowy-user-script) 14 | - [Configuration of triggers and actions](#configuration-of-triggers-and-actions) 15 | - [Keyboard shortcut triggers](#keyboard-shortcut-triggers) 16 | - [Bookmark triggers](#bookmark-triggers) 17 | - [Configuration Examples](#configuration-examples) 18 | - [Follow actions](#follow-actions) 19 | - [Suggestions for what to configure](#suggestions-for-what-to-configure) 20 | - [Developing JumpFlowy](#developing-jumpflowy) 21 | - [Running the tests](#running-the-tests) 22 | - [Linting](#linting) 23 | - [Contributing](#contributing) 24 | - [Versioning and backwards compatibility](#versioning-and-backwards-compatibility) 25 | - [Authors](#authors) 26 | - [License](#license) 27 | - [Acknowledgments](#acknowledgments) 28 | 29 | 30 | 31 | ## Target audience 32 | 33 | - WorkFlowy users who need more search and navigation capabilities 34 | 35 | ## Target platforms 36 | 37 | - Chrome/Firefox (macOS/Linux/Windows), via [Tampermonkey](https://tampermonkey.net/index.php) 38 | 39 | ## Documentation and examples 40 | 41 | The documentation and examples consist of: 42 | - This README file 43 | - A small example [JumpFlowy configuration](https://workflowy.com/s/mMo.Wdwdc5DDD3) 44 | 45 | ## Getting started 46 | 47 | ### Install the JumpFlowy user script 48 | 49 | - Install [Tampermonkey](https://tampermonkey.net/index.php) in Chrome or Firefox. 50 | - In your browser, open the [JumpFlowy user script](https://github.com/mbhutton/jumpflowy/raw/master/jumpflowy.user.js). 51 | - Install the user script in Tampermonkey. 52 | - Open/reload [WorkFlowy](https://workflowy.com/). 53 | 54 | ### Configuration of triggers and actions 55 | 56 | Configuring JumpFlowy is mostly about binding triggers (e.g. keyboard shortcuts) to actions. You do this by adding an item to your WorkFlowy document, where a tag describes the trigger, and the note describes an (optional) _follow action_. 57 | 58 | There are 2 types of trigger: 59 | - keyboard shortcuts, e.g. `#shortcut(ctrl+shift+KeyF)` 60 | - bookmarks, e.g. `#bm(home)` 61 | 62 | The trigger takes us to the item which defined that trigger, and then JumpFlowy _follows_ that item, i.e. it performs some _follow action_ based on the contents of the item. 63 | 64 | There are 3 types of _follow action_: 65 | - Perform some named function (where the item's text is the name of that function, e.g. `promptToNormalLocalSearch`) 66 | - Go to some other WorkFlowy item (where the item's text is the URL of that WorkFlowy item to go to, e.g. `https://workflowy.com`) 67 | - Go to the item itself (where neither a named function nor WorkFlowy URL are found as defined above) 68 | 69 | #### Keyboard shortcut triggers 70 | 71 | To add a keyboard trigger, add e.g. `#shortcut(ctrl+shift+KeyF)` to the item you want to follow. 72 | 73 | For the available key codes, see this Mozilla's [keyCode reference](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode). 74 | 75 | Note: Currently, you'll need to log out and log in again to WorkFlowy (after saving your changes) in order for the keyboard shortcut to take effect. This is a known issue, and a fix is planned. 76 | 77 | #### Bookmark triggers 78 | 79 | To add a keyboard trigger, add e.g. `#bm(home)` to the item you want to follow to define a bookmark called `home`. 80 | 81 | #### Configuration Examples 82 | 83 | See this [example configuration](https://workflowy.com/s/mMo.Wdwdc5DDD3). 84 | 85 | To use the above configuration as a starting point: 86 | - Install the JumpFlowy user script. 87 | - Copy the configuration section below it into your own WorkFlowy document. 88 | - Edit the configuration according to your needs. 89 | - Log out and in again (a fix is planned, you won't need to do this in future) 90 | 91 | This example binds the keyboard shortcut `ctrl+shift+KeyF` to a WorkFlowy search: 92 | - Text: `#shortcut(ctrl+shift+KeyF)` 93 | - Note: `promptToNormalLocalSearch` 94 | 95 | This example binds the keyboard shortcut `ctrl+KeyJ` to a bookmark search: 96 | - Text: `#shortcut(ctrl+KeyJ)` 97 | - Note: `promptToFindGlobalBookmarkThenFollow` 98 | 99 | This example binds the bookmark `hm` to the root/home of the document. 100 | - Text: `#bm(hm)` 101 | - Note: `https://workflowy.com/` 102 | 103 | With the above configuration enabled, you could press `ctrl+KeyJ` to trigger the bookmark search, then type `hm`, `ENTER` to find and follow the `hm` bookmark, which would take you to the root/home of the document. 104 | 105 | A good convention to follow is to put the trigger in the item's text, and the action as the item's note. However, the trigger (`#bm`/`#shortcut`) can be specified anywhere in the item's main text or note. The action must be either the only content in the item's main text, or the only content in the item's note. 106 | 107 | #### Follow actions 108 | 109 | When following an item, the follow action will be one of: 110 | - Go to the item itself, i.e. zoom into it 111 | - Go to some other item (specify the URL or this other item as this item's note) 112 | - Perform some named function (specify the name of the function as this item's note) 113 | 114 | The normal convention for specifying a named function to execute or a URL to follow is to put it as the only content in the item's text. 115 | To be recognised by JumpFlowy, it must be the full text of either the item's main text or the item's note. 116 | 117 | For a description of what each of these named functions do, find its comments in [`jumpflowy.user.js`](https://github.com/mbhutton/jumpflowy/blob/master/jumpflowy.user.js). 118 | 119 | The available named functions: 120 | 121 | - `addBookmark` 122 | - `blurFocusedContent` 123 | - `combinationUpdateDateThenMoveToBookmark` 124 | - `clearDate` 125 | - `createItemAtTopOfCurrent` 126 | - `createOrdinaryLink` 127 | - `dismissNotification` 128 | - `editCurrentItem` 129 | - `editParentOfFocusedItem` 130 | - `reassembleNameTree` 131 | - `logShortReport` 132 | - `markFocusedAndDescendantsNotComplete` 133 | - `moveToBookmark` 134 | - `openFirstLinkInFocusedItem` 135 | - `promptToAddBookmarkForCurrentItem` (Deprecated: use `addBookmark`) 136 | - `promptToFindGlobalBookmarkThenFollow` 137 | - `promptToFindLocalRegexMatchThenZoom` 138 | - `promptToNormalLocalSearch` 139 | - `promptToFindByDateRange` 140 | - `promptToFindByLastChanged` 141 | - `scatterDescendants` 142 | - `scheduleDescendants` 143 | - `sendToNameTree` 144 | - `sendToNameTreeAndClearDateAndComplete` 145 | - `updateDate` 146 | - `showZoomedAndMostRecentlyEdited` 147 | - `validateAllNameTrees` 148 | 149 | #### Suggestions for what to configure 150 | 151 | As a general guide: 152 | - Bind `promptToFindGlobalBookmarkThenFollow` to a convenient key combination, to enable bookmark support. 153 | - Add lots of named bookmarks (`#bm(name)`) to frequently visited locations in your document, updating these as needed. 154 | - Add a handful of keyboard shortcuts (`#shortcut(...)`) to frequently visited locations which don't change (e.g. top level 'work' or 'personal' sections). 155 | - Add bookmarks or shortcuts for some subset of the named functions, just those which seem useful to you. 156 | 157 | ## Developing JumpFlowy 158 | 159 | ### Running the tests 160 | 161 | To run the tests, see the instructions at the top of [add-browser-reload.user.js](https://github.com/mbhutton/jumpflowy/blob/master/devtools/add-browser-reload.user.js). 162 | 163 | ### Linting 164 | 165 | - Some static type checking is performed through the use of `VS Code`, by using `JSDoc` function annotations and a `TypeScript` declaration file. 166 | - Linting is done by by `ESLint`. 167 | - `prettier` is used for formatting. 168 | 169 | ### Contributing 170 | 171 | Pull requests and bug reports are very welcome. 172 | 173 | ## Versioning and backwards compatibility 174 | 175 | At this early stage while jumpflowy is still under active development, expect some breaking changes to happen even at minor version changes. 176 | 177 | ## Authors 178 | 179 | - **Matt Hutton** - *Initial work* 180 | 181 | ## License 182 | 183 | This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for details. 184 | 185 | ## Acknowledgments 186 | 187 | - The WorkFlowy team for creating such a great product 188 | - [rawbytz](https://rawbytz.wordpress.com/) for proving what's possible on top of WorkFlowy 189 | -------------------------------------------------------------------------------- /devtools/add-browser-reload.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Add JumpFlowy reload button 3 | // @namespace https://github.com/mbhutton/jumpflowy 4 | // @version 0.1.0.11 5 | // @description Add button to reload JumpFlowy scripts from localhost server 6 | // @author Matt Hutton 7 | // @match https://workflowy.com/* 8 | // @match https://beta.workflowy.com/* 9 | // @match https://dev.workflowy.com/* 10 | // @grant none 11 | // @run-at document-end 12 | // @downloadURL https://github.com/mbhutton/jumpflowy/raw/master/devtools/add-browser-reload.user.js 13 | // ==/UserScript== 14 | 15 | // ESLint globals: 16 | /* global toastr:false */ // Others 17 | 18 | /* 19 | A utility to assist reloading of jumpflowy.user.js and integration tests, 20 | by providing a button, a keyboard shortcut and a function to reload both 21 | from a local web server. 22 | 23 | To set up: 24 | 25 | (1) At the root of the jumpflowy source tree, run e.g.: 26 | > (cd ~/git/jumpflowy && python3 -m http.server 17362) 27 | The port must match the port used elsewhere in this script. 28 | 29 | (2) Disable cache in Chrome/Firefox dev tools under the Network tab. 30 | 31 | (3) Run this script once in the Chrome/Firefox developer console to add the 32 | reload button, if not already installed as a user script in Tampermonkey. 33 | 34 | To reload and run the integration tests each time, either: 35 | 36 | (A) Type reloadJumpFlowy() in the console, or 37 | (B) Press ctrl-r when focused on the WF doc, or 38 | (C) Click the reload button. 39 | 40 | To clean up: 41 | 42 | (1) Stop the HTTP server 43 | (2) Re-enable network cache in Chrome/Firefox 44 | (3) Reload the script in Tampermonkey if a new version was pushed 45 | (4) Close and reload WorkFlowy tab in Chrome/Firefox 46 | 47 | */ 48 | 49 | // Enable TypeScript checking 50 | // @ts-check 51 | 52 | (function() { 53 | "use strict"; 54 | 55 | let hostPort; 56 | 57 | function loadScript(fullPath) { 58 | const scriptElement = document.createElement("script"); 59 | scriptElement.src = fullPath; 60 | scriptElement.type = "text/javascript"; 61 | document.head.appendChild(scriptElement); 62 | } 63 | 64 | function loadCss(fullPath) { 65 | const linkElement = document.createElement("link"); 66 | linkElement.href = fullPath; 67 | linkElement.type = "text/css"; 68 | linkElement.rel = "stylesheet"; 69 | document.head.appendChild(linkElement); 70 | } 71 | 72 | async function pause(ms) { 73 | return new Promise(resolve => { 74 | setTimeout(() => { 75 | resolve(); 76 | }, ms); 77 | }); 78 | } 79 | 80 | async function reloadScripts() { 81 | // Load toastr first to show a progress notification 82 | loadScript(hostPort + "/node_modules/jquery/dist/jquery.min.js"); 83 | await pause(200); 84 | loadCss(hostPort + "/node_modules/toastr/build/toastr.css"); 85 | await pause(100); 86 | loadScript(hostPort + "/node_modules/toastr/toastr.js"); 87 | await pause(100); 88 | 89 | showInfo(`Reloading scripts from ${hostPort}`); 90 | 91 | loadScript(hostPort + "/node_modules/expect.js/index.js"); 92 | await pause(100); 93 | loadScript(hostPort + "/jumpflowy.user.js"); 94 | await pause(400); // 100 fails; 200 works; 400 to be safe 95 | loadScript(hostPort + "/tests/integration-tests.js"); 96 | console.log("Reloaded scripts."); 97 | } 98 | 99 | function addReloadButton() { 100 | const button = document.createElement("button"); 101 | button.innerHTML = "
Reload scripts
"; 102 | const header = document.getElementById("header"); 103 | if (header !== null) { 104 | header.appendChild(button); 105 | } else { 106 | document.body.appendChild(button); 107 | } 108 | button.onclick = reloadScripts; 109 | } 110 | 111 | // Adds keyboard shortcut ctrl-r (only appropriate on macOS) 112 | function addShortcut() { 113 | const keyEventHandler = function(keyEvent) { 114 | if (keyEvent.ctrlKey && !keyEvent.shiftKey && !keyEvent.altKey && !keyEvent.metaKey && keyEvent.code === "KeyR") { 115 | keyEvent.stopPropagation(); 116 | keyEvent.preventDefault(); 117 | reloadScripts(); 118 | } 119 | }; 120 | document.addEventListener("keydown", keyEventHandler); 121 | console.log("Added ctrl-r shortcut for reloading."); 122 | } 123 | 124 | function addReloadFunction() { 125 | window.reloadJumpFlowy = reloadScripts; 126 | console.log("Added reloadJumpFlowy() function to global scope."); 127 | } 128 | 129 | function toastrIfAvailable(message, methodName) { 130 | if (typeof toastr !== "undefined" && toastr !== null) { 131 | if (typeof toastr[methodName] === "function") { 132 | toastr[methodName](message); 133 | } else { 134 | toastr.info(`${methodName}: ${message}`); 135 | const errorMessage = "Invalid toastr level: " + methodName; 136 | toastr.error(errorMessage); 137 | console.error(errorMessage); 138 | } 139 | } 140 | } 141 | 142 | function showInfo(message) { 143 | console.info(message); 144 | toastrIfAvailable(message, "info"); 145 | } 146 | 147 | const USER_AGENT = navigator.userAgent; 148 | const IS_MOBILE = USER_AGENT.includes("iPhone") || USER_AGENT.includes("Android"); 149 | if (IS_MOBILE) { 150 | alert("This script isn't supported on mobile"); 151 | } else { 152 | hostPort = "http://127.0.0.1:17362"; 153 | 154 | addReloadButton(); 155 | addReloadFunction(); 156 | 157 | // Wait a while before adding the reload shortcut, 158 | // as IS_MAC_OS isn't set immediately. 159 | setTimeout(() => { 160 | if (USER_AGENT.includes("Mac OS X")) { 161 | addShortcut(); 162 | } else { 163 | console.log("Not macOS, so not adding ctrl-r reloading shortcut."); 164 | } 165 | }, 4000); 166 | } 167 | })(); 168 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | //**************************************************************************** 2 | // This file is not a reference. 3 | // It exists solely to help support TypeScript's static analysis. 4 | // 5 | // This file contains best effort type declarations for JumpFlowy, 6 | // and WorkFlowy's window level variables, added as needed to get jumpflowy.user.js 7 | // and the integration tests to pass type checks. 8 | // 9 | // The authoritative type definitions for JumpFlowy itself are 10 | // annotated in JSDoc on the functions definitions in jumpflowy.user.js. 11 | //**************************************************************************** 12 | 13 | //**************************** 14 | // JumpFlowy types 15 | //**************************** 16 | 17 | declare const jumpflowy: JumpFlowy; 18 | 19 | type TextPredicate = (s: string) => boolean; 20 | 21 | type ItemPredicate = (item: Item) => boolean; 22 | 23 | type ItemHandler = (item: Item) => void; 24 | 25 | interface NameTreeModule { 26 | plainAndRichNameOrNoteToNameChain(itemNamePlain: string, itemNameRich: string): string | null; 27 | itemToNameChain(item: Item): string | null; 28 | sendToNameTree(): void; 29 | sendToNameTreeAndClearDateAndComplete(): void; 30 | reassembleNameTree(): void; 31 | validateAllNameTrees(): void; 32 | } 33 | 34 | interface DateEntry {} 35 | 36 | interface DateInterpretation { 37 | date: Date; 38 | description: string; 39 | } 40 | 41 | interface DatesModule { 42 | _updateDateOnActiveItemsOrFail(): void; 43 | clearDate(): void; 44 | clearFirstDateOnItem(item: Item): void; 45 | clearFirstDateOnRawString(s: string): string; 46 | dateToDateEntry(date: Date): DateEntry; 47 | doesRawStringHaveDates(s: string): boolean; 48 | failIfMultipleDates(i: Item): void; 49 | interpretDate(s: string, referenceDate: Date): [DateInterpretation?, string?]; 50 | promptToFindByDateRange(): void; 51 | setFirstDateOnItem(item: Item, dateEntry: DateEntry): void; 52 | updateDate(): void; 53 | } 54 | 55 | interface ExperimentalUiDecoratorsModule { 56 | experimentalKeyMotion(): void; 57 | handleKeyEvent(keyEvent: KeyboardEvent): boolean; 58 | getItemElements(item: Item): ItemElements; 59 | overlayGutterCodes(drawOverElement: Element, s: string): Element; 60 | removeElementFromParent(childElement: Element): void; 61 | } 62 | 63 | interface ItemElements {} 64 | 65 | interface JumpFlowy { 66 | nameTree: NameTreeModule; 67 | 68 | dates: DatesModule; 69 | 70 | experimentalUiDecorators: ExperimentalUiDecoratorsModule; 71 | 72 | addBookmark(): void; 73 | 74 | applyToEachItem(functionToApply: ItemHandler, searchRoot: Item): void; 75 | 76 | blurFocusedContent(): void; 77 | 78 | callAfterDocumentLoaded(callbackFn: () => void); 79 | 80 | combinationUpdateDateThenMoveToBookmark(): void; 81 | 82 | createOrdinaryLink(): void; 83 | 84 | createItemAtTopOfCurrent(): void; 85 | 86 | cleanUp(): void; 87 | 88 | dateToYMDString(Date): string; 89 | 90 | deleteFocusedItemIfNoChildren(): void; 91 | 92 | dismissNotification(): void; 93 | 94 | doesItemHaveTag(tagToMatch: string, item: Item): boolean; 95 | 96 | doesItemNameOrNoteMatch(textPredicate: TextPredicate, item: Item): boolean; 97 | 98 | doesStringHaveTag(tagToMatch: string, s: string): boolean; 99 | 100 | editCurrentItem(): void; 101 | 102 | editParentOfFocusedItem(): void; 103 | 104 | expandAbbreviation(abbreviation: string): string; 105 | 106 | filterMapByKeys(keyFilter: (K) => boolean, map: Map): Map; 107 | 108 | filterMapByValues(valueFilter: (V) => boolean, map: Map): Map; 109 | 110 | findClosestCommonAncestor(itemA: Item, itemB: Item): Item; 111 | 112 | findItemsMatchingRegex(regExp: RegExp, searchRoot: Item): Array; 113 | 114 | findItemsWithSameText(searchRoot: Item): Map>; 115 | 116 | findItemsWithTag(tag: string, searchRoot: Item): Array; 117 | 118 | findMatchingItems(itemPredicate: ItemPredicate, searchRoot: Item): Array; 119 | 120 | findRecentlyEditedItems(earliestModifiedSec: number, maxSize: number, searchRoot: Item): Array; 121 | 122 | findTopItemsByComparator(isABetterThanB: (a: T, b: T) => boolean, maxSize: number, items: Iterable): Array; 123 | 124 | findTopItemsByScore( 125 | itemToScoreFn: (Item) => number, 126 | minScore: number, 127 | maxSize: number, 128 | searchRoot: Item 129 | ): Array; 130 | 131 | followItem(item: Item): void; 132 | 133 | followZoomedItem(): void; 134 | 135 | getAllBookmarkedItemsByBookmark(): Map; 136 | 137 | getTagsForFilteredItems(itemPredicate: ItemPredicate, searchRoot: Item): Set; 138 | 139 | getCurrentTimeSec(): number; 140 | 141 | getZoomedItem(): Item; 142 | 143 | getZoomedItemAsLongId(): string; 144 | 145 | isRootItem(item: Item): boolean; 146 | 147 | isValidCanonicalCode(canonicalCode: string): void; 148 | 149 | itemsToVolatileSearchQuery(items: Array): string; 150 | 151 | itemToCombinedPlainText(item: Item): string; 152 | 153 | itemToHashSegment(item: Item): string; 154 | 155 | itemToLastModifiedSec(item: Item): number; 156 | 157 | itemToPathAsItems(item: Item): Array; 158 | 159 | itemToPlainTextName(item: Item): string; 160 | 161 | itemToPlainTextNote(item: Item): string; 162 | 163 | itemToTagArgsText(tagToMatch: string, item: Item): string; 164 | 165 | itemToTags(item: Item): Array; 166 | 167 | itemToVolatileSearchQuery(item: Item): string; 168 | 169 | keyDownEventToCanonicalCode(keyEvent: KeyboardEvent): string; 170 | 171 | logElapsedTime(startDate: Date, message: string): void; 172 | 173 | logShortReport(): void; 174 | 175 | markFocusedAndDescendantsNotComplete(): void; 176 | 177 | moveToBookmark(): void; 178 | 179 | openFirstLinkInFocusedItem(): void; 180 | 181 | openHere(url: string): void; 182 | 183 | openInNewTab(url: string): void; 184 | 185 | zoomToAndSearch(item: Item, searchQuery: string | null): void; 186 | 187 | promptToChooseItem(items: Array, promptMessage: string): Item; 188 | 189 | promptToExpandAndInsertAtCursor(): void; 190 | 191 | promptToAddBookmarkForCurrentItem(): void; 192 | 193 | promptToFindGlobalBookmarkThenFollow(): void; 194 | 195 | promptToFindLocalRegexMatchThenZoom(): void; 196 | 197 | promptToNormalLocalSearch(): void; 198 | 199 | promptToFindByLastChanged(): void; 200 | 201 | scatterDescendants(): void; 202 | 203 | scheduleDescendants(): void; 204 | 205 | showZoomedAndMostRecentlyEdited(): void; 206 | 207 | splitStringToSearchTerms(s: string): string; 208 | 209 | stringToTagArgsText(tagToMatch: string, s: string): string; 210 | 211 | todayAsYMDString(): string; 212 | 213 | toItemMultimapWithSingleKeys(itemFunction: (Item) => K | null, searchRoot: Item): Map>; 214 | 215 | toItemMultimapWithMultipleKeys(itemFunction: (Item) => Array | null, searchRoot: Item): Map>; 216 | 217 | validRootUrls: Array; 218 | 219 | workFlowyUrlToHashSegmentAndSearchQuery(url: string): [string, string]; 220 | } 221 | 222 | //**************************** 223 | // Window object 224 | //**************************** 225 | 226 | interface Window { 227 | // From WorkFlowy 228 | blurFocusedContent: () => void; 229 | 230 | // From JumpFlowy 231 | jumpflowy: JumpFlowy; 232 | 233 | // From WorkFlowy 234 | WFEventListener: (eventName: string) => void; 235 | 236 | // From JumpFlowy add-browser-reload 237 | reloadJumpFlowy: (() => void) | null; 238 | } 239 | -------------------------------------------------------------------------------- /jumpflowy.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name JumpFlowy 3 | // @namespace https://github.com/mbhutton/jumpflowy 4 | // @version 0.1.8.20 5 | // @description WorkFlowy user script for search and navigation 6 | // @author Matt Hutton 7 | // @match https://workflowy.com/* 8 | // @match https://beta.workflowy.com/* 9 | // @match https://dev.workflowy.com/* 10 | // @grant none 11 | // @run-at document-end 12 | // @downloadURL https://github.com/mbhutton/jumpflowy/raw/master/jumpflowy.user.js 13 | // ==/UserScript== 14 | 15 | // ESLint globals from WorkFlowy: 16 | /* 17 | global WF:false 18 | */ 19 | 20 | // Enable TypeScript checking 21 | // @ts-check 22 | /// 23 | /// 24 | 25 | (function() { 26 | "use strict"; 27 | 28 | /** 29 | * Applies the given function to the given item 30 | * and each of its descendants, as a depth first search. 31 | * @param {function} functionToApply The function to apply to each item. 32 | * @param {Item} searchRoot The root item of the search. 33 | * @returns {void} 34 | */ 35 | function applyToEachItem(functionToApply, searchRoot) { 36 | // Apply the function 37 | functionToApply(searchRoot); 38 | // Recurse 39 | for (let child of searchRoot.getChildren()) { 40 | applyToEachItem(functionToApply, child); 41 | } 42 | } 43 | 44 | /** 45 | * @param {function} itemPredicate A function (Item -> boolean) which 46 | * returns whether or not an item is a match. 47 | * @param {Item} searchRoot The root item of the search. 48 | * @returns {Array} The matching items, in order of appearance. 49 | */ 50 | function findMatchingItems(itemPredicate, searchRoot) { 51 | const matches = Array(); 52 | 53 | function addIfMatch(item) { 54 | if (itemPredicate(item)) { 55 | matches.push(item); 56 | } 57 | } 58 | applyToEachItem(addIfMatch, searchRoot); 59 | return matches; 60 | } 61 | 62 | /** 63 | * @template K 64 | * @template V 65 | * @param {function} itemFunction A function of type (Item -> Array). 66 | * Can return null or empty array. 67 | * @param {Item} searchRoot The root item of the search. 68 | * @returns {Map>} A map of keys to arrays of items. 69 | */ 70 | function toItemMultimapWithMultipleKeys(itemFunction, searchRoot) { 71 | /** @type Map> */ 72 | const itemMultimap = new Map(); 73 | /** 74 | * @param {Item} item The item 75 | * @returns {void} 76 | */ 77 | function visitorFunction(item) { 78 | const keysForItem = itemFunction(item); 79 | if (keysForItem && keysForItem.length) { 80 | for (let key of keysForItem) { 81 | addToMultimap(key, item, itemMultimap); 82 | } 83 | } 84 | } 85 | applyToEachItem(visitorFunction, searchRoot); 86 | return itemMultimap; 87 | } 88 | 89 | /** 90 | * @template K 91 | * @template V 92 | * @param {function} itemFunction A function of type (Item -> K). 93 | * Can return null. 94 | * @param {Item} searchRoot The root item of the search. 95 | * @returns {Map>} A map of keys to arrays of items. 96 | */ 97 | function toItemMultimapWithSingleKeys(itemFunction, searchRoot) { 98 | /** 99 | * @param {Item} item The item 100 | * @returns {Array | null} The returned result (if any) as an array. 101 | */ 102 | function wrappedFunction(item) { 103 | const key = itemFunction(item); 104 | return (key && [key]) || null; 105 | } 106 | return toItemMultimapWithMultipleKeys(wrappedFunction, searchRoot); 107 | } 108 | 109 | /** 110 | * @template K 111 | * @template V 112 | * @param {K} key The key. 113 | * @param {V} value Value to append to the array associated with the key. 114 | * @param {Map>} multimap A map of keys to arrays of values. 115 | * @returns {void} 116 | */ 117 | function addToMultimap(key, value, multimap) { 118 | if (multimap.has(key)) { 119 | multimap.get(key).push(value); 120 | } else { 121 | multimap.set(key, [value]); 122 | } 123 | } 124 | 125 | /** 126 | * @template K 127 | * @template V 128 | * @param {function} keyFilter The filter to apply to keys in the map. 129 | * @param {Map} map The map to filter. 130 | * @returns {Map} map The filtered map. 131 | */ 132 | function filterMapByKeys(keyFilter, map) { 133 | /** @type {Map} */ 134 | const filteredMap = new Map(); 135 | map.forEach((v, k) => { 136 | if (keyFilter(k)) { 137 | filteredMap.set(k, v); 138 | } 139 | }); 140 | return filteredMap; 141 | } 142 | 143 | /** 144 | * @template K 145 | * @template V 146 | * @param {function} valueFilter The filter to apply to values in the map. 147 | * @param {Map} map The map to filter. 148 | * @returns {Map} map The filtered map. 149 | */ 150 | function filterMapByValues(valueFilter, map) { 151 | /** @type {Map} */ 152 | const filteredMap = new Map(); 153 | map.forEach((v, k) => { 154 | if (valueFilter(v)) { 155 | filteredMap.set(k, v); 156 | } 157 | }); 158 | return filteredMap; 159 | } 160 | 161 | /** 162 | * @param {Item} searchRoot The root item of the search. 163 | * @returns {Map>} Items with the same text, keyed by that 164 | * text. 165 | */ 166 | function findItemsWithSameText(searchRoot) { 167 | const allItemsByText = toItemMultimapWithSingleKeys(itemToCombinedPlainText, searchRoot); 168 | return filterMapByValues(items => items.length > 1, allItemsByText); 169 | } 170 | 171 | /** 172 | * Returns the tags found in the given item. 173 | * Returns the empty set if none are found. 174 | * 175 | * Note: Tags containing punctuation may produce unexpected results. 176 | * Suggested best practice: freely use '-' and '_' and ':' 177 | * as part of tags, being careful to avoid ':' as a suffix. 178 | * 179 | * @param {Item} item The item to query. 180 | * @returns {Array} An array of tags found in the item. 181 | */ 182 | function itemToTags(item) { 183 | const tagsForName = WF.getItemNameTags(item); 184 | const tagsForNote = WF.getItemNoteTags(item); 185 | const allTags = tagsForName.concat(tagsForNote); 186 | return allTags.map(x => x.tag); 187 | } 188 | 189 | /** 190 | * @param {function} itemPredicate A function (Item -> boolean) which 191 | * returns whether or not to include an item. 192 | * @param {Item} searchRoot The root item of the search. 193 | * @returns {Set} All tags under the given search root. 194 | */ 195 | function getTagsForFilteredItems(itemPredicate, searchRoot) { 196 | const allTags = new Set(); 197 | /** 198 | * @param {Item} item The item. 199 | * @returns {void} 200 | */ 201 | function appendTags(item) { 202 | if (!itemPredicate || itemPredicate(item)) { 203 | itemToTags(item).forEach(tag => allTags.add(tag)); 204 | } 205 | } 206 | applyToEachItem(appendTags, searchRoot); 207 | return allTags; 208 | } 209 | 210 | /** 211 | * @param {string} tagToMatch The tag to match. 212 | * @param {Item} item The item to test. 213 | * @returns {boolean} True if and only if the given item has the 214 | * exact given tag, ignoring case. Otherwise false. 215 | * @see {@link itemToTags} For notes, caveats regarding tag handling. 216 | */ 217 | function doesItemHaveTag(tagToMatch, item) { 218 | if (!item || !tagToMatch) { 219 | return false; 220 | } 221 | 222 | // Optimisation: check for first character of tag in the raw rich text 223 | // before converting to plain text, for both the name and the note. 224 | const firstTagChar = tagToMatch[0]; 225 | return ( 226 | (item.getName().includes(firstTagChar) && doesStringHaveTag(tagToMatch, item.getNameInPlainText())) || 227 | (item.getNote().includes(firstTagChar) && doesStringHaveTag(tagToMatch, item.getNoteInPlainText())) 228 | ); 229 | } 230 | 231 | /** 232 | * @param {Item} item The item 233 | * @returns {string} The plain text version of the item's name, 234 | * or the empty string if it is the root item. 235 | */ 236 | function itemToPlainTextName(item) { 237 | return item.getNameInPlainText() || ""; 238 | } 239 | 240 | /** 241 | * @param {Item} item The item 242 | * @returns {string} The plain text version of the item's note, 243 | * or the empty string if it is the root item. 244 | */ 245 | function itemToPlainTextNote(item) { 246 | return item.getNoteInPlainText() || ""; 247 | } 248 | 249 | /** 250 | * WARNING: The fastest way to get plain text name/note from an Item is 251 | * to use the built-in methods for doing so. Only use this method when 252 | * needing to convert to plain text after doing some processing of rich text. 253 | * 254 | * This function converts the given rich text to plain text, removing any tags 255 | * (preserving their inner text), and un-escaping the required characters. 256 | * 257 | * Note that richTextToPlainText(Item.getName()) should always return the same 258 | * result as Item.getNameInPlainText(), and a failure to do so would be a bug, 259 | * and the same applies for getNote() and getNoteInPlainText(). 260 | * 261 | * @param {string} richText The rich text to convert, e.g. from Item.getName() 262 | * @returns {string} The plain text equivalent 263 | */ 264 | function richTextToPlainText(richText) { 265 | return richText 266 | .replace(/<[^>]*>/g, "") 267 | .replace(/>/g, ">") 268 | .replace(/</g, "<") 269 | .replace(/&/g, "&"); 270 | } 271 | 272 | /** 273 | * @param {Item} item The item. 274 | * @returns {string} The item's name and note, concatenated. When the note is 275 | * present, it is preceded by a newline character. 276 | */ 277 | function itemToCombinedPlainText(item) { 278 | const name = itemToPlainTextName(item); 279 | const note = itemToPlainTextNote(item); 280 | const combinedText = note.length === 0 ? name : `${name}\n${note}`; 281 | return combinedText; 282 | } 283 | 284 | /** 285 | * Marks the focused item and all its descendants as not complete. 286 | * @returns {void} 287 | */ 288 | function markFocusedAndDescendantsNotComplete() { 289 | const focusedItem = WF.focusedItem(); 290 | if (focusedItem === null) { 291 | return; 292 | } 293 | WF.editGroup(() => { 294 | applyToEachItem(item => { 295 | if (item.isCompleted()) { 296 | WF.completeItem(item); 297 | } 298 | }, focusedItem); 299 | }); 300 | } 301 | 302 | /** 303 | * @param {function} textPredicate The predicate to apply to each string. 304 | * The predicate should handle null values, 305 | * as the root item has a null name and note. 306 | * @param {Item} item The item to test. 307 | * @returns {boolean} Whether textPredicate returns true for either the item's 308 | * name or note. 309 | */ 310 | function doesItemNameOrNoteMatch(textPredicate, item) { 311 | return textPredicate(itemToPlainTextName(item)) || textPredicate(itemToPlainTextNote(item)); 312 | } 313 | 314 | /** 315 | * @returns {number} The current clock time in seconds since Unix epoch. 316 | */ 317 | function getCurrentTimeSec() { 318 | return dateToSecondsSinceEpoch(new Date()); 319 | } 320 | 321 | /** 322 | * @param {Date} date The given date. 323 | * @returns {number} Seconds from epoch to the given date, rounding down. 324 | */ 325 | function dateToSecondsSinceEpoch(date) { 326 | return Math.floor(date.getTime() / 1000); 327 | } 328 | 329 | /** 330 | * @param {Item} item The item to query. 331 | * @returns {number} When the item was last modified, in seconds since 332 | * unix epoch. For the root item, returns zero. 333 | */ 334 | function itemToLastModifiedSec(item) { 335 | return isRootItem(item) ? 0 : dateToSecondsSinceEpoch(item.getLastModifiedDate()); 336 | } 337 | 338 | // Clean up any previous instance of JumpFlowy 339 | if ( 340 | typeof window.jumpflowy !== "undefined" && 341 | typeof window.jumpflowy !== "undefined" && 342 | typeof window.jumpflowy.cleanUp !== "undefined" 343 | ) { 344 | window.jumpflowy.cleanUp(); 345 | } 346 | 347 | // Global state 348 | /** @type {Map} */ 349 | const canonicalKeyCodesToTargets = new Map(); 350 | /** @type {Map} */ 351 | const builtInExpansionsMap = new Map(); 352 | /** @type {Map} */ 353 | let customExpansions = new Map(); 354 | /** @type {Map} */ 355 | let abbrevsFromTags = new Map(); 356 | /** @type {Map} */ 357 | let kbShortcutsFromTags = new Map(); 358 | /** @type {Map} */ 359 | const builtInFunctionTargetsByName = new Map(); 360 | /** @type {Map} */ 361 | let bookmarksToItemTargets = new Map(); 362 | /** @type {Map} */ 363 | let bookmarksToSourceItems = new Map(); 364 | /** @type {Map} */ 365 | let itemIdsToFirstBookmarks = new Map(); 366 | /** @type {Map} */ 367 | const hashSegmentsToIds = new Map(); 368 | /** @type {Set} */ 369 | const unknownHashSegments = new Set(); 370 | /** @type {Map} */ 371 | let bannedBookmarkSearchPrefixesToSuggestions = new Map(); 372 | 373 | // DEPRECATED TAGS START 374 | const bookmarkTag = "#bm"; 375 | const abbrevTag = "#abbrev"; 376 | const shortcutTag = "#shortcut"; 377 | // DEPRECATED TAGS END 378 | const SCATTER_TAG = "#scatter"; 379 | const SCHEDULE_TAG = "#scheduleFor"; 380 | 381 | const searchQueryToMatchNoItems = "META:NO_MATCHING_ITEMS_" + new Date().getTime(); 382 | let lastRegexString = null; 383 | let isCleanedUp = false; 384 | 385 | /** @type {Item} */ 386 | let configurationRootItem = null; 387 | const CONFIGURATION_ROOT_NAME = "jumpflowyConfiguration"; 388 | const CONFIG_SECTION_EXPANSIONS = "textExpansions"; 389 | const CONFIG_SECTION_BOOKMARKS = "bookmarks"; 390 | const CONFIG_SECTION_KB_SHORTCUTS = "keyboardShortcuts"; 391 | const CONFIG_SECTION_BANNED_BOOKMARK_SEARCH_PREFIXES = "bannedBookmarkSearchPrefixes"; 392 | 393 | // Global event listener data 394 | const gelData = [0, 0, 0, 0]; 395 | const GEL_CALLBACKS_FIRED = 0; 396 | const GEL_CALLBACKS_RECEIVED = 1; 397 | const GEL_CALLBACKS_TOTAL_MS = 2; 398 | const GEL_CALLBACKS_MAX_MS = 3; 399 | const IS_DEBUG_GEL_TIMING = false; 400 | 401 | const isBetaDomain = location.origin === "https://beta.workflowy.com"; 402 | const isDevDomain = location.origin === "https://dev.workflowy.com"; 403 | const originalWindowOpenFn = window.open; 404 | 405 | class AbortActionError extends Error { 406 | constructor(message) { 407 | super(message); 408 | this.name = "AbortActionError"; 409 | } 410 | } 411 | 412 | /** 413 | * Calls the given no-args function, catching any AbortActionError 414 | * and showing its message. 415 | * @param {function} f The function to call. 416 | * @returns {void} 417 | */ 418 | function callWithErrorHandling(f) { 419 | try { 420 | f(); 421 | } catch (err) { 422 | if (err instanceof AbortActionError) { 423 | WF.showMessage(`Action failed: ${err.message}`, true); 424 | } else { 425 | throw err; 426 | } 427 | } 428 | } 429 | 430 | /** 431 | * @param {boolean} condition Whether to throw the AbortActionError. 432 | * @param {string | function} message The message to include in the error. 433 | * If a function, must return a string. 434 | * @throws {AbortActionError} If the condition is true. 435 | * @returns {void} 436 | */ 437 | function failIf(condition, message) { 438 | if (condition) { 439 | throw new AbortActionError(message); 440 | } 441 | } 442 | 443 | /** 444 | * @param {Item} item The item to check. 445 | * @returns {void} 446 | * @throws {AbortActionError} If the item is embedded. 447 | */ 448 | function validateItemIsLocalOrFail(item) { 449 | failIf( 450 | item && item.isEmbedded(), 451 | () => `${formatItem(item)} is embedded from another document. Was expecting local items only.` 452 | ); 453 | } 454 | 455 | function openHere(url) { 456 | open(url, "_self"); 457 | } 458 | 459 | /** 460 | * Note: pop-ups must be allowed from https://workflowy.com for this to work. 461 | * @param {string} url The URL to open. 462 | * @returns {void} 463 | */ 464 | function openInNewTab(url) { 465 | open(url, "_blank"); 466 | } 467 | 468 | /** 469 | * An alternative to window.open which rewrites the URL as 470 | * necessary to avoid crossing between the various workflowy.com subdomains. 471 | * Intended primarily for use on the dev/beta domains but also usable in prod. 472 | * @param {string} url The URL to open. 473 | * @param {string} targetWindow (See documentation for window.open) 474 | * @param {string} features (See documentation for window.open) 475 | * @param {boolean} replace (See documentation for window.open) 476 | * @returns {Window} (See documentation for window.open) 477 | */ 478 | function _openWithoutChangingWfDomain(url, targetWindow, features, replace) { 479 | if (isWorkFlowyUrl(url)) { 480 | url = location.origin + url.substring(new URL(url).origin.length); 481 | } 482 | targetWindow = targetWindow || (isWorkFlowyUrl(url) ? "_self" : "_blank"); 483 | return originalWindowOpenFn(url, targetWindow, features, replace); 484 | } 485 | 486 | function zoomToAndSearch(item, searchQuery) { 487 | if (searchQuery) { 488 | // This is much faster than zooming and searching in two steps. 489 | openHere(itemAndSearchToWorkFlowyUrl("current", item, searchQuery)); 490 | } else { 491 | WF.zoomTo(item); 492 | } 493 | } 494 | 495 | /** 496 | * Prompt for a search query, then perform a normal WorkFlowy search. 497 | * @returns {void} 498 | */ 499 | function promptToNormalLocalSearch() { 500 | const previousQuery = WF.currentSearchQuery(); 501 | const newQuery = prompt("WorkFlowy search: ", previousQuery || ""); 502 | if (newQuery !== null) { 503 | WF.search(newQuery); 504 | } 505 | } 506 | 507 | /** 508 | * Prompts for X, then performs a local WorkFlowy search for last-changed:X 509 | * @returns {void} 510 | */ 511 | function promptToFindByLastChanged() { 512 | const timePeriod = prompt("last-changed="); 513 | if (timePeriod) { 514 | WF.search(`last-changed:${timePeriod.trim()}`); 515 | } 516 | } 517 | 518 | /** 519 | * @returns {void} Dismisses a notification from the WorkFlowy UI, e.g. after 520 | * deleting a large tree of items. 521 | */ 522 | function dismissNotification() { 523 | WF.hideMessage(); 524 | } 525 | 526 | function createItemAtTopOfCurrent() { 527 | let item = null; 528 | // Workaround: Use edit group to avoid WF.createItem() returning undefined 529 | WF.editGroup(() => { 530 | item = WF.createItem(WF.currentItem(), 0); 531 | }); 532 | if (item) { 533 | WF.editItemName(item); 534 | } 535 | } 536 | 537 | /** 538 | * @param {string} tagToMatch The tag to match. 539 | * @param {string} s The string to test. 540 | * @returns {boolean} True if and only if the given string has the 541 | * exact given tag, ignoring case. Otherwise false. 542 | * @see {@link itemToTags} For notes, caveats regarding tag handling. 543 | */ 544 | function doesStringHaveTag(tagToMatch, s) { 545 | if (s === null || tagToMatch === null) { 546 | return false; 547 | } 548 | 549 | // Ignore case 550 | tagToMatch = tagToMatch.toLowerCase(); 551 | s = s.toLowerCase(); 552 | 553 | let nextStart = 0; 554 | let matched = false; 555 | for (;;) { 556 | const tagIndex = s.indexOf(tagToMatch, nextStart); 557 | if (tagIndex === -1) { 558 | break; // Non-match: tag not found 559 | } 560 | if (tagToMatch.match(/^[@#]/) === null) { 561 | break; // Non-match: invalid tagToMatch 562 | } 563 | const afterTag = tagIndex + tagToMatch.length; 564 | nextStart = afterTag; 565 | if (s.substring(afterTag).match(/^[:]?[a-z0-9-_]/)) { 566 | continue; // This is a longer tag than tagToMatch, so keep looking 567 | } else { 568 | matched = true; 569 | break; 570 | } 571 | } 572 | return matched; 573 | } 574 | 575 | /** 576 | * An 'argument passing' mechanism, where '#foo(bar, baz)' 577 | * is considered as 'passing' the string "bar, baz" to #foo. 578 | * It's very rudimentary/naive: e.g. "#foo('bar)', baz')" 579 | * would lead to an args string of "'bar". 580 | * 581 | * @param {string} tagToMatch The tag to match. 582 | * E.g. "#foo". 583 | * @param {string} s The string to extract the args text from. 584 | * E.g. "#foo(bar, baz)". 585 | * @returns {string} The trimmed arguments string, or null if no call found. 586 | * E.g. "bar, baz". 587 | */ 588 | function stringToTagArgsText(tagToMatch, s) { 589 | if (s === null || s === "") { 590 | return null; 591 | } 592 | let start = 0; 593 | for (;;) { 594 | const tagIndex = s.indexOf(tagToMatch, start); 595 | if (tagIndex === -1) { 596 | return null; 597 | } 598 | const afterTag = tagIndex + tagToMatch.length; 599 | const callOpenIndex = s.indexOf("(", afterTag); 600 | if (callOpenIndex === -1) { 601 | return null; 602 | } 603 | const callCloseIndex = s.indexOf(")", callOpenIndex + 1); 604 | if (callCloseIndex === -1) { 605 | return null; 606 | } 607 | const fullCall = s.substring(afterTag, callCloseIndex + 1); 608 | if (_stringToTagArgsText_CallRegExp.test(fullCall)) { 609 | return s.substring(callOpenIndex + 1, callCloseIndex).trim(); 610 | } 611 | start = afterTag; 612 | } 613 | } 614 | 615 | const _stringToTagArgsText_CallRegExp = RegExp("^ *\\([^\\(\\)]*\\) *$"); 616 | 617 | /** 618 | * Finds and returns the items whose "combined plain text" matches the 619 | * given regular expression. Here, the "combined plain text" is the item's 620 | * name as plain text, plus the item's note as plain text, with a 621 | * newline separating the two only when the note is non-empty. 622 | * 623 | * @param {RegExp} regExp The compiled regular expression to match. 624 | * @param {Item} searchRoot The root item of the search. 625 | * @returns {Array} The matching items, in order of appearance. 626 | */ 627 | function findItemsMatchingRegex(regExp, searchRoot) { 628 | if (typeof regExp !== "object" || regExp.constructor.name !== "RegExp") { 629 | throw "regExp must be a compiled RegExp object. regExp: " + regExp; 630 | } 631 | function itemPredicate(item) { 632 | const combinedText = itemToCombinedPlainText(item); 633 | return regExp.test(combinedText); 634 | } 635 | return findMatchingItems(itemPredicate, searchRoot); 636 | } 637 | 638 | /** 639 | * Prompts the user for a regular expression string (case insensitive, and 640 | * defaulting to the last chosen regex), the searches for items under the 641 | * currently zoomed item which match it, then prompts the user to choose which 642 | * of the matching items to go to. 643 | * @see findItemsMatchingRegex For how the regex is matched against the item. 644 | * @returns {void} 645 | */ 646 | function promptToFindLocalRegexMatchThenZoom() { 647 | const defaultSearch = lastRegexString || ".*"; 648 | const promptString = "Search for regular expression (case insensitive):"; 649 | const regExpString = prompt(promptString, defaultSearch); 650 | if (!regExpString) { 651 | return; 652 | } 653 | lastRegexString = regExpString; 654 | let regExp; 655 | try { 656 | regExp = RegExp(regExpString, "i"); 657 | } catch (er) { 658 | alert(er); 659 | return; 660 | } 661 | const startTime = new Date(); 662 | const matchingItems = findItemsMatchingRegex(regExp, getZoomedItem()); 663 | const message = `Found ${matchingItems.length} matches for ${regExp}`; 664 | logElapsedTime(startTime, message); 665 | 666 | if (matchingItems.length === 0) { 667 | alert(`No items under current location match '${regExpString}'.`); 668 | } else { 669 | const chosenItem = promptToChooseItem(matchingItems, null); 670 | if (chosenItem) { 671 | zoomToAndSearch(chosenItem, null); 672 | } 673 | } 674 | } 675 | 676 | /** 677 | * @param {string} tagToMatch The tag to match. 678 | * @param {Item} item The item to extract the args text from. 679 | * @returns {string} The trimmed arguments string, or null if no call found. 680 | * @see {@link stringToTagArgsText} For semantics. 681 | */ 682 | function itemToTagArgsText(tagToMatch, item) { 683 | const resultForName = stringToTagArgsText(tagToMatch, itemToPlainTextName(item)); 684 | if (resultForName !== null) { 685 | return resultForName; 686 | } 687 | return stringToTagArgsText(tagToMatch, itemToPlainTextNote(item)); 688 | } 689 | 690 | /** 691 | * @param {Item} item The item to query 692 | * @returns {boolean} Whether the given item is the root item 693 | */ 694 | function isRootItem(item) { 695 | return item.getId() === "None"; 696 | } 697 | 698 | /** 699 | * @returns {string} The long (non-truncated) ID of the 700 | * item which is currently zoomed into. 701 | */ 702 | function getZoomedItemAsLongId() { 703 | return WF.currentItem().getId(); 704 | } 705 | 706 | /** 707 | * @returns {Item} The item which is currently zoomed into. 708 | */ 709 | function getZoomedItem() { 710 | const zoomedItemId = getZoomedItemAsLongId(); 711 | return WF.getItemById(zoomedItemId); 712 | } 713 | 714 | /** 715 | * @param {Item} item The item whose path to get 716 | * @returns {Array} An array starting with the root and ending 717 | * with the item. 718 | */ 719 | function itemToPathAsItems(item) { 720 | return item 721 | .getAncestors() // parent ... root 722 | .slice() 723 | .reverse() // root ... parent 724 | .concat(item); // root ... parent, item 725 | } 726 | 727 | /** 728 | * @param {Item} itemToMove The item to be moved. 729 | * @param {Item} targetItem The target item being moved to. 730 | * @returns {boolean} Whether it's safe to move the item to the target. 731 | */ 732 | function isSafeToMoveItemToTarget(itemToMove, targetItem) { 733 | // Both must be specified 734 | if (!itemToMove || !targetItem) { 735 | return false; 736 | } 737 | // Can't be the same item 738 | if (itemToMove.getId() === targetItem.getId()) { 739 | return false; 740 | } 741 | // Neither can be read-only 742 | if (itemToMove.isReadOnly() || targetItem.isReadOnly()) { 743 | return false; 744 | } 745 | // Can't move an ancestor to a descendant 746 | if (isAAncestorOfB(itemToMove, targetItem)) { 747 | return false; 748 | } 749 | 750 | return true; 751 | } 752 | 753 | /** 754 | * @param {Item} a Item A. 755 | * @param {Item} b Item B. 756 | * @returns {boolean} Whether item A is an ancestor of item B. 757 | */ 758 | function isAAncestorOfB(a, b) { 759 | if (!a || !b) { 760 | return false; 761 | } 762 | let ancestorOfB = b.getParent(); 763 | while (ancestorOfB) { 764 | if (a.getId() === ancestorOfB.getId()) { 765 | return true; 766 | } 767 | ancestorOfB = ancestorOfB.getParent(); 768 | } 769 | return false; 770 | } 771 | 772 | /** 773 | * @param {Item} itemA Some item 774 | * @param {Item} itemB Another item 775 | * @returns {Item} The closest common ancestor of both items, inclusive. 776 | */ 777 | function findClosestCommonAncestor(itemA, itemB) { 778 | const pathA = itemToPathAsItems(itemA); 779 | const pathB = itemToPathAsItems(itemB); 780 | const minLength = Math.min(pathA.length, pathB.length); 781 | 782 | let i; 783 | for (i = 0; i < minLength; i++) { 784 | if (pathA[i].getId() !== pathB[i].getId()) { 785 | break; 786 | } 787 | } 788 | if (i === 0) { 789 | throw "Items shared no common root"; 790 | } 791 | return pathA[i - 1]; 792 | } 793 | 794 | /** 795 | * @param {'prod' | 'beta' | 'dev' | 'current'} domainType Domain to use. 796 | * @returns {string} The base WorkFlowy URL for the given domain type. 797 | */ 798 | function getWorkFlowyBaseUrlForDomainType(domainType) { 799 | switch (domainType) { 800 | case "current": 801 | return location.origin; 802 | case "prod": 803 | return "https://workflowy.com"; 804 | case "beta": 805 | return "https://beta.workflowy.com"; 806 | case "dev": 807 | return "https://dev.workflowy.com"; 808 | default: 809 | throw "Unrecognized domain type: " + domainType; 810 | } 811 | } 812 | 813 | /** 814 | * @param {'prod' | 'beta' | 'dev' | 'current'} domainType Domain to use. 815 | * @param {Item} item The item to create a WorkFlowy URL for. 816 | * @param {string} searchQuery (Optional) search query string. 817 | * @returns {string} The WorkFlowy URL pointing to the item. 818 | */ 819 | function itemAndSearchToWorkFlowyUrl(domainType, item, searchQuery) { 820 | const baseUrl = getWorkFlowyBaseUrlForDomainType(domainType); 821 | const searchSuffix = searchQuery ? `?q=${encodeURIComponent(searchQuery)}` : ""; 822 | return `${baseUrl}/${itemToHashSegment(item)}${searchSuffix}`; 823 | } 824 | 825 | /** 826 | * @returns {boolean} True if and only if the given string is a WorkFlowy URL. 827 | * @param {string} s The string to test. 828 | */ 829 | function isWorkFlowyUrl(s) { 830 | return s && s.match("^https://(dev\\.|beta\\.)?workflowy\\.com(/.*)?$") !== null; 831 | } 832 | 833 | /** 834 | * @param {string} fullUrl The full WorkFlowy URL. 835 | * @returns {[string, string]} The hash segment, of the form returned by 836 | * itemToHashSegment(), and search query or null. 837 | */ 838 | function workFlowyUrlToHashSegmentAndSearchQuery(fullUrl) { 839 | const urlObject = new URL(fullUrl); 840 | let [hash, rawSearchQuery] = urlObject.hash.split("?q="); 841 | if (hash.length <= 2) { 842 | // '#/' or '#' or '' 843 | hash = "#"; 844 | } 845 | let searchQuery = null; 846 | if (rawSearchQuery) { 847 | searchQuery = decodeURIComponent(rawSearchQuery); 848 | } 849 | return [hash, searchQuery]; 850 | } 851 | 852 | /** 853 | * @param {Item} item The item. 854 | * @returns {string} '#' for the root item, or e.g. '#/80cbd123abe1'. 855 | */ 856 | function itemToHashSegment(item) { 857 | let hash = item.getUrl(); 858 | hash = hash.startsWith("/") ? hash.substring(1) : hash; 859 | return hash === "" ? "#" : hash; 860 | } 861 | 862 | /** 863 | * Walks the entire tree, (re-)populating the hashSegmentsToIds map. 864 | * @returns {void} 865 | */ 866 | function populateHashSegmentsForFullTree() { 867 | function populateHash(item) { 868 | const hashSegment = itemToHashSegment(item); 869 | if (!hashSegmentsToIds.has(hashSegment)) { 870 | hashSegmentsToIds.set(hashSegment, item.getId()); 871 | } 872 | } 873 | applyToEachItem(populateHash, WF.rootItem()); 874 | } 875 | 876 | /** 877 | * @param {string} hashSegment The hash segment part of a WorkFlowy URL. 878 | * @returns {string | null} The ID of the item if found, otherwise null. 879 | */ 880 | function findItemIdForHashSegment(hashSegment) { 881 | const existingEntry = hashSegmentsToIds.get(hashSegment); 882 | if (existingEntry) { 883 | return existingEntry; 884 | } 885 | if (unknownHashSegments.has(hashSegment)) { 886 | return null; 887 | } 888 | // First request for this hash segment. Re-index the entire tree. 889 | populateHashSegmentsForFullTree(); 890 | // Re-check the map, blacklisting the hash segment if not found 891 | if (hashSegmentsToIds.has(hashSegment)) { 892 | return hashSegmentsToIds.get(hashSegment); 893 | } else { 894 | unknownHashSegments.add(hashSegment); 895 | return null; 896 | } 897 | } 898 | 899 | /** 900 | * @param {string} fullUrl The WorkFlowy URL. 901 | * @returns {[string, string]} ID of the item in the URL, and search query. 902 | */ 903 | function findItemIdAndSearchQueryForWorkFlowyUrl(fullUrl) { 904 | const [hashSegment, searchQuery] = workFlowyUrlToHashSegmentAndSearchQuery(fullUrl); 905 | const itemId = findItemIdForHashSegment(hashSegment); 906 | return [itemId, searchQuery]; 907 | } 908 | 909 | /** 910 | * @param {string} fullUrl The WorkFlowy URL. 911 | * @returns {[Item, string]} The ID of the item in the URL, and search query. 912 | */ 913 | function findItemAndSearchQueryForWorkFlowyUrl(fullUrl) { 914 | const [id, query] = findItemIdAndSearchQueryForWorkFlowyUrl(fullUrl); 915 | let item = null; 916 | if (id) { 917 | item = WF.getItemById(id); 918 | } 919 | return [item, query]; 920 | } 921 | 922 | const validRootUrls = []; 923 | for (let subdomainPrefix of ["", "beta.", "dev."]) { 924 | for (let suffix of ["", "/", "/#", "/#/"]) { 925 | validRootUrls.push(`https://${subdomainPrefix}workflowy.com${suffix}`); 926 | } 927 | } 928 | 929 | /** 930 | * @param {Array} items The items to build the search query for. 931 | * @returns {string} The search query to use for finding the 932 | * items, or an unmatchable query if items is empty. 933 | */ 934 | function itemsToVolatileSearchQuery(items) { 935 | if (items.length === 0) { 936 | // Return a search query which matches no items 937 | return searchQueryToMatchNoItems; 938 | } 939 | const searches = items.map(n => itemToVolatileSearchQuery(n)); 940 | return searches.join(" OR "); 941 | } 942 | 943 | /** 944 | * @param {Item} item The item to build the search query for. 945 | * @returns {string} The search query to use for finding the item, or 946 | * an unmatchable query for the root item. 947 | */ 948 | function itemToVolatileSearchQuery(item) { 949 | if (isRootItem(item)) { 950 | // Return a search query which matches no items 951 | return searchQueryToMatchNoItems; 952 | } 953 | const currentTimeSec = getCurrentTimeSec(); 954 | const itemLastModifiedSec = itemToLastModifiedSec(item); 955 | const modifiedHowLongAgoSec = currentTimeSec - itemLastModifiedSec; 956 | const modifiedHowLongAgoMinutes = Math.ceil(modifiedHowLongAgoSec / 60); 957 | const timeClause = `last-changed:${modifiedHowLongAgoMinutes + 1} -last-changed:${modifiedHowLongAgoMinutes - 1} `; 958 | const nameClause = splitStringToSearchTerms(itemToPlainTextName(item)); 959 | return timeClause + nameClause; 960 | } 961 | 962 | function splitStringToSearchTerms(s) { 963 | const lines = s.match(/[^\r\n]+/g); 964 | if (lines === null || lines.length === 0) { 965 | return ""; 966 | } 967 | let result = ""; 968 | for (let line of lines) { 969 | for (let segment of line.split('"')) { 970 | if (segment.trim() !== "") { 971 | // Use 2 spaces here, to work around a WorkFlowy bug 972 | // where "a" "b c" works, but "a" "b c" does not. 973 | result += ` "${segment}"`; 974 | } 975 | } 976 | } 977 | return result; 978 | } 979 | 980 | /** 981 | * @param {string} tag The tag to find, e.g. "#foo". 982 | * @param {Item} searchRoot The root item of the search. 983 | * @returns {Array} The matching items, in order of appearance. 984 | */ 985 | function findItemsWithTag(tag, searchRoot) { 986 | return findMatchingItems(n => doesItemHaveTag(tag, n), searchRoot); 987 | } 988 | 989 | /** 990 | * @template T 991 | * @param {function} doesABeatB A function which return whether A beats B. 992 | * @param {Array} results The current results array, ordered 993 | * [null, null, ..., 2nd best, best]. 994 | * @param {T} candidate The candidate item. 995 | * @returns {void} 996 | * Note: optimised for large numbers of calls, with small results sizes. 997 | */ 998 | function insertIntoSortedResults(doesABeatB, results, candidate) { 999 | if (candidate === undefined || candidate === null) { 1000 | return; 1001 | } 1002 | let insertAt = -1; 1003 | for (let i = 0; i < results.length; i++) { 1004 | const toReplace = results[i]; 1005 | if (toReplace === null || doesABeatB(candidate, toReplace)) { 1006 | insertAt = i; 1007 | } else { 1008 | break; 1009 | } 1010 | } 1011 | if (insertAt > 0) { 1012 | // Shift existing results to the left, from out insertion point 1013 | results.copyWithin(0, 1, insertAt + 1); 1014 | } 1015 | if (insertAt >= 0) { 1016 | results[insertAt] = candidate; 1017 | } 1018 | } 1019 | 1020 | /** 1021 | * @returns {Array} The top items under the given 1022 | * search root (inclusive), with higher scoring items first. 1023 | * @param {function} itemToScoreFn A function from item (Item) 1024 | * to a score (number), where higher scores are better. 1025 | * @param {number} minScore Items must have this score or higher to 1026 | * be included in the results. 1027 | * @param {number} maxSize The results array will be at most this size. 1028 | * @param {Item} searchRoot The root item of the search. 1029 | */ 1030 | function findTopItemsByScore(itemToScoreFn, minScore, maxSize, searchRoot) { 1031 | const results = Array(maxSize).fill(null); 1032 | function isABetterThanB(itemAndScoreA, itemAndScoreB) { 1033 | return itemAndScoreA[1] > itemAndScoreB[1]; 1034 | } 1035 | function forEachItem(item) { 1036 | const score = itemToScoreFn(item); 1037 | const itemAndScore = [item, score]; 1038 | if (score >= minScore) { 1039 | insertIntoSortedResults(isABetterThanB, results, itemAndScore); 1040 | } 1041 | } 1042 | applyToEachItem(forEachItem, searchRoot); 1043 | return results 1044 | .filter(x => x !== null) 1045 | .map(x => x[0]) 1046 | .reverse(); 1047 | } 1048 | 1049 | /** 1050 | * @template T 1051 | * @returns {Array} The the best N items from the given Array, 1052 | * as scored by the given function. 1053 | * @param {function} isABetterThanB A function which take items A and B, 1054 | * returning true if A is 'better', and false if B is 'better'. 1055 | * @param {number} maxSize The results array will be at most this size. 1056 | * @param {Iterable} items The items to search. 1057 | */ 1058 | function findTopItemsByComparator(isABetterThanB, maxSize, items) { 1059 | const results = Array(maxSize).fill(null); 1060 | for (let candidate in items) { 1061 | insertIntoSortedResults(isABetterThanB, results, candidate); 1062 | } 1063 | return results.filter(x => x !== null).reverse(); 1064 | } 1065 | 1066 | /** 1067 | * @param {Item} item The item whose children to filter. 1068 | * @returns {Array} The filtered children. 1069 | */ 1070 | function getUncompletedChildren(item) { 1071 | return item.getChildren().filter(i => !i.isCompleted()); 1072 | } 1073 | 1074 | class ConversionFailure { 1075 | /** 1076 | * @param {string} description A description of the failure. 1077 | * @param {Item} item The item at which the failure occurred. 1078 | * @param {Array} causes The underlying causes. 1079 | */ 1080 | constructor(description, item, causes) { 1081 | this.description = description; 1082 | this.item = item; 1083 | this.causes = causes; 1084 | } 1085 | 1086 | toString() { 1087 | let parts = []; 1088 | if (this.description) { 1089 | parts.push(this.description); 1090 | } 1091 | if (this.item) { 1092 | parts.push(`See ${itemAndSearchToWorkFlowyUrl("current", this.item, null)} .`); 1093 | } 1094 | if (this.causes) { 1095 | parts.push(`Caused by: ${this.causes}.`); 1096 | } 1097 | return parts.join(" ") || "Conversion failure"; 1098 | } 1099 | } 1100 | 1101 | class ConversionResult { 1102 | /** 1103 | * @param {any} value The result value. 1104 | * @param {boolean} isUsable Whether or not the result is usable. 1105 | * @param {Array} conversionFailures Array of failures. 1106 | */ 1107 | constructor(value, isUsable, conversionFailures) { 1108 | this.value = value; 1109 | this.isUsable = isUsable; 1110 | this.conversionFailures = conversionFailures; 1111 | } 1112 | 1113 | toString() { 1114 | let parts = []; 1115 | if (this.value) { 1116 | parts.push(this.value.toString()); 1117 | } 1118 | if (!this.isUsable) { 1119 | parts.push("(Not usable.)"); 1120 | } 1121 | if (this.conversionFailures) { 1122 | parts.push(`Failures: ${this.conversionFailures}.`); 1123 | } 1124 | return parts.join(" ") || "Conversion result"; 1125 | } 1126 | } 1127 | 1128 | /** 1129 | * Returns a ConversionResult with a Map of values based on the given item. 1130 | * Ignores completed items, and trims keys strings. 1131 | * @param {Item} item The WorkFlowy item which contains the values. 1132 | * @param {function} keyToConverter (key) -> (Item) -> ConversionResult. 1133 | * @returns {ConversionResult} Result, with a Map of converted values. 1134 | */ 1135 | function convertToMap(item, keyToConverter) { 1136 | /** @type Map */ 1137 | const rMap = new Map(); 1138 | /** @type Array */ 1139 | const failures = new Array(); 1140 | for (let child of getUncompletedChildren(item)) { 1141 | let key = child.getNameInPlainText().trim(); 1142 | if (rMap.has(key)) { 1143 | failures.push(new ConversionFailure(`Ignoring value for ${key}, which is already set above.`, child, null)); 1144 | } else { 1145 | let childConverter = keyToConverter(key); 1146 | if (childConverter) { 1147 | /** @type ConversionResult */ 1148 | let conversionResult = childConverter(child); 1149 | if (conversionResult.isUsable) { 1150 | rMap.set(key, conversionResult.value); 1151 | } 1152 | if (conversionResult.conversionFailures) { 1153 | failures.push(...conversionResult.conversionFailures); 1154 | } 1155 | } else { 1156 | failures.push(new ConversionFailure(`Unknown key "${key}".`, child, null)); 1157 | } 1158 | } 1159 | } 1160 | return new ConversionResult(rMap, true, failures); 1161 | } 1162 | 1163 | /** 1164 | * Returns a ConversionResult with a Map of strings based on the given item. 1165 | * Ignores completed items, and trims keys strings. 1166 | * @param {Item} item The WorkFlowy item which contains the values. 1167 | * @returns {ConversionResult} Result, with a Map of converted strings. 1168 | */ 1169 | function convertToMapOfStrings(item) { 1170 | return convertToMap(item, () => convertToNotePlainText); 1171 | } 1172 | 1173 | /** 1174 | * Returns a ConversionResult with a Map of items based on the given item. 1175 | * @param {Item} item The WorkFlowy item which contains the values. 1176 | * @returns {ConversionResult} Result, with a Map of converted strings to items. 1177 | */ 1178 | function convertToMapOfItems(item) { 1179 | const wrapItemAsResult = i => new ConversionResult(i, true, null); 1180 | return convertToMap(item, () => wrapItemAsResult); 1181 | } 1182 | 1183 | /** 1184 | * Returns a ConversionResult with an Array of values based on the given item. 1185 | * Ignores completed items. 1186 | * @param {Item} item The WorkFlowy item which contains the values. 1187 | * @param {function} childConverter Converts the child values. 1188 | * (Item) -> ConversionResult. 1189 | * @returns {ConversionResult} Result, with an Array of converted values. 1190 | */ 1191 | function convertToArray(item, childConverter) { 1192 | const rArray = new Array(); 1193 | /** @type Array */ 1194 | const failures = new Array(); 1195 | for (let child of getUncompletedChildren(item)) { 1196 | /** @type ConversionResult */ 1197 | let conversionResult = childConverter(child); 1198 | if (conversionResult.isUsable) { 1199 | rArray.push(conversionResult.value); 1200 | } 1201 | if (conversionResult.conversionFailures) { 1202 | failures.push(...conversionResult.conversionFailures); 1203 | } 1204 | } 1205 | return new ConversionResult(rArray, true, failures); 1206 | } 1207 | 1208 | /** 1209 | * Returns a ConversionResult with an Array of strings based on the item. 1210 | * Ignores completed items. 1211 | * @param {Item} item The WorkFlowy item which contains the values. 1212 | * @returns {ConversionResult} Result, with an Array of converted strings. 1213 | */ 1214 | // eslint-disable-next-line no-unused-vars 1215 | function convertToArrayOfStrings(item) { 1216 | return convertToArray(item, convertToNameOrNotePlainText); 1217 | } 1218 | 1219 | /** 1220 | * @param {Item} item The item whose note to use. 1221 | * @returns {ConversionResult} A result, with a string value. 1222 | */ 1223 | function convertToNotePlainText(item) { 1224 | return new ConversionResult(item.getNoteInPlainText(), true, null); 1225 | } 1226 | 1227 | /** 1228 | * @param {Item} item The item whose name or note to use. 1229 | * @returns {ConversionResult} A result, with a string value. 1230 | */ 1231 | function convertToNameOrNotePlainText(item) { 1232 | const name = item.getNameInPlainText(); 1233 | const note = item.getNoteInPlainText(); 1234 | const nameTrimmed = name.trim(); 1235 | const noteTrimmed = note.trim(); 1236 | let isUsable = true; 1237 | let failures = new Array(); 1238 | let value = null; 1239 | if (nameTrimmed && noteTrimmed) { 1240 | failures.push( 1241 | new ConversionFailure("Can't specify both a name and a note. Delete the name or the note.", item, null) 1242 | ); 1243 | isUsable = false; 1244 | } else if (nameTrimmed) { 1245 | value = name; 1246 | } else if (noteTrimmed) { 1247 | value = note; 1248 | } else { 1249 | value = name; 1250 | } 1251 | return new ConversionResult(value, isUsable, failures); 1252 | } 1253 | 1254 | /** 1255 | * @param {Item} item The item to test. 1256 | * @returns {boolean} True if and only if the item is a configuration root. 1257 | */ 1258 | function isConfigurationRoot(item) { 1259 | return item.getNameInPlainText().trim() === CONFIGURATION_ROOT_NAME; 1260 | } 1261 | 1262 | /** 1263 | * @returns {Item | null} item The configuration item if found, or null. 1264 | */ 1265 | function findConfigurationRootItem() { 1266 | if (configurationRootItem === null || !isConfigurationRoot(configurationRootItem)) { 1267 | configurationRootItem = null; 1268 | const matchingNodes = findMatchingItems(isConfigurationRoot, WF.rootItem()); 1269 | if (matchingNodes.length > 0) { 1270 | configurationRootItem = matchingNodes[0]; 1271 | } 1272 | if (matchingNodes.length > 1) { 1273 | WF.showMessage(`Multiple ${CONFIGURATION_ROOT_NAME} items found. Using the first one.`, false); 1274 | } 1275 | } 1276 | return configurationRootItem; 1277 | } 1278 | 1279 | /** 1280 | * @param {Item} item The configuration item. 1281 | * @returns {ConversionResult} The configuration result. 1282 | */ 1283 | function convertJumpFlowyConfiguration(item) { 1284 | function keyToConverter(key) { 1285 | switch (key) { 1286 | case CONFIG_SECTION_BOOKMARKS: 1287 | return convertToMapOfItems; 1288 | case CONFIG_SECTION_EXPANSIONS: // Falls through 1289 | case CONFIG_SECTION_KB_SHORTCUTS: 1290 | case CONFIG_SECTION_BANNED_BOOKMARK_SEARCH_PREFIXES: 1291 | return convertToMapOfStrings; 1292 | } 1293 | } 1294 | return convertToMap(item, keyToConverter); 1295 | } 1296 | 1297 | /** 1298 | * @param {string} sectionName The name of the configuration section. 1299 | * @returns {Item | null} The section item, or null if not found. 1300 | */ 1301 | function findConfigurationSection(sectionName) { 1302 | if (configurationRootItem) { 1303 | for (let child of configurationRootItem.getChildren()) { 1304 | if (child.getNameInPlainText().trim() === sectionName) { 1305 | return child; 1306 | } 1307 | } 1308 | } 1309 | return null; 1310 | } 1311 | 1312 | /** 1313 | * Global event listener. 1314 | * @param {string} eventName The name of the event. 1315 | * @returns {void} 1316 | */ 1317 | function wfEventListener(eventName) { 1318 | if ((eventName && eventName.startsWith("operation--")) || eventName === "locationChanged") { 1319 | gelData[GEL_CALLBACKS_FIRED]++; 1320 | // Do the actual work after letting the UI update 1321 | setTimeout(() => { 1322 | const start = new Date(); 1323 | gelData[GEL_CALLBACKS_RECEIVED]++; 1324 | // When multiple events are fired together, only process the last one 1325 | if (gelData[GEL_CALLBACKS_FIRED] === gelData[GEL_CALLBACKS_RECEIVED]) { 1326 | cleanConfiguration(); 1327 | reloadConfiguration(); 1328 | } 1329 | const end = new Date(); 1330 | const elapsedMs = end.getTime() - start.getTime(); 1331 | gelData[GEL_CALLBACKS_TOTAL_MS] += elapsedMs; 1332 | gelData[GEL_CALLBACKS_MAX_MS] = Math.max(gelData[GEL_CALLBACKS_MAX_MS], elapsedMs); 1333 | if (IS_DEBUG_GEL_TIMING) console.log(`${gelData}`); 1334 | }, 0); 1335 | } 1336 | } 1337 | 1338 | /** 1339 | * Wipes state which is set in response to user configuration. 1340 | * @returns {void} 1341 | */ 1342 | function cleanConfiguration() { 1343 | customExpansions = new Map(); 1344 | bookmarksToItemTargets = new Map(); 1345 | bookmarksToSourceItems = new Map(); 1346 | itemIdsToFirstBookmarks = new Map(); 1347 | canonicalKeyCodesToTargets.clear(); 1348 | bannedBookmarkSearchPrefixesToSuggestions = new Map(); 1349 | } 1350 | 1351 | /** 1352 | * Finds and applies user configuration. 1353 | * @returns {boolean} True if the config was found, was usable, 1354 | * and was applied. False if not found or not usable. 1355 | */ 1356 | function reloadConfiguration() { 1357 | // Find and validate configuration 1358 | const configItem = findConfigurationRootItem(); 1359 | if (configItem === null) return false; 1360 | 1361 | const result = convertJumpFlowyConfiguration(configItem); 1362 | if (result.conversionFailures && result.conversionFailures.length > 0) { 1363 | WF.showMessage(result.conversionFailures.toString(), !result.isUsable); 1364 | } 1365 | if (result.isUsable && result.value) { 1366 | // Apply configuration 1367 | applyConfiguration(result.value); 1368 | return true; 1369 | } else { 1370 | return false; 1371 | } 1372 | } 1373 | 1374 | /** 1375 | * Applies the given user configuration object. 1376 | * @param {Map} configObject The configuration object. 1377 | * @returns {void} 1378 | */ 1379 | function applyConfiguration(configObject) { 1380 | // Text expansions 1381 | /** @type Map */ 1382 | const expansionsConfig = configObject.get(CONFIG_SECTION_EXPANSIONS) || new Map(); 1383 | customExpansions = new Map([...abbrevsFromTags, ...expansionsConfig]); 1384 | 1385 | // Keyboard shortcuts 1386 | /** @type Map */ 1387 | const shortcutsConfig = configObject.get(CONFIG_SECTION_KB_SHORTCUTS) || new Map(); 1388 | const allKeyCodesToFunctions = new Map([ 1389 | ...kbShortcutsFromTags, 1390 | ..._convertKbShortcutsConfigToTargetMap(shortcutsConfig) 1391 | ]); 1392 | allKeyCodesToFunctions.forEach((target, code) => { 1393 | canonicalKeyCodesToTargets.set(code, target); 1394 | }); 1395 | 1396 | // Banned bookmark searches 1397 | bannedBookmarkSearchPrefixesToSuggestions = 1398 | configObject.get(CONFIG_SECTION_BANNED_BOOKMARK_SEARCH_PREFIXES) || new Map(); 1399 | 1400 | // Bookmarks 1401 | applyBookmarksConfiguration(configObject); 1402 | } 1403 | 1404 | /** 1405 | * Applies the bookmarks configuration for given user configuration object. 1406 | * @param {Map} configObject The configuration object. 1407 | * @returns {void} 1408 | */ 1409 | function applyBookmarksConfiguration(configObject) { 1410 | /** @type Map */ 1411 | const bookmarksConfig = configObject.get(CONFIG_SECTION_BOOKMARKS) || new Map(); 1412 | bookmarksConfig.forEach((sourceItem, bookmarkName) => { 1413 | bookmarksToSourceItems.set(bookmarkName, sourceItem); 1414 | const wfUrl = sourceItem.getNoteInPlainText(); 1415 | if (isWorkFlowyUrl(wfUrl)) { 1416 | const [item, query] = findItemAndSearchQueryForWorkFlowyUrl(wfUrl); 1417 | if (item) { 1418 | bookmarksToItemTargets.set(bookmarkName, new ItemTarget(item, query)); 1419 | if (!itemIdsToFirstBookmarks.has(item.getId())) { 1420 | itemIdsToFirstBookmarks.set(item.getId(), bookmarkName); 1421 | } 1422 | } else { 1423 | WF.showMessage(`No item found for URL ${wfUrl}, re bookmark "${bookmarkName}".`); 1424 | } 1425 | } else { 1426 | WF.showMessage(`"${wfUrl}" is not a valid WorkFlowy URL, re bookmark "${bookmarkName}".`); 1427 | } 1428 | }); 1429 | } 1430 | 1431 | /** 1432 | * @returns {Array} Recently edited items, most recent first. 1433 | * @param {number} earliestModifiedSec Items edited before this are excluded. 1434 | * @param {number} maxSize The results array will be at most this size. 1435 | * @param {Item} searchRoot The root item of the search. 1436 | */ 1437 | function findRecentlyEditedItems(earliestModifiedSec, maxSize, searchRoot) { 1438 | const scoreFn = itemToLastModifiedSec; // Higher timestamp is a higher score 1439 | return findTopItemsByScore(scoreFn, earliestModifiedSec, maxSize, searchRoot); 1440 | } 1441 | 1442 | class ItemMove { 1443 | constructor(itemToMove, targetItem) { 1444 | this.itemToMove = itemToMove; 1445 | this.targetItem = targetItem; 1446 | } 1447 | } 1448 | 1449 | /** 1450 | * @param {ItemMove} itemMove The move to validate. 1451 | * @returns {string} An error message if failed, or null if succeeded. 1452 | */ 1453 | function validateMove(itemMove) { 1454 | const itemToMove = itemMove.itemToMove; 1455 | const targetItem = itemMove.targetItem; 1456 | if (!isSafeToMoveItemToTarget(itemToMove, targetItem)) { 1457 | return `Cannot move ${formatItem(itemToMove)} to ${formatItem(targetItem)}.`; 1458 | } 1459 | return null; 1460 | } 1461 | 1462 | /** 1463 | * @param {Array} itemMoves The moves to validate. 1464 | * @returns {string} An error message if failed, or null if succeeded. 1465 | */ 1466 | function validateMoves(itemMoves) { 1467 | for (const itemMove of itemMoves) { 1468 | const failureMessage = validateMove(itemMove); 1469 | if (failureMessage) { 1470 | return failureMessage; 1471 | } 1472 | } 1473 | return null; 1474 | } 1475 | 1476 | /** 1477 | * @param {Array} itemMoves The moves to perform. 1478 | * @returns {string?} An error message if failed, or null if succeeded. 1479 | */ 1480 | function performMoves(itemMoves) { 1481 | let whyUnsafe = validateMoves(itemMoves); 1482 | let itemsMovedAlready = 0; 1483 | if (whyUnsafe) { 1484 | return whyUnsafe; 1485 | } 1486 | // Re-do move safety checks, as the structure can changes as we go 1487 | for (const itemMove of itemMoves) { 1488 | const itemToMove = itemMove.itemToMove; 1489 | const targetItem = itemMove.targetItem; 1490 | if (!isSafeToMoveItemToTarget(itemToMove, targetItem)) { 1491 | const prefix = itemsMovedAlready ? `Partial failure: ${itemsMovedAlready} item(s) moved already, but: ` : ""; 1492 | return `${prefix}Cannot move ${formatItem(itemToMove)} to ${formatItem(targetItem)}.`; 1493 | } 1494 | WF.moveItems([itemToMove], targetItem, 0); 1495 | itemsMovedAlready++; 1496 | } 1497 | return null; 1498 | } 1499 | 1500 | /** 1501 | * @param {Array} itemMoves The moves to make. 1502 | * @param {boolean} shouldConfirm Whether to prompt the user to confirm. 1503 | * @param {function} toRunAfterSuccessInEditGroup Function to call after successful completion in same edit group, 1504 | * of type () -> void. 1505 | * @returns {void} 1506 | * @throws {AbortActionError} If a failure occurs 1507 | */ 1508 | function moveInEditGroupOrFail(itemMoves, shouldConfirm = false, toRunAfterSuccessInEditGroup = null) { 1509 | const toMoveCount = itemMoves.length; 1510 | if (toMoveCount === 0) { 1511 | WF.showMessage("No moves required."); 1512 | if (toRunAfterSuccessInEditGroup) { 1513 | WF.editGroup(() => toRunAfterSuccessInEditGroup()); 1514 | } 1515 | return; 1516 | } 1517 | const prompt = `Move ${toMoveCount} item(s)?`; 1518 | failIf(shouldConfirm && !confirm(prompt), "Moves cancelled by user"); 1519 | let errorMessage; 1520 | WF.editGroup(() => { 1521 | errorMessage = performMoves(itemMoves); 1522 | if (!errorMessage && toRunAfterSuccessInEditGroup) { 1523 | toRunAfterSuccessInEditGroup(); 1524 | } 1525 | }); 1526 | failIf(errorMessage, errorMessage); 1527 | WF.showMessage(`Moved ${toMoveCount} item(s).`); 1528 | } 1529 | 1530 | /** @type DatesModule */ 1531 | const datesModule = (function() { 1532 | const domParser = new DOMParser(); 1533 | 1534 | class DateEntry { 1535 | /** 1536 | * Year month and day. 1537 | * @param {string?} startYear The year attribute or null 1538 | * @param {string?} startMonth The month attribute or null 1539 | * @param {string?} startDay The day attribute or null 1540 | * @param {string} innerHTML The inner HTML visible in the name/note 1541 | */ 1542 | constructor(startYear, startMonth, startDay, innerHTML) { 1543 | this.startYear = startYear; 1544 | this.startMonth = startMonth; 1545 | this.startDay = startDay; 1546 | this.innerHTML = innerHTML; 1547 | this.name = "DateEntry"; 1548 | } 1549 | } 1550 | 1551 | const STANDARD_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 1552 | const STANDARD_DAY_NAMES_FROM_SUNDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 1553 | const FULL_DAY_NAMES_FROM_SUNDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 1554 | 1555 | const DAY_TOKEN_ARRAYS_FROM_SUNDAY = [ 1556 | ["su", "sun", "sunday"], 1557 | ["m", "mo", "mon", "monday"], 1558 | ["tu", "tue", "tues", "tuesday"], 1559 | ["w", "we", "wed", "wednesday"], 1560 | ["th", "thu", "thursday"], 1561 | ["f", "fr", "fri", "friday"], 1562 | ["sa", "sat", "saturday"] 1563 | ]; 1564 | const TODAY_TOKENS = ["td", "tod", "tdy", "today"]; 1565 | const TOMORROW_TOKENS = ["tm", "tw", "tmw", "tom", "tmrw", "tomorrow"]; 1566 | const YESTERDAY_TOKENS = ["y", "ye", "ys", "yes", "yest", "yst", "yd", "yesterday"]; 1567 | const EPOCH_TOKENS = ["ep", "epoch"]; 1568 | const END_TIME_TOKENS = ["et", "eot", "end"]; 1569 | 1570 | // Only use for simple alphanumeric strings 1571 | function asCapturingGroup(arrayOfStrings) { 1572 | return `(${arrayOfStrings.join("|")})`; 1573 | } 1574 | 1575 | const ALL_DAY_NAMES_CAPTURING_GROUP = asCapturingGroup(DAY_TOKEN_ARRAYS_FROM_SUNDAY.flat()); 1576 | 1577 | const NAME_WEEK_TOKENS = ["w", "we", "wk", "week"]; 1578 | const NAME_LAST_TOKENS = ["l", "la", "lst", "last"]; 1579 | 1580 | const onePatternWithSpaces = p => new RegExp(`^ *${p} *$`, "i"); 1581 | const twoPatternsWithSpaces = (a, b) => new RegExp(`^ *${a} +${b} *$`, "i"); 1582 | 1583 | function getOrdinalOrCardinalDayOfMonthAsCapturingGroup() { 1584 | const cardinals = Array.from(Array(31).keys()).map(n => (n + 1).toString()); 1585 | const ordinals = cardinals.map(s => `${s}th`); 1586 | const offset = 1; 1587 | ordinals[1 - offset] = "1st"; 1588 | ordinals[2 - offset] = "2nd"; 1589 | ordinals[3 - offset] = "3rd"; 1590 | ordinals[21 - offset] = "21st"; 1591 | ordinals[22 - offset] = "22nd"; 1592 | ordinals[23 - offset] = "23rd"; 1593 | ordinals[31 - offset] = "31st"; 1594 | const combined = ordinals.concat(cardinals); 1595 | return `${asCapturingGroup(combined)}`; 1596 | } 1597 | 1598 | class DateInterpretation { 1599 | /** 1600 | * @param {Date} date Date object form 1601 | * @param {string} description The description of the interpreted date, 1602 | * e.g. "Monday week, 8 days away" 1603 | */ 1604 | constructor(date, description) { 1605 | this.date = date; 1606 | this.description = description; 1607 | } 1608 | } 1609 | 1610 | /** 1611 | * @param {string} givenNamedDay The token representing a named day 1612 | * @returns {number} 0 for Sunday, ..., 6 for Saturday, -1 if not found 1613 | */ 1614 | function nameTokenToDayNumber(givenNamedDay) { 1615 | for (var dayNumber = 0; dayNumber < 7; dayNumber++) { 1616 | if (DAY_TOKEN_ARRAYS_FROM_SUNDAY[dayNumber].includes(givenNamedDay.toLowerCase())) { 1617 | return dayNumber; 1618 | } 1619 | } 1620 | return -1; 1621 | } 1622 | 1623 | /** 1624 | * @param {Date} referenceDate The "today" date to use as a reference 1625 | * @param {number} dayNumber The day number to find (Sunday is 0) 1626 | * @returns {number} The number of days until the next occurrence of the 1627 | * given day number after the reference 1628 | */ 1629 | function daysFromReferenceUntilComingDayNumber(referenceDate, dayNumber) { 1630 | const refDayNumber = referenceDate.getDay(); 1631 | return dayNumber === refDayNumber ? 7 : (dayNumber + 7 - refDayNumber) % 7; 1632 | } 1633 | 1634 | /** 1635 | * @param {Date} referenceDate The "today" date to use as a reference 1636 | * @param {number} dayNumber The day number to find (Sunday is 0) 1637 | * @returns {number} The number of days since the most recent previous 1638 | * occurrence of the given day number before the reference 1639 | */ 1640 | function daysFromRecentDayNumberUntilReference(referenceDate, dayNumber) { 1641 | const refDayNumber = referenceDate.getDay(); 1642 | return dayNumber === refDayNumber ? 7 : (refDayNumber + 7 - dayNumber) % 7; 1643 | } 1644 | 1645 | /** 1646 | * @param {string} s The date to interpret 1647 | * @param {Date} referenceDate The "today" date to use as a reference 1648 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1649 | */ 1650 | function interpretAsYesterday(s, referenceDate) { 1651 | if (s.match(onePatternWithSpaces(asCapturingGroup(YESTERDAY_TOKENS)))) { 1652 | return [new DateInterpretation(getNoonDateNDaysAway(-1, referenceDate), "Yesterday"), null]; 1653 | } else return [null, null]; 1654 | } 1655 | 1656 | /** 1657 | * @param {string} s The date to interpret 1658 | * @param {Date} referenceDate The "today" date to use as a reference 1659 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1660 | */ 1661 | function interpretAsToday(s, referenceDate) { 1662 | if (s.match(onePatternWithSpaces(asCapturingGroup(TODAY_TOKENS)))) { 1663 | return [new DateInterpretation(getNoonDateNDaysAway(0, referenceDate), "Today"), null]; 1664 | } else return [null, null]; 1665 | } 1666 | 1667 | /** 1668 | * @param {string} s The date to interpret 1669 | * @param {Date} referenceDate The "today" date to use as a reference 1670 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1671 | */ 1672 | function interpretAsTodayWeek(s, referenceDate) { 1673 | if (s.match(twoPatternsWithSpaces(asCapturingGroup(TODAY_TOKENS), asCapturingGroup(NAME_WEEK_TOKENS)))) { 1674 | return [new DateInterpretation(getNoonDateNDaysAway(7, referenceDate), "A week from today, 7 days away"), null]; 1675 | } else return [null, null]; 1676 | } 1677 | 1678 | /** 1679 | * @param {string} s The date to interpret 1680 | * @param {Date} referenceDate The "today" date to use as a reference 1681 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1682 | */ 1683 | function interpretAsTomorrow(s, referenceDate) { 1684 | if (s.match(onePatternWithSpaces(asCapturingGroup(TOMORROW_TOKENS)))) { 1685 | return [new DateInterpretation(getNoonDateNDaysAway(1, referenceDate), "Tomorrow"), null]; 1686 | } else return [null, null]; 1687 | } 1688 | 1689 | /** 1690 | * @param {string} s The date to interpret 1691 | * @param {Date} referenceDate The "today" date to use as a reference 1692 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1693 | */ 1694 | function interpretAsTomorrowWeek(s, referenceDate) { 1695 | if (s.match(twoPatternsWithSpaces(asCapturingGroup(TOMORROW_TOKENS), asCapturingGroup(NAME_WEEK_TOKENS)))) { 1696 | const description = "A week from tomorrow, 8 days away"; 1697 | return [new DateInterpretation(getNoonDateNDaysAway(8, referenceDate), description), null]; 1698 | } else return [null, null]; 1699 | } 1700 | 1701 | /** 1702 | * @param {string} s The date to interpret 1703 | * @param {Date} referenceDate The "today" date to use as a reference 1704 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1705 | */ 1706 | function interpretAsMostRecentNamedDay(s, referenceDate) { 1707 | const result = s.match(twoPatternsWithSpaces(ALL_DAY_NAMES_CAPTURING_GROUP, asCapturingGroup(NAME_LAST_TOKENS))); 1708 | if (result) { 1709 | const givenNamedDay = result[1]; 1710 | const dayNumber = nameTokenToDayNumber(givenNamedDay); 1711 | const daysPrior = daysFromRecentDayNumberUntilReference(referenceDate, dayNumber); 1712 | return [ 1713 | new DateInterpretation( 1714 | getNoonDateNDaysAway(-daysPrior, referenceDate), 1715 | `${FULL_DAY_NAMES_FROM_SUNDAY[dayNumber]} gone, ${daysPrior} days prior` 1716 | ), 1717 | null 1718 | ]; 1719 | } else return [null, null]; 1720 | } 1721 | 1722 | /** 1723 | * @param {string} s The date to interpret 1724 | * @param {Date} referenceDate The "today" date to use as a reference 1725 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1726 | */ 1727 | function interpretAsComingNamedDay(s, referenceDate) { 1728 | const result = s.match(onePatternWithSpaces(ALL_DAY_NAMES_CAPTURING_GROUP)); 1729 | if (result) { 1730 | const givenNamedDay = result[1]; 1731 | const dayNumber = nameTokenToDayNumber(givenNamedDay); 1732 | const daysAway = daysFromReferenceUntilComingDayNumber(referenceDate, dayNumber); 1733 | return [ 1734 | new DateInterpretation( 1735 | getNoonDateNDaysAway(daysAway, referenceDate), 1736 | `${FULL_DAY_NAMES_FROM_SUNDAY[dayNumber]}, ${daysAway} days away` 1737 | ), 1738 | null 1739 | ]; 1740 | } else return [null, null]; 1741 | } 1742 | 1743 | /** 1744 | * @param {string} s The date to interpret 1745 | * @param {Date} referenceDate The "today" date to use as a reference 1746 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1747 | */ 1748 | function interpretAsComingNamedDayPlusWeek(s, referenceDate) { 1749 | const result = s.match(twoPatternsWithSpaces(ALL_DAY_NAMES_CAPTURING_GROUP, asCapturingGroup(NAME_WEEK_TOKENS))); 1750 | if (result) { 1751 | const givenNamedDay = result[1]; 1752 | const dayNumber = nameTokenToDayNumber(givenNamedDay); 1753 | const daysAway = 7 + daysFromReferenceUntilComingDayNumber(referenceDate, dayNumber); 1754 | return [ 1755 | new DateInterpretation( 1756 | getNoonDateNDaysAway(daysAway, referenceDate), 1757 | `${FULL_DAY_NAMES_FROM_SUNDAY[dayNumber]} week, ${daysAway} days away` 1758 | ), 1759 | null 1760 | ]; 1761 | } else return [null, null]; 1762 | } 1763 | 1764 | /** 1765 | * @param {string} s The date to interpret 1766 | * @param {Date} referenceDate The "today" date to use as a reference 1767 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1768 | */ 1769 | function interpretAsDayAndMonthEitherOrder(s, referenceDate) { 1770 | const monthsCapturingGroup = asCapturingGroup(STANDARD_MONTH_NAMES); 1771 | const dayGroup = getOrdinalOrCardinalDayOfMonthAsCapturingGroup(); 1772 | var dayOfMonthString; 1773 | var monthName; 1774 | 1775 | var dayThenMonthResult = s.match(twoPatternsWithSpaces(dayGroup, monthsCapturingGroup)); 1776 | var monthThenDayResult = s.match(twoPatternsWithSpaces(monthsCapturingGroup, dayGroup)); 1777 | if (dayThenMonthResult) { 1778 | dayOfMonthString = dayThenMonthResult[1]; 1779 | monthName = dayThenMonthResult[2]; 1780 | } else if (monthThenDayResult) { 1781 | monthName = monthThenDayResult[1]; 1782 | dayOfMonthString = monthThenDayResult[2]; 1783 | } else { 1784 | return [null, null]; 1785 | } 1786 | 1787 | const simpleNumberString = dayOfMonthString.replace(/a-z/gi, ""); 1788 | const simpleNumber = parseInt(simpleNumberString); 1789 | const monthIndex = STANDARD_MONTH_NAMES.map(s => s.toLowerCase()).indexOf(monthName.toLowerCase()); 1790 | const date = new Date(referenceDate.getFullYear(), monthIndex, simpleNumber, 12, 0, 0); 1791 | const description = `${simpleNumberString} ${STANDARD_MONTH_NAMES[monthIndex]}`; 1792 | if (date.getTime() <= referenceDate.getTime()) { 1793 | return [null, "Month and day formats only supported for clearly future dates. To fix: specify the year."]; 1794 | } 1795 | return [new DateInterpretation(date, description), null]; 1796 | } 1797 | 1798 | /** 1799 | * @param {string} s The date to interpret 1800 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1801 | */ 1802 | function interpretAsEpoch(s) { 1803 | if (s.match(onePatternWithSpaces(asCapturingGroup(EPOCH_TOKENS)))) { 1804 | return [new DateInterpretation(new Date(0), "Epoch"), null]; 1805 | } else return [null, null]; 1806 | } 1807 | 1808 | /** 1809 | * @param {string} s The date to interpret 1810 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1811 | */ 1812 | function interpretAsEndOfTime(s) { 1813 | if (s.match(onePatternWithSpaces(asCapturingGroup(END_TIME_TOKENS)))) { 1814 | const endOfTime = new Date(9999, 11, 31, 23, 59, 59); // Pessimistic 1815 | return [new DateInterpretation(endOfTime, "End of time"), null]; 1816 | } else return [null, null]; 1817 | } 1818 | 1819 | /** 1820 | * @param {string} s The date to interpret 1821 | * @param {Date} referenceDate The "today" date to use as a reference 1822 | * @returns {[DateInterpretation?, string?]} Date interpretation or error message, or neither if no match 1823 | */ 1824 | function interpretAsNDaysOrWeeksAway(s, referenceDate) { 1825 | const cardinalNumberGroup = "([1-9][0-9]*)"; 1826 | const dayOrWeekSuffixGroup = "(d|w)"; 1827 | const result = s.match(onePatternWithSpaces(`${cardinalNumberGroup}${dayOrWeekSuffixGroup}`)); 1828 | if (result) { 1829 | const numberString = result[1]; 1830 | const number = parseInt(numberString); 1831 | const dayOrWeekString = result[2]; 1832 | const isWeeks = dayOrWeekString.toLowerCase() === "w"; 1833 | const date = getNoonDateNDaysAway(number * (isWeeks ? 7 : 1), referenceDate); 1834 | const description = `${formatDateWithoutTime(date)}, ${number} ${isWeeks ? "week" : "day"}(s) away`; 1835 | return [new DateInterpretation(date, description), null]; 1836 | } else return [null, null]; 1837 | } 1838 | 1839 | /** 1840 | * Attempts to parse the given date string, returning a corresponding 1841 | * Date object at noon (12pm) or an error message. 1842 | * @param {string} s The string to parse 1843 | * @param {Date} referenceDate The "today" date to use as a reference 1844 | * @return {[DateInterpretation?, string?]} A tuple with exactly one of a 1845 | * DateInterpretation, or an error explaining why it couldn't be recognized. 1846 | * The other value will be null. 1847 | */ 1848 | function interpretDate(s, referenceDate) { 1849 | // Note: optimise for flexibility and maintainability, not runtime speed. 1850 | 1851 | const interpretations = []; 1852 | const errors = []; 1853 | const addIfMatch = ([interpretation, error]) => { 1854 | if (interpretation) { 1855 | interpretations.push(interpretation); 1856 | } 1857 | if (error) { 1858 | errors.push(error); 1859 | } 1860 | }; 1861 | addIfMatch(interpretAsYesterday(s, referenceDate)); 1862 | addIfMatch(interpretAsToday(s, referenceDate)); 1863 | addIfMatch(interpretAsTodayWeek(s, referenceDate)); 1864 | addIfMatch(interpretAsTomorrow(s, referenceDate)); 1865 | addIfMatch(interpretAsTomorrowWeek(s, referenceDate)); 1866 | addIfMatch(interpretAsComingNamedDay(s, referenceDate)); 1867 | addIfMatch(interpretAsComingNamedDayPlusWeek(s, referenceDate)); 1868 | addIfMatch(interpretAsMostRecentNamedDay(s, referenceDate)); 1869 | addIfMatch(interpretAsDayAndMonthEitherOrder(s, referenceDate)); 1870 | addIfMatch(interpretAsEpoch(s)); 1871 | addIfMatch(interpretAsEndOfTime(s)); 1872 | addIfMatch(interpretAsNDaysOrWeeksAway(s, referenceDate)); 1873 | 1874 | if (interpretations.length === 1 && errors.length === 0) { 1875 | return [interpretations[0], null]; 1876 | } else if (errors.length !== 0) { 1877 | return [null, errors[0]]; 1878 | } else if (interpretations.length === 0) { 1879 | return [null, `"${s}" was not recognized as a date`]; 1880 | } else if (interpretations.length > 1) { 1881 | return [null, `"${s}" was recognized in too many ways: ${interpretations.map(i => i.description).join(", ")}`]; 1882 | } 1883 | } 1884 | 1885 | /** 1886 | * @param {Date} date The date object to format in WorkFlowy's default style 1887 | * @returns {string} The date as a string 1888 | */ 1889 | function formatDateWithoutTime(date) { 1890 | // E.g. Sat, Feb 29, 2020 1891 | const year = date.getFullYear().toString(); 1892 | const dayNumber = date.getDate().toString(); 1893 | const monthName = STANDARD_MONTH_NAMES[date.getMonth()]; 1894 | const dayName = STANDARD_DAY_NAMES_FROM_SUNDAY[date.getDay()]; 1895 | return `${dayName}, ${monthName} ${dayNumber}, ${year}`; 1896 | } 1897 | 1898 | /** 1899 | * Sets the time of the given date to noon (12pm). 1900 | * @param {Date} date The date to set 1901 | * @returns {void} 1902 | */ 1903 | function setToNoon(date) { 1904 | date.setHours(12); 1905 | date.setMinutes(0); 1906 | date.setSeconds(0); 1907 | } 1908 | 1909 | /** 1910 | * Returns a Date object the given number of days from the reference date, 1911 | * at noon (12pm) 1912 | * @param {number} daysFromReference The number of days from today 1913 | * @param {Date} referenceDate The "today" date to use as a reference 1914 | * @returns {Date} A future Date 1915 | */ 1916 | function getNoonDateNDaysAway(daysFromReference, referenceDate) { 1917 | const date = referenceDate ? new Date(referenceDate) : new Date(); 1918 | // This is to reduce likelihood of out by one errors re leap seconds 1919 | setToNoon(date); 1920 | const epochMillis = date.getTime(); 1921 | const newEpochMillis = epochMillis + 1000 * 60 * 60 * 24 * daysFromReference; 1922 | date.setTime(newEpochMillis); 1923 | setToNoon(date); 1924 | return date; 1925 | } 1926 | 1927 | /** 1928 | * Converts the given date to a DateEntry, with year/month/date and 1929 | * innerHTML set. 1930 | * @param {Date} date The date object to convert 1931 | * @return {DateEntry} The converted date entry 1932 | */ 1933 | function dateToDateEntry(date) { 1934 | return new DateEntry( 1935 | date.getFullYear().toString(), 1936 | (date.getMonth() + 1).toString(), 1937 | date.getDate().toString(), 1938 | formatDateWithoutTime(date) 1939 | ); 1940 | } 1941 | 1942 | const TIME_ATTR_STARTYEAR = "startYear"; 1943 | const TIME_ATTR_STARTMONTH = "startMonth"; 1944 | const TIME_ATTR_STARTDAY = "startDay"; 1945 | 1946 | /** 1947 | * Converts the given