Stack Exchange Formatter

Format a webpage on Stack Enchange websites such as stackoverflow.com so that web clippers can save a pretty webpage containing only the content you need.

  1. // ==UserScript==
  2. // @name Stack Exchange Formatter
  3. // @namespace https://greatest.deepsurf.us/en/users/211578
  4. // @version 1.0.2
  5. // @description Format a webpage on Stack Enchange websites such as stackoverflow.com so that web clippers can save a pretty webpage containing only the content you need.
  6. // @author twchen
  7. // @include https://stackoverflow.com/questions/*
  8. // @include https://*.stackexchange.com/questions/*
  9. // @include https://superuser.com/questions/*
  10. // @include https://serverfault.com/questions/*
  11. // @include https://askubuntu.com/questions/*
  12. // @run-at document-idle
  13. // @grant GM_addStyle
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM.getValue
  17. // @grant GM.setValue
  18. // ==/UserScript==
  19.  
  20. "use strict";
  21. if (typeof GM_addStyle == "undefined") {
  22. this.GM_addStyle = (css) => {
  23. const style = document.createElement("style");
  24. style.textContent = css;
  25. document.documentElement.appendChild(style);
  26. return style;
  27. };
  28. }
  29.  
  30. if (typeof GM == "undefined") {
  31. this.GM = {};
  32. [
  33. ["getValue", GM_getValue],
  34. ["setValue", GM_setValue],
  35. ].forEach(([newFunc, oldFunc]) => {
  36. GM[newFunc] = (...args) => {
  37. return new Promise((resolve, reject) => {
  38. try {
  39. resolve(oldFunc(...args));
  40. } catch (error) {
  41. reject(error);
  42. }
  43. });
  44. };
  45. });
  46. }
  47.  
  48. function createElement(type, props, ...children) {
  49. const element = document.createElement(type);
  50. Object.entries(props || {}).forEach(([name, value]) => {
  51. if (name.startsWith("on")) {
  52. const eventName = name.slice(2);
  53. element.addEventListener(eventName, value);
  54. } else if (name == "style" && typeof value !== "string") {
  55. Object.assign(element.style, value);
  56. } else {
  57. element.setAttribute(name, value);
  58. }
  59. });
  60. children
  61. .map((child) =>
  62. typeof child === "string" ? document.createTextNode(child) : child
  63. )
  64. .forEach((child) => element.appendChild(child));
  65. return element;
  66. }
  67.  
  68. const body = document.body;
  69. const formatted = createElement("div", { id: "formatted" });
  70. body.appendChild(formatted);
  71. const posts = document.querySelectorAll(".question, .answer");
  72. let postCheckboxes = [];
  73. let commentCheckboxes = [];
  74.  
  75. function addCheckboxes() {
  76. posts.forEach((post, i) => {
  77. post.querySelectorAll(".post-layout--right").forEach((layout, j) => {
  78. const isCommentLayout = layout.querySelector(".comments") !== null;
  79. if (isCommentLayout && layout.querySelectorAll("li.comment").length === 0)
  80. return;
  81. layout.style.position = "relative";
  82. const checkboxId = `post${i}-layout${j}`;
  83. const checkbox = createElement("input", {
  84. type: "checkbox",
  85. id: checkboxId,
  86. });
  87. if (isCommentLayout) commentCheckboxes.push(checkbox);
  88. else postCheckboxes.push(checkbox);
  89. const container = createElement(
  90. "div",
  91. { class: "ft-checkbox-container" },
  92. createElement("label", { for: checkboxId }, "Keep"),
  93. createElement("br"),
  94. checkbox
  95. );
  96. layout.appendChild(container);
  97. });
  98. });
  99. }
  100.  
  101. function addLinks() {
  102. const question = document.querySelector(".question");
  103. const answers = document.querySelectorAll(".answer");
  104. answers.forEach((answer) => {
  105. const menu = answer.querySelector(".post-menu");
  106. const saveAnsLink = createElement(
  107. "a",
  108. {
  109. href: "#",
  110. style: "margin-right: 0.5rem",
  111. onclick: async (event) => {
  112. event.preventDefault();
  113. unselectAllCheckboxes();
  114. await keepPost(answer);
  115. save();
  116. },
  117. },
  118. "save this answer"
  119. );
  120. const saveQALink = createElement(
  121. "a",
  122. {
  123. href: "#",
  124. onclick: async (event) => {
  125. event.preventDefault();
  126. unselectAllCheckboxes();
  127. await keepPost(question);
  128. await keepPost(answer);
  129. save();
  130. },
  131. },
  132. "save this Q&A"
  133. );
  134. menu.append(saveAnsLink, saveQALink);
  135. });
  136.  
  137. const advancedSaveLink = createElement(
  138. "div",
  139. { style: "padding-left: 1rem" },
  140. createElement(
  141. "a",
  142. {
  143. href: "#",
  144. class: "ws-nowrap s-btn s-btn__primary",
  145. onclick: (event) => {
  146. event.preventDefault();
  147. startChoosing();
  148. },
  149. },
  150. "Advanced Save"
  151. )
  152. );
  153. const header = document.querySelector("#question-header");
  154. header.append(advancedSaveLink);
  155. }
  156.  
  157. function startChoosing() {
  158. document.querySelectorAll(".ft-checkbox-container").forEach((container) => {
  159. container.style.display = "block";
  160. });
  161. let dialog = document.getElementById("ft-dialog");
  162. if (dialog) {
  163. dialog.style.display = "block";
  164. } else {
  165. createDialog();
  166. }
  167. }
  168.  
  169. async function createDialog() {
  170. const dialog = createElement(
  171. "div",
  172. { id: "ft-dialog" },
  173. createElement("label", { for: "selectAllPosts" }, "Select All Posts"),
  174. createElement("input", {
  175. type: "checkbox",
  176. id: "selectAllPosts",
  177. onchange: (event) => {
  178. for (let checkbox of postCheckboxes) {
  179. checkbox.checked = event.target.checked;
  180. }
  181. },
  182. }),
  183. createElement("br"),
  184. createElement("label", { for: "selectAllComments" }, "Select All Comments"),
  185. createElement("input", {
  186. type: "checkbox",
  187. id: "selectAllComments",
  188. onchange: (event) => {
  189. for (let checkbox of commentCheckboxes) {
  190. checkbox.checked = event.target.checked;
  191. }
  192. },
  193. }),
  194. createElement("br"),
  195. createElement(
  196. "label",
  197. { for: "selectCommentsByDefault" },
  198. "Select Comments by Default"
  199. ),
  200. createElement("input", {
  201. type: "checkbox",
  202. id: "selectCommentsByDefault",
  203. onchange: (event) => {
  204. GM.setValue("selectCommentsByDefault", event.target.checked);
  205. },
  206. }),
  207. createElement("br"),
  208. createElement(
  209. "button",
  210. {
  211. onclick: (event) => {
  212. document
  213. .querySelectorAll(".ft-checkbox-container")
  214. .forEach((container) => {
  215. container.style.display = "none";
  216. });
  217. dialog.style.display = "none";
  218. },
  219. },
  220. "Cancel"
  221. ),
  222. createElement("button", { onclick: (event) => save() }, "Save")
  223. );
  224. const selectComments = await GM.getValue("selectCommentsByDefault");
  225. dialog.querySelector("#selectCommentsByDefault").checked = selectComments;
  226. for (let checkbox of commentCheckboxes) {
  227. checkbox.checked = selectComments;
  228. }
  229. body.appendChild(dialog);
  230. }
  231.  
  232. function save() {
  233. const children = [];
  234. const questionLink = document.querySelector(
  235. "#question-header .question-hyperlink"
  236. );
  237. const hr = createElement("hr", { style: "height: 0px" });
  238. let title = undefined;
  239. if (questionLink) {
  240. title = createElement(
  241. "div",
  242. { class: "post-layout--right" },
  243. questionLink.cloneNode(true)
  244. );
  245. children.push(title);
  246. }
  247. posts.forEach((post, i) => {
  248. const layouts = [];
  249. post.querySelectorAll(".post-layout--right").forEach((layout, j) => {
  250. const checkboxId = `post${i}-layout${j}`;
  251. const checkbox = document.getElementById(checkboxId);
  252. if (checkbox && checkbox.checked) {
  253. layouts.push(layout.cloneNode(true));
  254. }
  255. });
  256. if (layouts.length > 0) {
  257. children.push(...layouts);
  258. children.push(hr.cloneNode(true));
  259. }
  260. });
  261. if (children.length === 0 || (children.length === 1 && title)) {
  262. alert("Select at least one post!");
  263. return;
  264. }
  265. children.pop();
  266. hideAllChildren(body);
  267. removeAllChildren(formatted);
  268. formatted.append(...children);
  269. formatted.style.display = "block";
  270. window.history.pushState("formatted", "");
  271. }
  272.  
  273. function unselectAllCheckboxes() {
  274. for (let checkboxes of [postCheckboxes, commentCheckboxes]) {
  275. for (let checkbox of checkboxes) {
  276. checkbox.checked = false;
  277. }
  278. }
  279. }
  280.  
  281. async function keepPost(post) {
  282. const layouts = post.querySelectorAll(".post-layout--right");
  283. const selectComments = await GM.getValue("selectCommentsByDefault");
  284. for (let layout of layouts) {
  285. console.log(layout);
  286. const checkbox = layout.querySelector(
  287. '.ft-checkbox-container input[type="checkbox"]'
  288. );
  289. if (checkbox === null) {
  290. console.log(1);
  291. continue;
  292. }
  293. if (layout.querySelector(".comments") && selectComments === false) {
  294. console.log(2);
  295. checkbox.checked = false;
  296. } else {
  297. checkbox.checked = true;
  298. }
  299. }
  300. }
  301.  
  302. function removeAllChildren(el) {
  303. while (el.firstChild) {
  304. el.removeChild(el.firstChild);
  305. }
  306. }
  307.  
  308. function showAllChildren(el) {
  309. [...el.children].forEach((child) => {
  310. child.style.display =
  311. child.old_display === undefined ? "" : child.old_display;
  312. });
  313. }
  314.  
  315. function hideAllChildren(el) {
  316. [...el.children].forEach((child) => {
  317. child.old_display = child.style.display;
  318. child.style.display = "none";
  319. });
  320. }
  321.  
  322. GM_addStyle(`
  323. .ft-checkbox-container {
  324. position: absolute;
  325. top: 0;
  326. right: -1rem;
  327. text-align: center;
  328. display: none;
  329. }
  330. #ft-dialog {
  331. background-color: white;
  332. position: fixed;
  333. top: 50%;
  334. right: 2rem;
  335. transform: translateY(-50%);
  336. z-index: 100;
  337. text-align: center;
  338. padding: 0.8rem;
  339. border: 1px solid black;
  340. border-radius: 5px;
  341. }
  342. #ft-dialog label {
  343. width: 10rem;
  344. display: inline-block;
  345. text-align: left;
  346. }
  347. #ft-dialog button {
  348. width: 5rem;
  349. margin: 0 0.5rem;
  350. }
  351. #formatted {
  352. background-color: #f6f6f6;
  353. position: absolute;
  354. top: 0;
  355. left: 0;
  356. width: 100%;
  357. }
  358. #formatted .question-hyperlink {
  359. color: black;
  360. font-size: 2rem;
  361. }
  362. #formatted .ft-checkbox-container {
  363. display: none !important;
  364. }
  365. #formatted .post-layout--right {
  366. background-color: white;
  367. padding: 2rem;
  368. margin: 0 2rem;
  369. box-shadow: 0 1px 3px #808080b5;
  370. }
  371. #formatted .post-menu, #formatted .post-signature, #formatted *[id^="comments-link-"] {
  372. display: none;
  373. }
  374. `);
  375. // handle backward/forward events
  376. window.addEventListener("popstate", function (event) {
  377. if (event.state === "formatted") {
  378. hideAllChildren(body);
  379. formatted.style.display = "block";
  380. } else {
  381. showAllChildren(body);
  382. formatted.style.display = "none";
  383. }
  384. });
  385. addCheckboxes();
  386. addLinks();