- // ==UserScript==
- // @name Notion.so v3 Trash Cleaner
- // @namespace https://github.com/bjxpen
- // @version 0.4
- // @description Provides a pop up where you can select a workspace in Notion.so to clear its trash
- // @author Jiaxing Peng
- // @license MIT
- // @match *://www.notion.so/*
- // @require https://cdn.jsdelivr.net/npm/redom@3.24.0/dist/redom.min.js
- // @require https://cdn.jsdelivr.net/npm/axios@0.19.0/dist/axios.min.js
- // @run-at document-idle
- // @grant GM_xmlhttpRequest
- // ==/UserScript==
-
- /*jshint esversion: 6 */
- class Component {
- setState(state) {
- if (this.state === undefined) {
- this.state = {}
- }
- Object.assign(this.state, state)
- this.update()
- }
-
- update() {}
- }
-
- class Menu extends Component {
- constructor() {
- super()
- this.state = {
- msg: ""
- }
- this.render()
- this.fetchSpaces()
- }
-
- fetchSpaces() {
- this.setState({
- fetchSpaceState: "fetching"
- })
- postJSON('/api/v3/loadUserContent')
- .catch(err => {
- console.log(err)
- this.setState({
- fetchSpaceState: "error"
- })
- })
- .then(res => [... function*() {
- const spaceView = res.recordMap.space_view
- for (let _ in spaceView) {
- yield res.recordMap.space[spaceView[_].value.space_id].value
- }
- }()])
- .then(spaces => {
- this.spaces = spaces
- this.setState({
- fetchSpaceState: "fetched"
- })
- })
- }
-
- render() {
- this.el = redom.el("div#_del-trash-menu")
- }
-
- setMsg(msg) {
- setTimeout(() => this.setState({
- msg
- }), 0)
- }
-
- update() {
- const msg = (() => {
- if (this.state.fetchSpaceState === "fetched" && this.state.msg !== "") {
- return this.state.msg
- }
- switch (this.state.fetchSpaceState) {
- case "fetching":
- return "Fetching workspace metadata..."
- case "fetched":
- return "Choose workspace to delete:"
- case "error":
- return "Network error: Failed fetching workspace data"
- default:
- return this.state.msg
- }
- })()
-
- redom.setChildren(this.el, [
- redom.el("div", "(Turn off this script to close the pop up)"),
- redom.el("pre", msg),
- this.state.fetchSpaceState === "fetched" &&
- redom.el("ul", this.spaces.map(space => new Space({
- space,
- setMsg: this.setMsg.bind(this)
- }))),
- ]);
- }
- }
-
- class Space extends Component {
- constructor({
- space,
- setMsg
- }) {
- super()
- this.space = space
- this.setMsg = setMsg
- this.render()
- }
-
- render() {
- this.el = redom.el("li", this.space.name)
- this.el.addEventListener("click", this.onClick.bind(this))
- }
-
- deleteBlocks(query, currentQueryCount, totalQueryCount) {
- return new Promise(res => {
- this.setMsg(`Workspace "${this.space.name}":\nFetching block ids in trash for block query "${query}" (${currentQueryCount}/${totalQueryCount}) ...`)
- postJSON("/api/v3/searchTrashPages", {
- query: query,
- limit: 0,
- spaceId: this.space.id
- }, {
- onDownloadProgress: (ev) => {
- this.setMsg(`Workspace "${this.space.name}":\nFetching block ids in trash (${ev.loaded/1024} KB) for block query "${query}" ...`)
- }
- })
- .catch(err => {
- console.log(err)
- this.setMsg(`Workspace "${this.space.name}":\nNetwork error: Failed fetching trash posts for block query "${query}"`)
- })
- .then(res => res.results)
- .then(blockIds => {
- if (blockIds.length === 0) {
- this.setMsg(`Workspace "${this.space.name}":\nTrash is cleared for block query "${query}"`)
- return res()
- }
- const total = blockIds.length
- const chunkSize = 1000
- const recurDel = () => {
- if (blockIds.length > 0) {
- const deleted = total - blockIds.length
- this.setMsg(`Workspace "${this.space.name}":\nDeleting blocks for block query "${query}" (${deleted}/${total}) ...`)
- postJSON("/api/v3/deleteBlocks", {
- blockIds: blockIds.splice(0, chunkSize),
- permanentlyDelete: true
- })
- .catch(err => {
- console.log(err)
- this.setMsg(`Workspace "${this.space.name}":\nNetwork error: Failed deleting posts for block query "${query}"`)
- })
- .then(_ => {
- recurDel()
- })
- } else {
- this.setMsg(`Workspace "${this.space.name}":\nRetry to delete leftover posts for block query "${query}" ...`)
- setTimeout(() => this.deleteBlocks(query, currentQueryCount, totalQueryCount).then(res), 300)
- }
- }
- recurDel()
- })
- })
- }
-
- onClick(ev) {
- const queries = ["", ..." abcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?]"]
- runSerial(queries.map((query, index) => () => this.deleteBlocks(query, index + 1, queries.length)))
- .then(() => this.setMsg("Done"))
- }
- }
-
- function runSerial(promises) {
- let result = Promise.resolve()
- for (let i = 0; i < promises.length; i++) {
- result = result.then(() => promises[i]())
- }
- return result
- }
-
- function loadScript(url) {
- return new Promise((res, rej) => {
- const script = document.createElement("script")
- document.body.appendChild(script)
- script.addEventListener("load", (ev) => {
- res(url)
- })
- script.src = url
- })
- }
-
- function loadCSS(css) {
- const elm = document.createElement("style")
- elm.innerHTML = css
- document.body.appendChild(elm)
- }
-
- function postJSON(url, jsonPayload = null, config = {}) {
- return axios.post(url, jsonPayload, config).then(res => res.data)
- }
-
- Promise.all([
- window.redom || loadScript("https://cdn.jsdelivr.net/npm/redom@3.24.0/dist/redom.min.js"),
- window.axios || loadScript("https://cdn.jsdelivr.net/npm/axios@0.19.0/dist/axios.min.js")
- ])
- .then(() => {
- loadCSS(`
- #_del-trash-menu {
- position: absolute;
- color: rgba(55, 53, 47, 0.6);
- background: rgb(247, 246, 243);
- padding: 1em;
- top: 0;
- left: calc(50% - 200px);
- width: 400px;
- min-height: 200px;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
- font-size: 14px;
- z-index: 9999;
- }
-
- #_del-trash-menu ul, #_del-trash-menu li {
- color: black;
- list-style: none;
- margin: 0;
- padding: 0;
- }
-
- #_del-trash-menu li {
- margin: 12px 0;
- padding: 6px;
- }
-
- #_del-trash-menu li:hover {
- cursor: pointer;
- background: white;
- }
- `)
- const root = document.querySelector("#_del-trash-menu")
- root && root.remove()
- document.body.appendChild(new Menu().el)
- })