Greasy Fork is available in English.

Anki_Search

同步搜索Anki上的内容,支持google、bing、yahoo、百度。依赖AnkiConnect(插件:2055492159)

// ==UserScript==
// @name         Anki_Search
// @namespace    https://github.com/yekingyan/anki_search_on_web/
// @version      1.0.8
// @description  同步搜索Anki上的内容,支持google、bing、yahoo、百度。依赖AnkiConnect(插件:2055492159)
// @author       Yekingyan
// @run-at       document-start
// @include      *://www.google.com/*
// @include      *://www.google.com.*/*
// @include      *://www.google.co.*/*
// @include      *://mijisou.com/*
// @include      *://*.bing.com/*
// @include      *://search.yahoo.com/*
// @include      *://www.baidu.com/*
// @include      *://ankiweb.net/*
// @grant        unsafeWindow
// ==/UserScript==

/**
 * version change
 *  - fix replace target width
 */

const URL = "http://127.0.0.1:8765"
const SEARCH_FROM = "-deck:English"
const MAX_CARDS = 37

// set card size
const MIN_CARD_WIDTH = 30
const MAX_CARD_WIDTH = 40
const MAX_CARD_HEIGHT = 70
const MAX_IMG_WIDTH = MAX_CARD_WIDTH - 3

// adaptor
const HOST_MAP = new Map([
    ["local", ["#anki-q", "#anki-card"]],
    ["google", ["#APjFqb", "#rhs"]],
    ["bing", ["#sb_form_q", "#b_context"]],
    ["yahoo", ["#yschsp", "#right"]],
    ["baidu", ["#kw", "#content_right"]],
    ["anki", [".form-control", "#content_right"]],
    ["mijisou", ["#q", "#sidebar_results"]],
    // ["duckduckgo", ["#search_form_input", ".results--sidebar"]],
])

const INPUT_WAIT_MS = 700


// utils
function log() {
    console.log.apply(console, arguments)
}


function* counter() {
    /**
     * 计数器,统计请求次数
     */
    let val = 0
    let skip = 0
    while (true) {
        skip = yield val
        val = val + 1 + (skip === undefined ? 0 : skip)
    }
}
let g_counterReqText = counter()
let g_counterReqSrc = counter()
g_counterReqText.next()
g_counterReqSrc.next()


class Singleton {
    constructor() {
        const instance = this.constructor.instance
        if (instance) {
            return instance
        }
        this.constructor.instance = this
    }
}


// request and data
class Api{
    static _commonData(action, params) {
        /**
         * 请求表单的共同数据结构
         * action: str findNotes notesInfo
         * params: dict
         * return: dict
         */
        return {
            "action": action,
            "version": 6,
            "params": params
        }
    }

    static async _searchByText(searchText) {
        /**
         * 通过文本查卡片ID
         */
        let query = `${SEARCH_FROM} ${searchText}`
        let data = this._commonData("findNotes", { "query": query })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            g_counterReqText.next()
            return await response.json()
        } catch (error) {
            console.log("Request searchByText Failed", error)
        }
    }

    static async _searchByID(ids) {
        /**
         * 通过卡片ID获取卡片内容
         */
        let data = this._commonData("notesInfo", { "notes": ids })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            g_counterReqText.next()
            return await response.json()
        } catch (error) {
            console.log("Request searchByID Failed", error)
        }
    }

    static async searchImg(filename) {
        /**
         * 搜索文件名 返回 资源的base64编码
         * return base64 code
         */
        let data = this._commonData("retrieveMediaFile", { "filename": filename })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            let res = await response.json()
            g_counterReqSrc.next()
            return res.result
        } catch (error) {
            log("Request searchImg Failed", error, filename)
        }
    }

    static formatBase64Img(base64) {
        let src = `data:image/png;base64,${base64}`
        return src
    }

    static async searchImgBase64(filename) {
        let res = await this.searchImg(filename)
        let base64Img = this.formatBase64Img(res)
        return base64Img
    }

    static async search(searchText) {
        /**
         * 结合两次请求, 一次完整的搜索
         * searchValue: 搜索框的内容
         */
        if (searchText.length === 0) {
            return []
        }
        try {
            let idRes = await this._searchByText(searchText)
            let ids = idRes.result
            ids.length >= MAX_CARDS ? ids.length = MAX_CARDS : null
            let cardRes = await this._searchByID(ids)
            let cards = cardRes.result
            return cards
        } catch (error) {
            log("Request search Failed", error, searchText)
        }
    }
}


