Skip to content

Instantly share code, notes, and snippets.

@tereshhhchenko
Last active October 6, 2020 14:23
Show Gist options
  • Save tereshhhchenko/a238aac2b71dcc7e9cb8640b62a8f765 to your computer and use it in GitHub Desktop.
Save tereshhhchenko/a238aac2b71dcc7e9cb8640b62a8f765 to your computer and use it in GitHub Desktop.
Programmatically replace title text in Notion Tasks in Calendar View

Update 6 Oct 2020: Changed filtering buttons logic to work properly for tasks without emoji, but with attributes in preview (for example, with time).

Problem – Notion adds 'Copy of' to copied tasks title

I have several repeting tasks in Notion Calendar. Such as zero-inbox and other routine stuff that I need to do every once in a while. Notion doesn't have a feature like Google Calendar that would enable us to create those tasks as re-occuring events. I end up copying tasks for the day and moving them to the next week manually. It works fine for tasks emojis and properties, but every copy has an appended 'Copy of' in it's title. Removing it manually is exhausting and boring. Let's automate it!

Solution

Notion API is not released yet. To automate text replacement in titles I played around with Notion in browser. The resulting script is a bit time consuming, but it is still better than manual editing. Here's how I wrote it.

1. Filter buttons in Calendar view

Notion calendar view screenshot

We need to list all events that start with 'Copy of'. One way to do that is to open Calendar view and filter out buttons. Every button has task's title in HTML, so we can use it. We would need buttons later to open modals and edit titles.

function getButtons(regex) {
    return Array.from(document.querySelectorAll('.notion-calendar-view .notion-collection-item a > div:first-child')).filter(node => {
        const text = node.innerText.split(/\n/)
        return text.some(title => regex.test(title))
    })
}

getButtons(/^Copy of /)

We don't want to touch original tasks, so we filter out buttons for the tasks that start with 'Copy of '. Each node's innerText will start either with emoji (if you set up emoji for the task) or with text. To keep things simple, we would like to test regex against actual title, not emoji. In case emoji exists, it is separated from text with a new line, so we use split here.

// Update 6 Oct 2020 //

We can't simply rely on text.length to find title as it was in previous version. There could be a task without emoji but with time or other attributes listed on new lines after title. So the length is not sufficient for finding index of title in text array. We can test each entry (or line) of text and filter those buttons which contain at least one line that is tested positive.

It has a downside: if some attribute's value is listed in preview (in button's text) and starts with 'Copy of ', we'll end up with filtering more buttons than we might need and the task will take longer. For my case it's not an issue, but if your regex is something other than 'Copy of ', you might want to find a better solution.

//-------------------//

2. Open modal for task editing

To open modal in Notion we need to dispatch 3 events: mousedown, mouseup and click

function openModal(button) {
    button.dispatchEvent(new Event('mousedown', { bubbles: true }))
    button.dispatchEvent(new Event('mouseup', { bubbles: true }))
    button.dispatchEvent(new Event('click', { bubbles: true }))
}

3. Remove 'Copy of'

The title is in contenteditable <div>, so we need to change it's innerHTML and trigger input event.

function removeText(regex) {
    let input = document.querySelector('div.vertical:nth-child(3) > div:nth-child(1) > div:nth-child(1) > div:nth-child(3) > div:nth-child(1)')
    input.innerHTML = input.innerHTML.replace(regex, '')
    input.dispatchEvent(new Event('input', { bubbles: true }))
}

4. Close modal

After we edit one task we need to close modal and click on the next button on our list.

function closeModal() {
    let backdrop = document.querySelector('.notion-peek-renderer > div')
    backdrop.dispatchEvent(new Event('click', { bubbles: true }))
}

5. DOM and Networks are slow, let's wait for them

When we click a button, browser needs some time to fetch task's data and render the modal. So we need to wait for it with setTimeout. I tried to wait for different amount of time, 400ms turns out to be not enough, so I settled on 1 second, which worked fine so far. Feel free to adjust it if you need to.

Another caveat here is that we need to work synchoroniously and edit one task after another. Or at least simulate sync code execution. Wrapping our setTimeout in a Promise should do the trick.

function handleModal(regex) {
    return new Promise(resolve => {
        setTimeout(() => {
            removeText(regex)
            closeModal()
            resolve()
        }, 1000)
    })
}

6. Putting it all together

async function replaceText(regex = /^Copy of /) {
    const buttons = getButtons(regex)
    for (let button of buttons) {
        openModal(button)
        await handleModal(regex)
    }
}

Now call replaceText() and enjoy your coffee while this little ad-hoc macros does the boring renaming for you. ✨

function getButtons(regex) {
return Array.from(document.querySelectorAll('.notion-calendar-view .notion-collection-item a > div:first-child')).filter(node => {
const text = node.innerText.split(/\n/)
return text.some(title => regex.test(title))
})
}
function openModal(button) {
button.dispatchEvent(new Event('mousedown', { bubbles: true }))
button.dispatchEvent(new Event('mouseup', { bubbles: true }))
button.dispatchEvent(new Event('click', { bubbles: true }))
}
function removeText(regex) {
let input = document.querySelector('div.vertical:nth-child(3) > div:nth-child(1) > div:nth-child(1) > div:nth-child(3) > div:nth-child(1)')
input.innerHTML = input.innerHTML.replace(regex, '')
input.dispatchEvent(new Event('input', { bubbles: true }))
}
function closeModal() {
let backdrop = document.querySelector('.notion-peek-renderer > div')
backdrop.dispatchEvent(new Event('click', { bubbles: true }))
}
function handleModal(regex) {
return new Promise(resolve => {
setTimeout(() => {
removeText(regex)
closeModal()
resolve()
}, 1000)
})
}
async function replaceText(regex = /^Copy of /) {
const buttons = getButtons(regex)
for (let button of buttons) {
openModal(button)
await handleModal(regex)
}
}
replaceText()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment