// ==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