swagger-toolkit

Swagger 站点工具脚本 💪 | 保存浏览历史 🕘 | 显示收藏夹 ⭐️ | 点击 path 快速定位 🎯 | 快速复制 API path 🔗

  1. // ==UserScript==
  2. // @name swagger-toolkit
  3. // @namespace https://github.com/SublimeCT/greasy_monkey_scripts
  4. // @version 1.2.0
  5. // @description Swagger 站点工具脚本 💪 | 保存浏览历史 🕘 | 显示收藏夹 ⭐️ | 点击 path 快速定位 🎯 | 快速复制 API path 🔗
  6. // @description:en Swagger Toolkit Script 💪 | save history in sidebar 🕘 | has favorites list in sidebar ⭐️ | click path(in sidebar) to jump 🎯 | copy(hover API) API path 🔗
  7. // @note v1.0.1 增加当前页是不是 swagger 构建的文档判断; 自动展开所有 tag, 以定位到对应的 API;
  8. // @note v1.1.0 增加复制 API path 功能
  9. // @note v1.1.1 fix: 修复增加历史记录时将 toolkit-btn-group 内容一起加进去的问题
  10. // @note v1.2.0 feat: 增加多语言(英语)支持
  11. // @author Sven
  12. // @icon https://static1.smartbear.co/swagger/media/assets/swagger_fav.png
  13. // @match *://*/docs/index.html
  14. // @match *://*/docs/api/index.html
  15. // @match https://petstore.swagger.io
  16. // @grant none
  17. // ==/UserScript==
  18.  
  19. ; (() => {
  20. // @require file:///Users/test/projects/greasy_monkey_scripts/swagger_toolkit.js
  21. const TIMES = 30
  22. let current = 0
  23. let isLoaded = false
  24. const interval = setInterval(() => {
  25. if (++current >= TIMES) {
  26. clearInterval(interval)
  27. return
  28. }
  29. const item = document.querySelector('.opblock-tag')
  30. const swaggerAPI = window.SwaggerUIBundle
  31. if (!item || !swaggerAPI) return
  32. if (!isLoaded) {
  33. // 首先展开所有 tag, 否则无法定位
  34. const notOpenTags = document.querySelectorAll('.opblock-tag[data-is-open=false]') || []
  35. for (const tag of Array.from(notOpenTags)) {
  36. tag.click()
  37. }
  38. // 增加监听事件
  39. const wrapper = document.querySelector('.swagger-ui')
  40. wrapper.addEventListener('click', evt => {
  41. // 点击接口标题时在当前 URL 中加入锚点
  42. const linkTitleDom = evt.target.closest('.opblock-summary')
  43. if (linkTitleDom) {
  44. const linkDom = linkTitleDom.parentNode
  45. const isOpen = !linkDom.classList.contains('is-open')
  46. const hash = isOpen ? linkDom.id : ''
  47. if (hash) location.hash = hash
  48. return
  49. }
  50. // 点击接口中的 Model 时同步展开下方数据结构
  51. const modelLinkDom = evt.target.closest('ul.tab')
  52. if (modelLinkDom && evt.target.innerText.trim() === 'Model') {
  53. setTimeout(() => {
  54. const icons = modelLinkDom.nextElementSibling.querySelectorAll('.model-toggle.collapsed')
  55. if (icons.length) icons[icons.length - 1].click()
  56. }, 300)
  57. return
  58. }
  59. })
  60. if (location.hash) {
  61. observeHash()
  62. window.addEventListener('hashchange', observeHash)
  63. }
  64. isLoaded = true
  65. return
  66. }
  67. }, 300);
  68. const observeHash = evt => {
  69. const linkedDom = document.getElementById(location.hash.length > 0 ? location.hash.substr(1) : '')
  70. if (linkedDom) {
  71. const isOpen = linkedDom.classList.contains('is-open')
  72. linkedDom.scrollIntoView()
  73. if (!isOpen) linkedDom.querySelector('.opblock-summary').click()
  74. console.log('scroll into view: ', linkedDom, linkedDom.querySelector('.opblock-summary'))
  75. }
  76. }
  77. class Sheets {
  78. static sheets = `
  79. body {
  80. --row-width: 13vw;
  81. --row-min-width: 245px;
  82. --row-title-font-size: 14px;
  83. --body-wrapper-width: 80vw;
  84. --body-wrapper-margin-right: 3vw;
  85. --body-wrapper-min-width: 800px;
  86. --body-btn-group-width: 20px;
  87. }
  88.  
  89. /* 应用于 Copy input */
  90. .toolkit-hidden { width: 1; height: 1; }
  91.  
  92. /* 接口信息部分样式 */
  93. #swagger-ui .opblock .toolkit-path-btn-group { margin-left: 10px; display: none; }
  94. #swagger-ui .opblock:hover .toolkit-path-btn-group { display: block; }
  95. #swagger-ui .opblock .toolkit-path-btn-group a { text-decoration: none; }
  96.  
  97. /* 页面内容主体布局 */
  98. #swagger-ui div.topbar { display: flex; justify-content: flex-end; }
  99. #swagger-ui div.topbar .wrapper { margin: 0; width: var(--body-wrapper-width); min-width: var(--body-wrapper-min-width); margin-right: var(--body-wrapper-margin-right) }
  100. #swagger-ui div.swagger-ui { display: flex; justify-content: flex-end; }
  101. #swagger-ui div.swagger-ui .wrapper { margin: 0; width: var(--body-wrapper-width); min-width: var(--body-wrapper-min-width); margin-right: var(--body-wrapper-margin-right) }
  102.  
  103. /* sidebar part */
  104. #swagger-toolkit-sidebar {
  105. width: var(--row-width);
  106. min-width: var(--row-min-width);
  107. display: flex;
  108. position: fixed;
  109. top: 0;
  110. left: 0;
  111. height: 100vh;
  112. flex-direction: column;
  113. justify-content: space-between;
  114. background-color: #FAFAFA;
  115. border-right: 1px solid #c4d6d6;
  116. }
  117. #swagger-toolkit-sidebar .list { width: 100%; }
  118. #swagger-toolkit-sidebar .list > header { font-size: 18px; background-color: #999; }
  119. #swagger-toolkit-sidebar .list > header > .title { color: #FFF; text-align: center; font-weight: 200; }
  120. #swagger-toolkit-sidebar .row { display: flex; padding-bottom: 5px; width: 100%; cursor: pointer; text-decoration: none; }
  121. #swagger-toolkit-sidebar .row.method-DELETE { background-color: rgba(249,62,62,.1); }
  122. #swagger-toolkit-sidebar .row.method-DELETE:hover { background-color: rgba(249,62,62,.5); }
  123. #swagger-toolkit-sidebar .row.method-GET { background-color: rgba(97,175,254,.1); }
  124. #swagger-toolkit-sidebar .row.method-GET:hover { background-color: rgba(97,175,254,.5); }
  125. #swagger-toolkit-sidebar .row.method-POST { background-color: rgba(73,204,144,.1); }
  126. #swagger-toolkit-sidebar .row.method-POST:hover { background-color: rgba(73,204,144,.5); }
  127. #swagger-toolkit-sidebar .row.method-PUT { background-color: rgba(252,161,48,.1); }
  128. #swagger-toolkit-sidebar .row.method-PUT:hover { background-color: rgba(252,161,48,.5); }
  129. #swagger-toolkit-sidebar .row.method-PATCH { background-color: rgba(80,227,194,.1); }
  130. #swagger-toolkit-sidebar .row.method-PATCH:hover { background-color: rgba(80,227,194,.5); }
  131.  
  132. #swagger-toolkit-sidebar .row .description { color: #333; font-size: 14px; width: calc(var(--row-width) - var(--body-btn-group-width)); min-width: calc(var(--row-min-width) - var(--body-btn-group-width)); }
  133. #swagger-toolkit-sidebar .row .method { display: flex; line-height: 45px; min-width: 64px; }
  134. #swagger-toolkit-sidebar .row .path > a { color: #409EFF; }
  135.  
  136. #swagger-toolkit-sidebar .row .btn-group { font-size: 12px; }
  137. #swagger-toolkit-sidebar .row .btn-group > a { text-decoration: none; display: block; }
  138. #swagger-toolkit-sidebar .row .btn-group > a:hover { font-size: 14px; }
  139.  
  140. /* helper */
  141. .tool-text-size-fixed { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  142. `
  143. static inject() {
  144. const sheet = document.createTextNode(Sheets.sheets)
  145. const el = document.createElement('style')
  146. el.id = 'swagger-toolkit-sheets'
  147. el.appendChild(sheet)
  148. document.getElementsByTagName('head')[0].appendChild(el)
  149. }
  150. }
  151. class LinkStore {
  152. key = ''
  153. path = ''
  154. method = ''
  155. description = '' // 接口名
  156. id = ''
  157. createdat = 0
  158. static MAX_LENGTH = 10
  159. static save(row, key) {
  160. const store = new LinkStore()
  161. store.id = row.id
  162. store.key = key
  163. store.method = row.querySelector('.opblock-summary-method').innerText
  164. store.path = row.querySelector('.opblock-summary-path > a').innerText
  165. store.description = row.querySelector('.opblock-summary-description').innerText
  166. LinkStore.add(key, store)
  167. }
  168. static add(key, store, filterRepeat) {
  169. let data = LinkStore.getStore(key)
  170. if (filterRepeat) {
  171. for (const row of data) {
  172. if (row.id === store.id && store.path === store.path) return false
  173. }
  174. }
  175. data.unshift(store)
  176. if (data.length > LinkStore.MAX_LENGTH) data = data.slice(0, LinkStore.MAX_LENGTH)
  177. localStorage.setItem(key, JSON.stringify(data))
  178. }
  179. static remove(key, index) {
  180. let data = LinkStore.getStore(key)
  181. data.splice(index, 1)
  182. localStorage.setItem(key, JSON.stringify(data))
  183. }
  184. static getStore(key) {
  185. let store = []
  186. try {
  187. const _store = localStorage.getItem(key)
  188. if (_store) store = JSON.parse(_store)
  189. } catch (err) {
  190. console.error(err)
  191. }
  192. return store
  193. }
  194. }
  195. class Pane {
  196. dom = null
  197. localKey = null
  198. title = null
  199. placeholder = '暂无数据'
  200. placeholder_en = 'no data'
  201. btnSave = '收藏'
  202. btnSave_en = 'add to favorites'
  203. btnRemove = '删除'
  204. btnRemove_en = 'remove'
  205. enableMarkBtn = false
  206. /**
  207. * 生成或更新当前 Pane
  208. * @description 将生成 `.list>(header>.title)+(a.row>(.method+.contents>(.description+a.path)))`
  209. */
  210. generateDom(isUpdate) {
  211. if (isUpdate) this.dom.innerHTML = ''
  212. const list = isUpdate ? this.dom : document.createElement('div')
  213. list.classList.add('list')
  214. list.classList.add(this.localKey)
  215. list.setAttribute('data-key', this.localKey)
  216. // 添加 header
  217. const header = document.createElement('header')
  218. const title = document.createElement('div')
  219. title.classList.add('title')
  220. title.innerText = this.getLabelByLanguage('title')
  221. list.appendChild(header)
  222. header.appendChild(title)
  223. // 添加数据
  224. const data = LinkStore.getStore(this.localKey)
  225. for (const dataRow of data) {
  226. const row = document.createElement('a')
  227. row.href = '#' + dataRow.id
  228. row.setAttribute('data-row', JSON.stringify(dataRow))
  229. const method = document.createElement('div')
  230. method.innerText = dataRow.method
  231. const contents = document.createElement('div')
  232. const description = document.createElement('div')
  233. description.innerText = dataRow.description
  234. const path = document.createElement('div')
  235. const pathLink = document.createElement('a')
  236. pathLink.innerText = dataRow.path
  237. pathLink.href = '#' + dataRow.id
  238. const btnGroup = document.createElement('div')
  239. const markBtn = document.createElement('a')
  240. if (this.enableMarkBtn) {
  241. markBtn.href = 'javascript:;'
  242. markBtn.setAttribute('title', this.getLabelByLanguage('btnSave'))
  243. markBtn.innerText = '⭐️'
  244. }
  245. const deleteBtn = document.createElement('a')
  246. deleteBtn.href = 'javascript:;'
  247. deleteBtn.setAttribute('title', this.getLabelByLanguage('btnRemove'))
  248. deleteBtn.innerText = '✖️'
  249.  
  250. row.classList.add('row')
  251. row.classList.add('method-' + dataRow.method)
  252. method.classList.add('method')
  253. contents.classList.add('contents')
  254. description.classList.add('description')
  255. description.classList.add('tool-text-size-fixed')
  256. path.classList.add('path')
  257. btnGroup.classList.add('btn-group')
  258. if (this.enableMarkBtn) markBtn.classList.add('btn-mark')
  259. deleteBtn.classList.add('btn-delete')
  260.  
  261. path.appendChild(pathLink)
  262. contents.appendChild(description)
  263. contents.appendChild(path)
  264. // row.appendChild(method)
  265. row.appendChild(contents)
  266. row.appendChild(btnGroup)
  267. btnGroup.appendChild(deleteBtn)
  268. if (this.enableMarkBtn) btnGroup.appendChild(markBtn)
  269. list.appendChild(row)
  270. }
  271. if (data.length === 0) list.appendChild(this.getPlaceholderDom())
  272. this.dom = list
  273. if (typeof this.afterGenerageDom === 'function') this.afterGenerageDom()
  274. return list
  275. }
  276. getPlaceholderDom() {
  277. const dom = document.createElement('section')
  278. dom.innerText = this.getLabelByLanguage('placeholder')
  279. return dom
  280. }
  281. getLabelByLanguage(field, language) {
  282. let lang = language
  283. if (!lang) {
  284. const _lang = navigator.language
  285. lang = _lang.indexOf('zh') === 0 ? '' : 'en'
  286. }
  287. return this[`${field}${lang ? ('_' + lang) : '' }`]
  288. }
  289. }
  290. class HistoryPane extends Pane {
  291. localKey = 'swagger-toolkit-history'
  292. title = '浏览历史'
  293. title_en = 'History'
  294. placeholder = '暂无浏览历史数据'
  295. placeholder_en = 'No history at present'
  296. enableMarkBtn = true
  297. }
  298. class MarkPane extends Pane {
  299. localKey = 'swagger-toolkit-mark'
  300. title = '收藏夹'
  301. title_en = 'Favorites'
  302. placeholder = '暂无收藏数据, 点击 ⭐️ 按钮添加'
  303. placeholder_en = 'No favorite data, click ⭐️ button to add'
  304. afterGenerageDom() {
  305. this.dom
  306. }
  307. }
  308. class SideBar {
  309. static dom = null
  310. static panes = []
  311. static pathBtnGroupClassName = 'toolkit-path-btn-group'
  312. static copyInput = document.createElement('input')
  313. initCopyDOM() {
  314. SideBar.copyInput.classList.add('toolkit-hidden')
  315. document.body.appendChild(SideBar.copyInput)
  316. return this
  317. }
  318. addListeners() {
  319. window.addEventListener('hashchange', () => {
  320. const _path = location.hash.length > 0 ? location.hash.substr(1) : ''
  321. if (!_path) return
  322. const row = document.getElementById(_path) || (document.querySelector(`a[href="#${_path}"]`) && document.querySelector(`a[href="#${_path}"]`).closest('.opblock'))
  323. if (row) LinkStore.save(row, 'swagger-toolkit-history')
  324. this._updatePane('swagger-toolkit-history')
  325. })
  326. document.querySelector('#swagger-ui').addEventListener('mouseover', evt => {
  327. this._showPathBtnGroup(evt) // 显示在 path 栏中的按钮组
  328. })
  329. return this
  330. }
  331. _showPathBtnGroup(evt) {
  332. const opblock = evt.target.closest('.opblock')
  333. if (!opblock) return
  334. this._appendPathBtnGroupDOM(opblock)
  335. }
  336. _appendPathBtnGroupDOM(opblock) {
  337. if (opblock.querySelector('.' + SideBar.pathBtnGroupClassName)) return
  338. const group = document.createElement('div')
  339. const copyBtn = document.createElement('a')
  340. group.classList.add(SideBar.pathBtnGroupClassName)
  341. copyBtn.setAttribute('href', 'javascript:;')
  342. copyBtn.classList.add('btn-copy')
  343. copyBtn.innerText = '🔗'
  344. copyBtn.setAttribute('title', 'copy')
  345. group.appendChild(copyBtn)
  346. copyBtn.addEventListener('click', evt => {
  347. this._copyPath(evt)
  348. })
  349.  
  350. const pathDOM = opblock.querySelector('.opblock-summary-path')
  351. if (pathDOM) pathDOM.appendChild(group)
  352. }
  353. _copyPath(evt) {
  354. evt.stopPropagation()
  355. const pathDOM = evt.target.closest('.opblock-summary-path')
  356. if (!pathDOM) return
  357. const pathLink = pathDOM.querySelector('a')
  358. if (!pathLink) return
  359. const path = pathLink.innerText
  360. SideBar.copyInput.value = path
  361. SideBar.copyInput.select()
  362. document.execCommand('Copy')
  363. console.log('copy successfuly')
  364. }
  365. generateDom() {
  366. const sidebar = document.createElement('sidebar')
  367. sidebar.id = 'swagger-toolkit-sidebar'
  368. SideBar.dom = sidebar
  369. return this
  370. }
  371. inject() {
  372. document.body.appendChild(SideBar.dom)
  373. return this
  374. }
  375. appendPanes() {
  376. for (const pane of SideBar.panes) {
  377. SideBar.dom.appendChild(pane.generateDom())
  378. }
  379. return this
  380. }
  381. _updatePane(key) {
  382. for (const pane of SideBar.panes) {
  383. if (pane.localKey !== key) continue
  384. pane.generateDom(true)
  385. }
  386. }
  387. appendPanesListeners() {
  388. SideBar.dom.addEventListener('click', evt => {
  389. if (evt.target.classList.contains('btn-delete')) {
  390. evt.preventDefault()
  391. evt.stopPropagation()
  392. const index = this._getRowIndex({ btnItem: evt.target })
  393. const key = evt.target.parentNode.parentNode.parentNode.getAttribute('data-key')
  394. LinkStore.remove(key, index)
  395. this._updatePane(key)
  396. } else if (evt.target.classList.contains('btn-mark')) {
  397. evt.preventDefault()
  398. evt.stopPropagation()
  399. const row = evt.target.parentNode.parentNode.getAttribute('data-row')
  400. LinkStore.add('swagger-toolkit-mark', JSON.parse(row), true)
  401. this._updatePane('swagger-toolkit-mark')
  402. }
  403. })
  404. }
  405. _getRowIndex({ btnItem }) {
  406. const listDom = Array.from(btnItem.parentNode.parentNode.parentNode.children)
  407. for (let index = listDom.length; index--;) {
  408. if (listDom[index] === btnItem.parentNode.parentNode) return index - 1
  409. }
  410. return -1
  411. }
  412. }
  413. Sheets.inject()
  414. SideBar.panes.push(new HistoryPane())
  415. SideBar.panes.push(new MarkPane())
  416. window.$$_SideBar = new SideBar()
  417. window.$$_SideBar
  418. .initCopyDOM()
  419. .addListeners()
  420. .generateDom()
  421. .appendPanes()
  422. .inject()
  423. .appendPanesListeners()
  424. })();