swagger-toolkit

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

// ==UserScript==
// @name         swagger-toolkit
// @namespace    https://github.com/SublimeCT/greasy_monkey_scripts
// @version      1.2.0
// @description  Swagger 站点工具脚本 💪 | 保存浏览历史 🕘 | 显示收藏夹 ⭐️ | 点击 path 快速定位 🎯 | 快速复制 API path 🔗
// @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 🔗
// @note         v1.0.1 增加当前页是不是 swagger 构建的文档判断; 自动展开所有 tag, 以定位到对应的 API;
// @note         v1.1.0 增加复制 API path 功能
// @note         v1.1.1 fix: 修复增加历史记录时将 toolkit-btn-group 内容一起加进去的问题
// @note         v1.2.0 feat: 增加多语言(英语)支持
// @author       Sven
// @icon         https://static1.smartbear.co/swagger/media/assets/swagger_fav.png
// @match        *://*/docs/index.html
// @match        *://*/docs/api/index.html
// @match        https://petstore.swagger.io
// @grant        none
// ==/UserScript==

; (() => {
    // @require      file:///Users/test/projects/greasy_monkey_scripts/swagger_toolkit.js
    const TIMES = 30
    let current = 0
    let isLoaded = false
    const interval = setInterval(() => {
        if (++current >= TIMES) {
            clearInterval(interval)
            return
        }
        const item = document.querySelector('.opblock-tag')
        const swaggerAPI = window.SwaggerUIBundle
        if (!item || !swaggerAPI) return
        if (!isLoaded) {
            // 首先展开所有 tag, 否则无法定位
            const notOpenTags = document.querySelectorAll('.opblock-tag[data-is-open=false]') || []
            for (const tag of Array.from(notOpenTags)) {
                tag.click()
            }
            // 增加监听事件
            const wrapper = document.querySelector('.swagger-ui')
            wrapper.addEventListener('click', evt => {
                // 点击接口标题时在当前 URL 中加入锚点
                const linkTitleDom = evt.target.closest('.opblock-summary')
                if (linkTitleDom) {
                    const linkDom = linkTitleDom.parentNode
                    const isOpen = !linkDom.classList.contains('is-open')
                    const hash = isOpen ? linkDom.id : ''
                    if (hash) location.hash = hash
                    return
                }
                // 点击接口中的 Model 时同步展开下方数据结构
                const modelLinkDom = evt.target.closest('ul.tab')
                if (modelLinkDom && evt.target.innerText.trim() === 'Model') {
                    setTimeout(() => {
                        const icons = modelLinkDom.nextElementSibling.querySelectorAll('.model-toggle.collapsed')
                        if (icons.length) icons[icons.length - 1].click()
                    }, 300)
                    return
                }
            })
            if (location.hash) {
                observeHash()
                window.addEventListener('hashchange', observeHash)
            }
            isLoaded = true
            return
        }
    }, 300);
    const observeHash = evt => {
        const linkedDom = document.getElementById(location.hash.length > 0 ? location.hash.substr(1) : '')
        if (linkedDom) {
            const isOpen = linkedDom.classList.contains('is-open')
            linkedDom.scrollIntoView()
            if (!isOpen) linkedDom.querySelector('.opblock-summary').click()
            console.log('scroll into view: ', linkedDom, linkedDom.querySelector('.opblock-summary'))
        }
    }
    class Sheets {
        static sheets = `
            body {
                --row-width: 13vw;
                --row-min-width: 245px;
                --row-title-font-size: 14px;
                --body-wrapper-width: 80vw;
                --body-wrapper-margin-right: 3vw;
                --body-wrapper-min-width: 800px;
                --body-btn-group-width: 20px;
            }

            /* 应用于 Copy input */
            .toolkit-hidden { width: 1; height: 1; }

            /* 接口信息部分样式 */
            #swagger-ui .opblock .toolkit-path-btn-group { margin-left: 10px; display: none; }
            #swagger-ui .opblock:hover .toolkit-path-btn-group { display: block; }
            #swagger-ui .opblock .toolkit-path-btn-group a { text-decoration: none; }

            /* 页面内容主体布局 */
            #swagger-ui div.topbar { display: flex; justify-content: flex-end; }
            #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) }
            #swagger-ui div.swagger-ui { display: flex; justify-content: flex-end; }
            #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) }

            /* sidebar part */
            #swagger-toolkit-sidebar {
                width: var(--row-width);
                min-width: var(--row-min-width);
                display: flex;
                position: fixed;
                top: 0;
                left: 0;
                height: 100vh;
                flex-direction: column;
                justify-content: space-between;
                background-color: #FAFAFA;
                border-right: 1px solid #c4d6d6;
            }
            #swagger-toolkit-sidebar .list { width: 100%; }
            #swagger-toolkit-sidebar .list > header { font-size: 18px; background-color: #999; }
            #swagger-toolkit-sidebar .list > header > .title { color: #FFF; text-align: center; font-weight: 200; }
            #swagger-toolkit-sidebar .row { display: flex; padding-bottom: 5px; width: 100%; cursor: pointer; text-decoration: none; }
            #swagger-toolkit-sidebar .row.method-DELETE { background-color: rgba(249,62,62,.1); }
            #swagger-toolkit-sidebar .row.method-DELETE:hover { background-color: rgba(249,62,62,.5); }
            #swagger-toolkit-sidebar .row.method-GET { background-color: rgba(97,175,254,.1); }
            #swagger-toolkit-sidebar .row.method-GET:hover { background-color: rgba(97,175,254,.5); }
            #swagger-toolkit-sidebar .row.method-POST { background-color: rgba(73,204,144,.1); }
            #swagger-toolkit-sidebar .row.method-POST:hover { background-color: rgba(73,204,144,.5); }
            #swagger-toolkit-sidebar .row.method-PUT { background-color: rgba(252,161,48,.1); }
            #swagger-toolkit-sidebar .row.method-PUT:hover { background-color: rgba(252,161,48,.5); }
            #swagger-toolkit-sidebar .row.method-PATCH { background-color: rgba(80,227,194,.1); }
            #swagger-toolkit-sidebar .row.method-PATCH:hover { background-color: rgba(80,227,194,.5); }

            #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)); }
            #swagger-toolkit-sidebar .row .method { display: flex; line-height: 45px; min-width: 64px; }
            #swagger-toolkit-sidebar .row .path > a { color: #409EFF; }

            #swagger-toolkit-sidebar .row .btn-group { font-size: 12px; }
            #swagger-toolkit-sidebar .row .btn-group > a { text-decoration: none; display: block; }
            #swagger-toolkit-sidebar .row .btn-group > a:hover { font-size: 14px; }

            /* helper */
            .tool-text-size-fixed { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        `
        static inject() {
            const sheet = document.createTextNode(Sheets.sheets)
            const el = document.createElement('style')
            el.id = 'swagger-toolkit-sheets'
            el.appendChild(sheet)
            document.getElementsByTagName('head')[0].appendChild(el)
        }
    }
    class LinkStore {
        key = ''
        path = ''
        method = ''
        description = '' // 接口名
        id = ''
        createdat = 0
        static MAX_LENGTH = 10
        static save(row, key) {
            const store = new LinkStore()
            store.id = row.id
            store.key = key
            store.method = row.querySelector('.opblock-summary-method').innerText
            store.path = row.querySelector('.opblock-summary-path > a').innerText
            store.description = row.querySelector('.opblock-summary-description').innerText
            LinkStore.add(key, store)
        }
        static add(key, store, filterRepeat) {
            let data = LinkStore.getStore(key)
            if (filterRepeat) {
                for (const row of data) {
                    if (row.id === store.id && store.path === store.path) return false
                }
            }
            data.unshift(store)
            if (data.length > LinkStore.MAX_LENGTH) data = data.slice(0, LinkStore.MAX_LENGTH)
            localStorage.setItem(key, JSON.stringify(data))
        }
        static remove(key, index) {
            let data = LinkStore.getStore(key)
            data.splice(index, 1)
            localStorage.setItem(key, JSON.stringify(data))
        }
        static getStore(key) {
            let store = []
            try {
                const _store = localStorage.getItem(key)
                if (_store) store = JSON.parse(_store)
            } catch (err) {
                console.error(err)
            }
            return store
        }
    }
    class Pane {
        dom = null
        localKey = null
        title = null
        placeholder = '暂无数据'
        placeholder_en = 'no data'
        btnSave = '收藏'
        btnSave_en = 'add to favorites'
        btnRemove = '删除'
        btnRemove_en = 'remove'
        enableMarkBtn = false
        /**
         * 生成或更新当前 Pane
         * @description 将生成 `.list>(header>.title)+(a.row>(.method+.contents>(.description+a.path)))`
         */
        generateDom(isUpdate) {
            if (isUpdate) this.dom.innerHTML = ''
            const list = isUpdate ? this.dom : document.createElement('div')
            list.classList.add('list')
            list.classList.add(this.localKey)
            list.setAttribute('data-key', this.localKey)
            // 添加 header
            const header = document.createElement('header')
            const title = document.createElement('div')
            title.classList.add('title')
            title.innerText = this.getLabelByLanguage('title')
            list.appendChild(header)
            header.appendChild(title)
            // 添加数据
            const data = LinkStore.getStore(this.localKey)
            for (const dataRow of data) {
                const row = document.createElement('a')
                row.href = '#' + dataRow.id
                row.setAttribute('data-row', JSON.stringify(dataRow))
                const method = document.createElement('div')
                method.innerText = dataRow.method
                const contents = document.createElement('div')
                const description = document.createElement('div')
                description.innerText = dataRow.description
                const path = document.createElement('div')
                const pathLink = document.createElement('a')
                pathLink.innerText = dataRow.path
                pathLink.href = '#' + dataRow.id
                const btnGroup = document.createElement('div')
                const markBtn = document.createElement('a')
                if (this.enableMarkBtn) {
                    markBtn.href = 'javascript:;'
                    markBtn.setAttribute('title', this.getLabelByLanguage('btnSave'))
                    markBtn.innerText = '⭐️'
                }
                const deleteBtn = document.createElement('a')
                deleteBtn.href = 'javascript:;'
                deleteBtn.setAttribute('title', this.getLabelByLanguage('btnRemove'))
                deleteBtn.innerText = '✖️'

                row.classList.add('row')
                row.classList.add('method-' + dataRow.method)
                method.classList.add('method')
                contents.classList.add('contents')
                description.classList.add('description')
                description.classList.add('tool-text-size-fixed')
                path.classList.add('path')
                btnGroup.classList.add('btn-group')
                if (this.enableMarkBtn) markBtn.classList.add('btn-mark')
                deleteBtn.classList.add('btn-delete')

                path.appendChild(pathLink)
                contents.appendChild(description)
                contents.appendChild(path)
                // row.appendChild(method)
                row.appendChild(contents)
                row.appendChild(btnGroup)
                btnGroup.appendChild(deleteBtn)
                if (this.enableMarkBtn) btnGroup.appendChild(markBtn)
                list.appendChild(row)
            }
            if (data.length === 0) list.appendChild(this.getPlaceholderDom())
            this.dom = list
            if (typeof this.afterGenerageDom === 'function') this.afterGenerageDom()
            return list
        }
        getPlaceholderDom() {
            const dom = document.createElement('section')
            dom.innerText = this.getLabelByLanguage('placeholder')
            return dom
        }
        getLabelByLanguage(field, language) {
            let lang = language
            if (!lang) {
                const _lang = navigator.language
                lang = _lang.indexOf('zh') === 0 ? '' : 'en'
            }
            return this[`${field}${lang ? ('_' + lang) : '' }`]
        }
    }
    class HistoryPane extends Pane {
        localKey = 'swagger-toolkit-history'
        title = '浏览历史'
        title_en = 'History'
        placeholder = '暂无浏览历史数据'
        placeholder_en = 'No history at present'
        enableMarkBtn = true
    }
    class MarkPane extends Pane {
        localKey = 'swagger-toolkit-mark'
        title = '收藏夹'
        title_en = 'Favorites'
        placeholder = '暂无收藏数据, 点击 ⭐️ 按钮添加'
        placeholder_en = 'No favorite data, click ⭐️ button to add'
        afterGenerageDom() {
            this.dom
        }
    }
    class SideBar {
        static dom = null
        static panes = []
        static pathBtnGroupClassName = 'toolkit-path-btn-group'
        static copyInput = document.createElement('input')
        initCopyDOM() {
            SideBar.copyInput.classList.add('toolkit-hidden')
            document.body.appendChild(SideBar.copyInput)
            return this
        }
        addListeners() {
            window.addEventListener('hashchange', () => {
                const _path = location.hash.length > 0 ? location.hash.substr(1) : ''
                if (!_path) return
                const row = document.getElementById(_path) || (document.querySelector(`a[href="#${_path}"]`) && document.querySelector(`a[href="#${_path}"]`).closest('.opblock'))
                if (row) LinkStore.save(row, 'swagger-toolkit-history')
                this._updatePane('swagger-toolkit-history')
            })
            document.querySelector('#swagger-ui').addEventListener('mouseover', evt => {
                this._showPathBtnGroup(evt) // 显示在 path 栏中的按钮组
            })
            return this
        }
        _showPathBtnGroup(evt) {
            const opblock = evt.target.closest('.opblock')
            if (!opblock) return
            this._appendPathBtnGroupDOM(opblock)
        }
        _appendPathBtnGroupDOM(opblock) {
            if (opblock.querySelector('.' + SideBar.pathBtnGroupClassName)) return
            const group = document.createElement('div')
            const copyBtn = document.createElement('a')
            group.classList.add(SideBar.pathBtnGroupClassName)
            copyBtn.setAttribute('href', 'javascript:;')
            copyBtn.classList.add('btn-copy')
            copyBtn.innerText = '🔗'
            copyBtn.setAttribute('title', 'copy')
            group.appendChild(copyBtn)
            copyBtn.addEventListener('click', evt => {
                this._copyPath(evt)
            })

            const pathDOM = opblock.querySelector('.opblock-summary-path')
            if (pathDOM) pathDOM.appendChild(group)
        }
        _copyPath(evt) {
            evt.stopPropagation()
            const pathDOM = evt.target.closest('.opblock-summary-path')
            if (!pathDOM) return
            const pathLink = pathDOM.querySelector('a')
            if (!pathLink) return
            const path = pathLink.innerText
            SideBar.copyInput.value = path
            SideBar.copyInput.select()
            document.execCommand('Copy')
            console.log('copy successfuly')
        }
        generateDom() {
            const sidebar = document.createElement('sidebar')
            sidebar.id = 'swagger-toolkit-sidebar'
            SideBar.dom = sidebar
            return this
        }
        inject() {
            document.body.appendChild(SideBar.dom)
            return this
        }
        appendPanes() {
            for (const pane of SideBar.panes) {
                SideBar.dom.appendChild(pane.generateDom())
            }
            return this
        }
        _updatePane(key) {
            for (const pane of SideBar.panes) {
                if (pane.localKey !== key) continue
                pane.generateDom(true)
            }
        }
        appendPanesListeners() {
            SideBar.dom.addEventListener('click', evt => {
                if (evt.target.classList.contains('btn-delete')) {
                    evt.preventDefault()
                    evt.stopPropagation()
                    const index = this._getRowIndex({ btnItem: evt.target })
                    const key = evt.target.parentNode.parentNode.parentNode.getAttribute('data-key')
                    LinkStore.remove(key, index)
                    this._updatePane(key)
                } else if (evt.target.classList.contains('btn-mark')) {
                    evt.preventDefault()
                    evt.stopPropagation()
                    const row = evt.target.parentNode.parentNode.getAttribute('data-row')
                    LinkStore.add('swagger-toolkit-mark', JSON.parse(row), true)
                    this._updatePane('swagger-toolkit-mark')
                }
            })
        }
        _getRowIndex({ btnItem }) {
            const listDom = Array.from(btnItem.parentNode.parentNode.parentNode.children)
            for (let index = listDom.length; index--;) {
                if (listDom[index] === btnItem.parentNode.parentNode) return index - 1
            }
            return -1
        }
    }
    Sheets.inject()
    SideBar.panes.push(new HistoryPane())
    SideBar.panes.push(new MarkPane())
    window.$$_SideBar = new SideBar()
    window.$$_SideBar
        .initCopyDOM()
        .addListeners()
        .generateDom()
        .appendPanes()
        .inject()
        .appendPanesListeners()
})();