Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Batch editing for Search & Replace #59

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ It's available in their respective extension stores for both [Chrome](https://ch

clozach marked this conversation as resolved.
Show resolved Hide resolved
![](./media/fuzzy_date.gif)
1. Date increment/decrement
- Shortcuts for ±1 day and ±7 days
- If there is only 1 date in the block - place the cursor anywhere withing it and press `Ctrl-Alt-Up/Down`, if there is more then 1 date - you need to place the cursor within the name of the date.
1. Spaced repetition
* Anki SRS algorithm & Shortcuts
* Leitner System automation shortcuts
1. Block actions: Delete, Duplicate
1. Single-block actions: Duplicate, Delete, Copy Block Reference, and Copy Block Embed
1. Batch-block actions: Batch Link a word or phrase, and add/remove "end" tags
1. Task estimates
1. Custom CSS

1. Navigation Hotkeys: Go to Today, Go to Tomorrow, and Go to Yesterday

## Running the development version

1. Checkout the repository
2. Revert the https://github.com/roam-unofficial/roam-toolkit/commit/20ad9560b7cfaf71adf65dbc3645b3554c2ab598 change locally to allow Toolkit to properly run in the development mode
2. Revert the https://github.com/roam-unofficial/roam-toolkit/commit/20ad9560b7cfaf71adf65dbc3645b3554c2ab598 change locally to allow Toolkit to properly run in the development mode. **← Do not commit the resulting change as it will break the release build!**

### In terminal or command prompt

Expand Down
115 changes: 115 additions & 0 deletions src/ts/features/batch-editing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {Feature, Shortcut} from '../utils/settings'
import {Roam} from '../roam/roam'
import {getHighlightedBlocks} from '../utils/dom'
import {forEachAsync, delay} from '../utils/async'

export const config: Feature = {
id: 'editing',
clozach marked this conversation as resolved.
Show resolved Hide resolved
name: 'Batch Editing',
settings: [
{
type: 'shortcut',
id: 'batchLinking',
label: 'Apply [[link]] brackets to a word in every highlighted block\n\n(whole-words only)',
initValue: 'Meta+shift+l',
onPress: () => batchLinking(),
} as Shortcut,
{
type: 'shortcut',
id: 'batchAppendTag',
label: 'Append #yourtag to every highlighted block',
initValue: 'Ctrl+shift+t',
onPress: () => appendTag(),
} as Shortcut,
{
type: 'shortcut',
id: 'removeLastEndTag',
label: 'Remove the last #tag at the end of each highlighted block',
initValue: 'Ctrl+shift+meta+t',
onPress: () => removeLastTag(),
} as Shortcut,
{
type: 'shortcut',
id: 'regexSearchAndReplace',
label:
'Roll your own complex search and replace by providing a search string or regex plus a replacement string',
initValue: 'Ctrl+shift+f',
clozach marked this conversation as resolved.
Show resolved Hide resolved
onPress: () => regexSearchAndReplace(),
} as Shortcut,
],
}

const batchLinking = () => {
const text = prompt('What (whole) word do you want to convert into bracketed links?')
if (!text || text === '') return
clozach marked this conversation as resolved.
Show resolved Hide resolved

const warning = `Replace all visible occurrences of "${text}" in the highlighted blocks with "[[${text}]]"?

🛑 This operation CANNOT BE UNDONE!`

if (!confirm(warning)) return

withHighlightedBlocks(originalString => {
// Replace whole words only, ignoring already-[[linked]] matches.
// http://www.rexegg.com/regex-best-trick.html#javascriptcode
const regex = new RegExp(`\\[\\[${text}]]|(\\b${text}\\b)`, 'g')
return originalString.replace(regex, function (m, group1) {
if (!group1) return m
else return `[[${m}]]`
})
Comment on lines +55 to +59
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be made simpler by using regex like [^\\[]\\b${text}\\b[^\\]] and then just replacing all matches

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this…

    const regex = new RegExp(`[^\\[]\\b${text}\\b[^\\]]`, 'g')
    return originalString.replace(regex, `[[${text}]]`)

…and it doesn't work:

Create a Bracket Link.

becomes

Create a[[Bracket Link]]

¯_(ツ)_/¯

})
}

const appendTag = () => {
const text = prompt('What "string" do you want to append as "#string"?')
if (!text || text === '') return

withHighlightedBlocks(originalString => {
if (text.includes(' ')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are a bunch of other things just # does not work with. I think it'd be simpler to just always add brackets

return `${originalString} #[[${text}]]`
} else {
return `${originalString} #${text}`
}
})
}