class Card {
    constructor(id, index, frontCardContent, backCardData, parent) {
        this.id = id
        this.index = index
        this.isfirstChild = index === 1
        this.frontCardContent = frontCardContent  // strContent
        this.backCardData = backCardData  // [order, field, content]
        this.backCardData.sort((i, j) => i > j ? 1 : -1)
        this.parent = parent

        this._cardHTML = null
        this._title = null
        this.isExtend = null
        this.bodyDom = null
        this.titleDom = null
    }

    get title() {
        let title = ""
        let parseTitle = this.frontCardContent.split(/<div.*?>/)
        let blankHead = parseTitle[0].split(/\s+/)
        //有div的情况
        if (this.frontCardContent.includes("</div>")) {
            // 第一个div之前不是全部都是空白,就是标题
            if (!/^\s+$/.test(blankHead[0]) && blankHead[0] !== "") {
                title = blankHead
            } else {
                // 标题是第一个div标签的内容
                title = parseTitle[1].split("</div>")[0]
            }
        } else {
            //没有div的情况
            title = this.frontCardContent
        }
        this._title = title
        title = this.index + "、" + title
        return title
    }

    get forntCard() {
        if (this._title === this.frontCardContent) {
            let arrow = `<span style="padding-left: 4.5em;">↓</span>`
            let arrows = ""
            for (let index = 0; index < 4; index++) {
                arrows = arrows + arrow
            }
            return `<div style="text-align: center;">↓${arrows}</div>`
        }
        return this.frontCardContent
    }

    get backCard() {
        let back = ""
        if (this.backCardData.length <= 1) {
            back += this.backCardData[0][2]
        } else {
            this.backCardData.forEach(item => {
                let order, field, content
                [order, field, content] = item
                if (content.length > 0) {
                    back += `<div class="anki-sub-title"><em>${field}</em></div>
                             <div calss="anki-sub-back-card">${content}</div><br>`
                }
            })
        }
        return back
    }

    get templateCard() {
        let template = `
            <div class="anki-card anki-card-size">
              <div class="anki-title" id="title-${this.id}">${this.title}</div>
              <div class="anki-body" id="body-${this.id}">
                <div class="anki-front-card">${this.forntCard}</div>
                <div class="anki-back-card">${this.backCard}</div>
              </div>
            </div>
            `
        return template
    }

    get cardHTML() {
        if (!this._cardHTML) {
            throw "pls requestCardSrc first"
        }
        return this._cardHTML
    }

    set cardHTML(cardHTML) {
        this._cardHTML = cardHTML
    }

    async replaceImg(templateCard) {
        let reSrc = /src="(.*?)"/g
        let reFilename = /src="(?<filename>.*?)"/
        let srcsList = templateCard.match(reSrc)
        let temp = templateCard

        if (!srcsList) {
            return temp
        }

        await Promise.all(srcsList.map(async (i) => {
            let filename = i.match(reFilename).groups.filename
            let base64Img = await Api.searchImgBase64(filename)
            let orgImg = `<img src="${filename}"`
            let replaceImg = `<img class="anki-img-width" src="${base64Img}"`
            temp = temp.replace(orgImg, replaceImg)
        }))

        return temp
    }

    async requestCardSrc() {
        let templateCard = await this.replaceImg(this.templateCard)
        this.cardHTML = templateCard
        return templateCard
    }

