// ==UserScript== // @name Notion.so v3 Trash Cleaner // @namespace https://github.com/bjxpen // @version 0.1 // @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 // @downloadURL none // ==/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)) } onClick(ev) { let deletedPostCount = 0 const recurDel = () => { this.setMsg(`Workspace "${this.space.name}":\nDeleting posts (done: ${deletedPostCount}) (takes a while) ...`) postJSON("api/v3/searchTrashPages", { query: "", limit: 20, spaceId: this.space.id }) .catch(err => { console.log(err) this.setMsg(`Workspace "${this.space.name}":\nNetwork error: Failed fetching trash posts`) }) .then(res => res.results) .then(pageIds => { if (pageIds.length > 0) { postJSON("api/v3/deleteBlocks", { blockIds: pageIds, permanentlyDelete: true }) .catch(err => { console.log(err) this.setMsg(`Workspace "${this.space.name}":\nNetwork error: Failed deleting posts`) }) .then(_ => { deletedPostCount += pageIds.length recurDel() }) } else { this.setMsg(`Workspace "${this.space.name}":\nTrash is cleared`) } }) } recurDel() } } 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) { const config = { method: "POST" } if (jsonPayload) { Object.assign(config, { headers: { "Content-Type": "application/json" }, body: JSON.stringify(jsonPayload) }) } return axios.post(url, jsonPayload).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% - 160px); width: 320px; 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; } `) document.body.appendChild(new Menu().el) })