// ==UserScript== // @name WorkFlowy - Find & Replace [ARCHIVED] // @description Adds a Find & Replace button to the search input. // @author rawbytz and Gavin Elster // @version 2025.03.08 // @license MIT // // @namespace https://github.com/elstgav // @homepageURL https://github.com/elstgav/workflowy // @supportURL https://github.com/elstgav/workflowy/issues // // @downloadURL https://raw.githubusercontent.com/elstgav/workflowy/main/dist/scripts/archive/find-and-replace/workflowy.find-and-replace.user.js // @updateURL https://raw.githubusercontent.com/elstgav/workflowy/main/dist/scripts/archive/find-and-replace/workflowy.find-and-replace.user.js // // @match https://workflowy.com/* // // @grant none // @run-at document-end // ==/UserScript== //#region src/scripts/archive/find-and-replace/find-and-replace.ts let searchInput const findAndReplace = () => { const toastMsg = (str, sec = 2, err = false) => { WF.showMessage(str, err) setTimeout(() => WF.hideMessage(), sec * 1e3) } const applyToEachItem = (functionToApply, parent) => { functionToApply(parent) for (const child of parent.getChildren()) applyToEachItem(functionToApply, child) } const findMatchingItems = (itemPredicate, parent) => { const matches = [] const addIfMatch = (item) => { if (itemPredicate(item)) matches.push(item) } applyToEachItem(addIfMatch, parent) return matches } const editableItemWithVisibleMatch = (item) => { const isVisible = WF.completedVisible() || !item.isWithinCompleted() return Boolean( item.data?.search_result && item.data.search_result.matches && isVisible && !item.isReadOnly(), ) } const escapeForRegExp = (str) => str.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&') const countMatches = (items, rgx) => { let matchCount = 0 items.forEach((item) => { const result = item.data?.search_result if (!result) return if (result.nameMatches) { const nameMatch = item.getName().match(rgx) if (nameMatch) matchCount += nameMatch.length } if (result.noteMatches) { const noteMatch = item.getNote().match(rgx) if (noteMatch) matchCount += noteMatch.length } }) return matchCount } const htmlEscTextForContent = (str) => str .replace(/&/g, '&') .replace(/>/g, '>') .replace(/ { WF.editGroup(() => { items.forEach((item) => { const result = item.data?.search_result if (!result) return if (result.nameMatches) WF.setItemName(item, item.getName().replace(rgx, htmlEscTextForContent(replacement))) if (result.noteMatches) WF.setItemNote(item, item.getNote().replace(rgx, htmlEscTextForContent(replacement))) }) }) if (replacement === '') { WF.clearSearch() return } WF.search(tQuery.replace(findText, replacement)) } const htmlEscText = (str) => str.replace(/&/g, '&').replace(/>/g, '>').replace(/ { const addButton = (num, name) => `` const boxStyle = `#inputBx{${getColors()}width:95%;height:20px;display:block;margin-top:5px;border:1px solid #ccc;border-radius:4px;padding:4px}` const btnStyle = `.btnX{font-size:18px;background-color:gray;border:2px solid;border-radius:20px;color:#fff;padding:5px 15px;margin-top:16px;margin-right:16px}.btnX:focus,.btnX:hover{border-color:#c4c4c4;background-color:steelblue}` const box = `
Replace:
` const buttons = addButton(1, `Replace: All (${allCount})`) + addButton(2, `Replace: Match Case (${caseCount})`) WF.showAlertDialog( `
${bodyHtml}
${box}
${buttons}
`, title, ) const intervalId = setInterval(() => { let inputBx = document.getElementById('inputBx') if (inputBx instanceof HTMLInputElement) { clearInterval(intervalId) const btn1 = document.getElementById('btn1') const btn2 = document.getElementById('btn2') if (!(btn1 instanceof HTMLButtonElement)) return if (!(btn2 instanceof HTMLButtonElement)) return inputBx.select() inputBx.addEventListener('keyup', (event) => { if (event.key === 'Enter') btn1.click() }) btn1.onclick = () => { const userInput = inputBx.value WF.hideDialog() setTimeout(() => { replaceMatches(matches, rgx_gi, userInput) }, 50) } btn2.onclick = () => { const userInput = inputBx.value WF.hideDialog() setTimeout(() => { replaceMatches(matches, rgx_g, userInput) }, 50) } } }, 50) } const searchQuery = WF.currentSearchQuery() if (!searchQuery) { toastMsg( 'Use the searchbox to find. Click here for more information.', 3, true, ) return } const tQuery = searchQuery.trim() const matches = findMatchingItems(editableItemWithVisibleMatch, WF.currentItem()) const isQuoted = tQuery.match(/(")(.+)(")/) const find = isQuoted ? isQuoted[2] : tQuery.includes(' ') ? null : tQuery if (!find) { if ( confirm( 'The search contains at least one space.\n\n1. Press OK to convert your search to "exact match".\n\n2. Activate Find/Replace again.', ) ) WF.search(`"${tQuery}"`) return } const findText = find const title = 'Find/Replace' const modeTxt = isQuoted ? 'Exact Match, ' : 'Single Word/Tag, ' const compTxt = `Completed: ${WF.completedVisible() ? 'Included' : 'Excluded'}` const findTxt = isQuoted ? isQuoted[0] : tQuery const body = `