    showSelTitleClass(show) {
        let selTitleClass = "anki-title-sel"
        show 
            ? this.titleDom.classList.add(selTitleClass)
            : this.titleDom.classList.remove(selTitleClass)
    }

    setExtend(show) {
        if (this.isExtend === show) {
            return
        } else {
            let hideClass = "anki-collapsed"
            let showClass = "anki-extend"
            if (show) {
                this.bodyDom.classList.add(showClass)
                this.bodyDom.classList.remove(hideClass)
            } else {
                this.bodyDom.classList.add(hideClass)
                this.bodyDom.classList.remove(showClass)
            }

            this.isExtend = show
            this.showSelTitleClass(show)
        }
    }

    tryCollapse() {
        if (!this.isfirstChild) {
            this.setExtend(false)
            return
        }
        this.isExtend = true
        this.showSelTitleClass(true)
    }

    listenEvent() {
        this.titleDom = window.top.document.getElementById(`title-${this.id}`)
        this.titleDom.addEventListener("click", () => this.onClick())

        this.bodyDom = window.top.document.getElementById(`body-${this.id}`)
        this.bodyDom.addEventListener("animationend", () => this.onAniEnd())
    }

    onClick() {
        this.parent.onCardClick(this)
        let show = !this.isExtend
        this.setExtend(show)
    }

    onAniEnd() {
        if (this.isExtend) {
            window.scroll(window.outerWidth, window.pageYOffset)
        }
    }

    onInsert() {
        this.listenEvent()
        this.tryCollapse()
    }

}


class CardMgr extends Singleton {
    constructor () {
        super()
        this.cards = []
    }

    formatCardsData(cardsData) {
        /** turn cardData 2 cardObj */
        let cards = []
        cardsData.forEach((item, index) => {
            let id = item.noteId
            let frontCard = []
            let backCards = []
            for (const [k, v] of Object.entries(item.fields)) {
                if (v.order === 0) {
                    frontCard = v.value
                    continue
                }
                backCards.push([v.order, k, v.value])
            }
            let card = new Card(id, index+1, frontCard, backCards, this)
            cards.push(card)
        })
        return cards
    }

    insertCardsDom(cards) {
        if (!DomOper.getContainer()) {
            return
        }
        DomOper.clearContainer()
        cards.forEach(card => {
            DomOper.getContainer().insertAdjacentHTML("beforeend", card.cardHTML)
            card.onInsert()
        })
    }

    async searchAndInsertCard(searchValue) {
        DomOper.insertContainerOnce()
        if (!DomOper.getContainer()) {
            return
        }
        let cardsData = await Api.search(searchValue)
        let cards = this.formatCardsData(cardsData)
        this.cards = cards
        await Promise.all(cards.map(async (card) => await card.requestCardSrc()))
        this.insertCardsDom(cards)
        log(
            `total req: ${g_counterReqText.next(-1).value + g_counterReqSrc.next(-1).value}\n`,
            `req searchText: ${g_counterReqText.next(-1).value}\n`,
            `req searchSrc: ${g_counterReqSrc.next(-1).value}\n`,
        )
    }

    onCardClick(curCard) {
        this.cards.forEach( card => {
            if (card !== curCard) {
                card.setExtend(false)
            }
        })
    }

}


// dom
const REPLACE_TARGET_ID = "anki-replace-target"
const REPLACE_TARGET = `<div id="${REPLACE_TARGET_ID}"><div>`

const CONTAINER_ID = "anki-container"
const CONTAINER = `<div id="${CONTAINER_ID}"><div>`

class DomOper {
    static getHostSearchInputAndTarget() {
        /**
         * 获取当前网站的搜索输入框 与 需要插入的位置
         *  */
        let host = window.location.host || "local"
        let searchInput = null  // 搜索框
        let targetDom = null    // 左边栏的父节点
        this.removeReplaceTargetDom()

        for (let [key, value] of HOST_MAP) {
            if (host.includes(key)) {
                searchInput = window.top.document.querySelector(value[0])
                targetDom = window.top.document.querySelector(value[1])
                break
            }
        }
        if (!targetDom) {
            targetDom = this.getOrCreateReplaceTargetDom()
        }

        return [searchInput, targetDom]
    }