const removeLastTag = () => {
if (!confirm('Remove the end tag from every highlighted block?'))
return

withHighlightedBlocks(originalString => {
const regex = new RegExp(`(.*) (#.*)`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not seem like it would handle newlines?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also if you don't need to use template string/etc the /(.*) (#.*)/ syntax is a bit nicer

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think just doing something like "find last # " and take a substring before that as a result may be simpler here vs using regex

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good potential catch. I tested it out and it actually seems to work fine with newlines, assuming you mean the sort that allow you to add whitespace within a single block (Shift-return on my Mac).

Good point re: template string. I've replaced it with an inlined regex literal. :)

return originalString.replace(regex, '$1')
})
}

const regexSearchAndReplace = () => {
const userRegex = prompt('Enter a search string or regex to find in each selected block')
if (!userRegex || userRegex === '') return

const replacement = prompt('Enter a replacement string (can include $&, $1, and other group matchers)')
if (!replacement || replacement === '') return

withHighlightedBlocks(originalString => {
const regex = new RegExp(userRegex, 'g')
return originalString.replace(regex, replacement)
})
}

const withHighlightedBlocks = (mod: { (orig: string): string }) => {
const highlighted = getHighlightedBlocks()

const contentBlocks = Array.from(highlighted.contentBlocks)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shoul getHighlightedBlocks return array straight away?

forEachAsync(contentBlocks, async element => {
await Roam.replace(element, mod)
})

// Preserve selection
const parentBlocks = Array.from(highlighted.parentBlocks)
forEachAsync(parentBlocks, async element => {
// Wait for dom to settle before re-applying highlight style
await delay(100)
await element.classList.add('block-highlight-blue')
})
}

2 changes: 2 additions & 0 deletions src/ts/features/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {config as incDec} from './inc-dec-value'
import {config as customCss} from './custom-css'
import {config as srs} from '../srs/srs'
import {config as blockManipulation} from './block-manipulation'
import {config as batchEditing} from './batch-editing'
import {config as estimate} from './estimates'
import {config as navigation} from './navigation'
import {filterAsync, mapAsync} from '../utils/async'
Expand All @@ -13,6 +14,7 @@ export const Features = {
incDec, // prettier
srs,
blockManipulation,
batchEditing,
estimate,
customCss,
navigation,
Expand Down
21 changes: 21 additions & 0 deletions src/ts/roam/roam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {RoamNode, Selection} from './roam-node'
import {getActiveEditElement, getFirstTopLevelBlock, getInputEvent, getLastTopLevelBlock} from '../utils/dom'
import {Keyboard} from '../utils/keyboard'
import {Mouse} from '../utils/mouse'
import {delay} from '../utils/async'

export const Roam = {
save(roamNode: RoamNode) {
Expand Down Expand Up @@ -49,6 +50,12 @@ export const Roam = {
async activateBlock(element: HTMLElement) {
if (element.classList.contains('roam-block')) {
await Mouse.leftClick(element)

// Prevent race condition when attempting to use the
// resulting block before Roam has had a chance to
// replace the <span> with a <textarea>. Without this,
// Batch operations often skip some blocks.
await delay(100)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftClick has additional delay parameter

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also is 100 an absolute minimum we can live with?

}
return this.getRoamBlockInput()
},
Expand Down Expand Up @@ -77,6 +84,20 @@ export const Roam = {
this.applyToCurrent(node => node.withCursorAtTheEnd())
},

async replace(element: HTMLElement, mod: { (orig: string): string }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this is basically apply but for HtmlElement instead of current block, I feel like they should follow similar interface (i.e. in terms of RoamNode)

const textarea = await Roam.activateBlock(element) as HTMLTextAreaElement
if (!textarea) {
console.log("🚨 NO TEXTAREA returned from ", element)
return
}

const newText = mod(textarea.value)

Roam.save(new RoamNode(newText))
Comment on lines +89 to +96
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is the apply function that does basically this

Keyboard.pressEsc()
Keyboard.pressEsc()
Comment on lines +97 to +98
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 2?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, I'm not sure if this should be responsibility of this function

},

writeText(text: string) {
this.applyToCurrent(node => new RoamNode(text, node.selection))
return this.getActiveRoamNode()?.text === text
Expand Down
10 changes: 10 additions & 0 deletions src/ts/utils/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ export async function filterAsync<T>(
const filterMap = await mapAsync(array, callbackfn)
return array.filter((_value, index) => filterMap[index])
}

export async function forEachAsync<T>(
array: T[],
callbackfn: (value: T, ...args: any[]) => void
) {
for await (const element of array) {
await callbackfn(element, arguments)
}
}

12 changes: 12 additions & 0 deletions src/ts/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export function getFirstTopLevelBlock() {
return firstChild.querySelector('.roam-block, textarea') as HTMLElement
}

export function getHighlightedBlocks(): { parentBlocks: NodeListOf<HTMLElement>, contentBlocks: NodeListOf<HTMLElement> } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably be 2 separate functions


const highlightedParentBlocks = document.querySelectorAll('.block-highlight-blue') as NodeListOf<HTMLElement>

const highlightedContentBlocks = document.querySelectorAll('.block-highlight-blue .roam-block') as NodeListOf<HTMLElement>

return {
parentBlocks: highlightedParentBlocks,
contentBlocks: highlightedContentBlocks
}
}

export function getInputEvent() {
return new Event('input', {
bubbles: true,
Expand Down