Mode:
${modeTxt + compTxt}

Find:
${htmlEscText(findTxt)}

` const findRgx = escapeForRegExp(htmlEscTextForContent(findText)) const rgx_gi = new RegExp(findRgx, 'gi') const rgx_g = new RegExp(findRgx, 'g') const allCount = countMatches(matches, rgx_gi) const caseCount = countMatches(matches, rgx_g) if (allCount > 0) showFindReplaceDialog(body, title, allCount, caseCount, findText) else WF.showAlertDialog(`${body}

No matches found.`, title) } /** * Gavin’s additions: ---------------------------------------------------------------------------- */ const createElementFromHTML = (html) => { const template = document.createElement('template') template.innerHTML = html.trim() const element = template.content.firstChild if (!(element instanceof Element)) throw new Error('Gavin: Unable to create element') return element } const findAndReplaceIcon = createElementFromHTML( ``, ) const style = createElementFromHTML( ``.replace(/\s/, ' '), ) const addFindAndReplaceButton = () => { if (document.querySelectorAll('.gavin-find-and-replace-button').length) return if (!searchInput) return try { const searchWrapper = searchInput.closest('label')?.parentElement if (!(searchWrapper instanceof HTMLDivElement)) throw new Error('Missing search wrapper') const starButton = searchWrapper.querySelector( 'svg[width="20"][height="20"][fill="none"]', )?.parentElement if (!(starButton instanceof HTMLButtonElement)) throw new Error('Missing star button') const buttonsWrapper = starButton.parentElement if (!(buttonsWrapper instanceof HTMLDivElement)) throw new Error('Missing button wrapper') const button = starButton.cloneNode(true) if (!(button instanceof HTMLButtonElement)) throw new Error('Unable to clone button') button.querySelector('svg')?.replaceWith(findAndReplaceIcon) button.onclick = findAndReplace button.title = 'Find & Replace' button.classList.add('gavin-find-and-replace-button') buttonsWrapper.firstChild?.before(button) } catch (error) { console.error('Gavin: Unable to add find and replace button', error) } } const appObserver = new MutationObserver(() => { const nextSearchInput = document.getElementById('srch-input') searchInput = nextSearchInput instanceof HTMLInputElement ? nextSearchInput : null if (!searchInput?.value) return appObserver.disconnect() requestAnimationFrame(addFindAndReplaceButton) }) document.head.appendChild(style) appObserver.observe(document.body, { subtree: true, childList: true, }) //#endregion