    // listen input
    static addInputEventListener(searchInput) {
        function onSearchTextInput(event) {
            lastInputTs = event.timeStamp
            searchText = event.srcElement.value
            setTimeout(() => {
                if (event.timeStamp === lastInputTs) {
                    new CardMgr().searchAndInsertCard(searchText)
                }
            }, INPUT_WAIT_MS)
        }
        let lastInputTs, searchText
        searchInput.addEventListener("input", onSearchTextInput)
    }

    static getReplaceTargetDom() {
        return window.top.document.getElementById(REPLACE_TARGET_ID)
    }

    static createReplaceTargetDom() {
        let targetDomParent = window.top.document.getElementById("rcnt")
        if (targetDomParent) {
            targetDomParent.insertAdjacentHTML("beforeend", REPLACE_TARGET)
        }
    }

    static getOrCreateReplaceTargetDom() {
        if (!this.getReplaceTargetDom()) {
            this.createReplaceTargetDom()
        }
        return this.getReplaceTargetDom()
    }

    static removeReplaceTargetDom () {
        if (!this.getReplaceTargetDom()) {
            return
        }
        this.getReplaceTargetDom().remove()
    }

    static insertCssStyle() {
        let headDom = window.top.document.getElementsByTagName("HEAD")[0]
        headDom.insertAdjacentHTML("beforeend", style)
    }

    static insertContainerOnce(targetDom) {
        if (this.getContainer()) {
            return
        }
        targetDom = targetDom ? targetDom : this.getHostSearchInputAndTarget()[1]
        if (!targetDom) {
            log("AKS can't insert cards container", targetDom)
            return
        }
        targetDom.insertAdjacentHTML("afterbegin", CONTAINER)
        this.insertCssStyle()
    }

    static getContainer() {
        return window.top.document.getElementById(CONTAINER_ID)
    }

    static clearContainer() {
        this.getContainer().innerHTML = ""
    }

    static replaceImgHTML(html, filename, base64Img) {
        let orgImg = `<img src="${filename}"`
        let replaceImg = `<img class="anki-img-width" src="${base64Img}"`
        html = html.replace(orgImg, replaceImg)
        return html
    }

}


async function main() {
    log("Anki Serarch Launching")
    let [searchInput, targetDom] = DomOper.getHostSearchInputAndTarget()
    if (!searchInput) {
        log("AKS can't find search input", searchInput)
        return
    }
    DomOper.addInputEventListener(searchInput)
    DomOper.insertContainerOnce(targetDom)

    let searchText = searchInput.value
    new CardMgr().searchAndInsertCard(searchText)
}


window.onload = main


const style = `
  <style>
  /*card*/
  .anki-card-size {
    min-width: ${MIN_CARD_WIDTH}em; 
    max-width: ${MAX_CARD_WIDTH}em;
    max-height: ${MAX_CARD_HEIGHT}em;
  }

  .anki-img-width {
    max-width: ${MAX_IMG_WIDTH}em; 
  }
 
  .anki-card {
    position: relative;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-direction: column;
    flex-direction: column;
    word-wrap: break-word;
    width:fit-content; 
    width:-webkit-fit-content;
    width:-moz-fit-content;
    margin-bottom: .25em;
    border: .1em solid #69928f;
    // border-radius: calc(.7em - 1px);
    border-radius: .7em;
  }

  .anki-body {
    overflow-x: visible;
    overflow-y: auto;
  }

  /* card title */
  .anki-title {
    padding: .75em;
    margin: 0em;
    font-weight: 700;
    color: black;
    background-color: #e0f6f9;
    // border-radius: calc(.5em - 1px);
    border-radius: .7em;

    transition-property: all;
    transition-duration: 1.5s;
    transition-timing-function: ease-out;
  }

  .anki-title-sel {
      animation-name: select-title;
      animation-duration: 5s;
      animation-iteration-count: infinite;
      animation-direction: alternate;
  }

  .anki-title:hover{
    // background-color: #9791b1;
    background-color: #d2e4f9;
  }

  .anki-sub-title {
    color: #5F9EA0;
  }

  .anki-front-card {
    padding: .75em;
    border-bottom: solid .3em #c6e1e4;
  }

  .anki-back-card {
    padding: .75em .75em;
  }

  .anki-collapsed {
    overflow: hidden;
    animation-name: collapsed;
    animation-duration: .3s;
    animation-timing-function: ease-out;
    animation-fill-mode:forwards;
    animation-direction: normal;
  }

  .anki-extend {
    overflow-x: visible;
    animation-name: extend;
    animation-duration: .3s;
    animation-timing-function: ease-in;
    animation-fill-mode:forwards;
    animation-direction: normal;
  }

  div#anki-container ul {
    margin-bottom: 1em;
    margin-left: 2em;
  }

  div#anki-container ol {
    margin-bottom: 1em;
    margin-left: 2em;
  }

  div#anki-container ul li{
    list-style-type: disc;
  }

  div#anki-container ul ul li{
    list-style-type: circle;
  }

  div#anki-container ul ul ul li{
    list-style-type: square;
  }

  div#anki-container ul ul ul ul li{
    list-style-type: circle;
  }

  div#anki-replace-target {
    margin-left: 2em;
    width: ${MIN_CARD_WIDTH}em;
    max-width: ${MAX_CARD_WIDTH}em;
    float: right;
    display: block;
    position: relative;
}

  @keyframes collapsed
    {
      0%   {max-height: ${MAX_CARD_HEIGHT}em; max-width: ${MAX_CARD_WIDTH}em;}
      100% {max-height: 0em; max-width: 30em;}
    }

  @keyframes extend
    {
      0%   {max-height: 0em; max-width: ${MIN_CARD_WIDTH}em;}
      100% {max-height: ${MAX_CARD_WIDTH}em; max-width: ${MAX_CARD_WIDTH}em;}
    }

  @keyframes select-title
    {
      0%   {background: #e0f6f9;}
      50%  {background: #e1ddf3;}
      100% {background: #d2e4f9;}
    }

  /**
   * hljs css
   */
  pre code.hljs {
    display: block;
    overflow-x: auto;
    padding: 1em
  }
  
  code.hljs {
    padding: 3px 5px
  }
  
  .hljs {
    color: #e0e2e4;
    background: #282b2e
  }
  
  .hljs-keyword, .hljs-literal, .hljs-selector-id, .hljs-selector-tag {
    color: #93c763
  }
  
  .hljs-number {
    color: #ffcd22
  }
  
  .hljs-attribute {
    color: #668bb0
  }
  
  .hljs-link, .hljs-regexp {
    color: #d39745
  }
  
  .hljs-meta {
    color: #557182
  }
  
  .hljs-addition, .hljs-built_in, .hljs-bullet, .hljs-emphasis, .hljs-name, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-subst, .hljs-tag, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable {
    color: #8cbbad
  }
  
  .hljs-string, .hljs-symbol {
    color: #ec7600
  }
  
  .hljs-comment, .hljs-deletion, .hljs-quote {
    color: #818e96
  }
  
  .hljs-selector-class {
    color: #a082bd
  }
  
  .hljs-doctag, .hljs-keyword, .hljs-literal, .hljs-name, .hljs-section, .hljs-selector-tag, .hljs-strong, .hljs-title, .hljs-type {
    font-weight: 700
  }
  
  .hljs-class .hljs-title, .hljs-code, .hljs-section, .hljs-title.class_ {
    color: #fff
  }
  </style>
`