<commit_before>require('./ipc')

const fs = require('fs')
const {ipcRenderer, clipboard, remote} = require('electron')
const {app, dialog, Menu} = remote
const EventEmitter = require('events')
const Pikaday = require('pikaday')

const view = require('./view')
const $ = require('../modules/sprint')
const sgf = require('../modules/sgf')
const fuzzyfinder = require('../modules/fuzzyfinder')
const gametree = require('../modules/gametree')
const sound = require('../modules/sound')
const helper = require('../modules/helper')
const setting = require('../modules/setting')
const gtp = require('../modules/gtp')
const Board = require('../modules/board')

window.sabaki = {
    view,
    events: new EventEmitter(),
    modules: {sgf, gametree, sound, setting}
}

/**
 * Getters & Setters
 */

sabaki.getGameTrees = function() {
    let trees = $('body').data('gametrees')
    return trees ? trees : [sabaki.getRootTree()]
}

sabaki.setGameTrees = function(trees) {
    trees.forEach(tree => tree.parent = null)
    $('body').data('gametrees', trees)
}

sabaki.getGameIndex = function() {
    return sabaki.getGameTrees().length == 1 ? 0 : $('body').data('gameindex')
}

sabaki.setGameIndex = function(index) {
    let trees = sabaki.getGameTrees()
    $('body').data('gameindex', index)

    sabaki.setGameTrees(trees)
    sabaki.setRootTree(trees[index])
    view.updateTitle()
    sabaki.setUndoable(false)
    if (setting.get('game.goto_end_after_loading')) sabaki.goToEnd()
}

sabaki.getRootTree = function() {
    if (!sabaki.getCurrentTreePosition()) return null
    return sabaki.getGameTrees()[sabaki.getGameIndex()]
}

sabaki.setRootTree = function(tree) {
    if (tree.nodes.length == 0) return

    let trees = sabaki.getGameTrees()
    trees[sabaki.getGameIndex()] = tree
    sabaki.setGameTrees(trees)

    tree.parent = null
    gametree.getBoard(tree)
    sabaki.setCurrentTreePosition(tree, 0, true, true)

    view.setPlayerName(1,
        gametree.getPlayerName(tree, 1, 'Black'),
        'BR' in tree.nodes[0] ? tree.nodes[0].BR[0] : ''
    )
    view.setPlayerName(-1,
        gametree.getPlayerName(tree, -1, 'White'),
        'WR' in tree.nodes[0] ? tree.nodes[0].WR[0] : ''
    )
}

sabaki.getTreeHash = function() {
    return $('body').data('treehash')
}

sabaki.getFileHash = function() {
    return $('body').data('filehash')
}

sabaki.getGraphMatrixDict = function() {
    return $('#graph').data('graphmatrixdict')
}

sabaki.setGraphMatrixDict = function(matrixdict) {
    if (!view.getShowSidebar()) return

    let s, graph

    try {
        s = $('#graph').data('sigma')
        graph = gametree.matrixdict2graph(matrixdict)
    } catch (err) { }

    try {
        if (s && graph) {
            s.graph.clear()
            s.graph.read(graph)
        }
    } catch (err) {
        sabaki.setGraphMatrixDict(matrixdict)
    }

    $('#graph').data('graphmatrixdict', matrixdict)
}

sabaki.setCurrentPlayer = function(sign) {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]
    let intendedSign = 'B' in node ? -1 : +('W' in node)

    if (intendedSign == sign) {
        delete node.PL
    } else {
        node.PL = [sign > 0 ? 'B' : 'W']
    }

    view.setCurrentPlayer(sign)
}

sabaki.getCurrentPlayer = function() {
    return view.getCurrentPlayer()
}

sabaki.getCurrentTreePosition = function() {
    return $('#goban').data('position')
}

sabaki.setCurrentTreePosition = function(tree, index, now = false, redraw = false, ignoreAutoplay = false) {
    if (!tree || view.getScoringMode() || view.getEstimatorMode()) return
    if (!ignoreAutoplay && sabaki.getAutoplaying()) sabaki.setAutoplaying(false)

    // Remove old graph node color

    let oldGraphNode = sabaki.getCurrentGraphNode()
    let oldPos = sabaki.getCurrentTreePosition()
    let graphNode = sabaki.getGraphNode(tree, index)

    if (oldGraphNode && oldGraphNode != graphNode)
        oldGraphNode.color = oldGraphNode.originalColor

    // Store new position

    $('#goban').data('position', [tree, index])
    redraw = !!redraw
        || !graphNode
        || !gametree.onCurrentTrack(tree)
        || tree.collapsed

    let t = tree
    t.collapsed = false
    while (t.parent && t.parent.collapsed) {
        redraw = true
        t.parent.collapsed = false
        t = t.parent
    }

    // Update bookmark, graph, slider and comment text

    let node = tree.nodes[index]

    sabaki.updateSidebar(redraw, now)
    view.setShowHotspot('HO' in node)
    sabaki.setBoard(gametree.getBoard(tree, index))

    // Determine current player

    let currentplayer = 1

    if ('B' in node || 'HA' in node && +node.HA[0] >= 1)
        currentplayer = -1

    if ('PL' in node)
        currentplayer = node.PL[0] == 'W' ? -1 : 1

    view.setCurrentPlayer(currentplayer)
}

sabaki.getCurrentGraphNode = function() {
    let pos = sabaki.getCurrentTreePosition()
    if (!pos) return null
    return sabaki.getGraphNode(...pos)
}

sabaki.getGraphNode = function(tree, index) {
    let id = typeof tree === 'object' ? tree.id + '-' + index : tree
    let s = $('#graph').data('sigma')
    return s.graph.nodes(id)
}

sabaki.getSelectedTool = function() {
    let $li = $('#edit .selected')
    let tool = $li.attr('class').replace('selected', '').replace('-tool', '').trim()

    if (tool == 'stone') {
        return $li.find('img').attr('src').includes('_1') ? 'stone_1' : 'stone_-1'
    } else if (tool == 'line') {
        return $li.find('img').attr('src').includes('line') ? 'line' : 'arrow'
    } else {
        return tool
    }
}

sabaki.setSelectedTool = function(tool) {
    if (!view.getEditMode()) {
        view.setEditMode(true)
        if (sabaki.getSelectedTool().includes(tool)) return
    }

    $('#goban').data('edittool-data', null)
    $('#edit .' + tool + '-tool a').trigger('click')
}

sabaki.getBoard = function() {
    return $('#goban').data('board')
}

sabaki.setBoard = function(board) {
    let $goban = $('#goban')

    if (!sabaki.getBoard()
    || sabaki.getBoard().width != board.width
    || sabaki.getBoard().height != board.height) {
        $goban.data('board', board)
        view.buildBoard()
    }

    $goban.data('board', board)
    view.setCaptures(board.captures)

    for (let x = 0; x < board.width; x++) {
        for (let y = 0; y < board.height; y++) {
            let $li = $goban.find('.pos_' + x + '-' + y)
            let $span = $li.find('.stone span')
            let sign = board.get([x, y])

            // Clean up

            let types = ['ghost_1', 'ghost_-1', 'siblingghost_1', 'siblingghost_-1',
                'circle', 'triangle', 'cross', 'square', 'label',
                'point', 'dimmed', 'paint_1', 'paint_-1',
                'badmove', 'doubtfulmove', 'interestingmove', 'goodmove']

            types.forEach(x => $li.hasClass(x) ? $li.removeClass(x) : null)
            $span.attr('title', '')

            // Add markups

            if ([x, y] in board.markups) {
                let [type, label] = board.markups[[x, y]]

                if (type != '') $li.addClass(type)
                if (label != '') $span.attr('title', label)

                $li.toggleClass('smalllabel', label.length >= 3)
            }

            // Set stone image

            if ($li.hasClass('sign_' + sign)) continue

            for (let i = -1; i <= 1; i++) {
                if ($li.hasClass('sign_' + i)) $li.removeClass('sign_' + i)
            }

            $li.addClass('sign_' + sign)
        }
    }

    // Add ghosts

    for (let [v, s, types] of board.ghosts) {
        let $li = $('#goban .pos_' + v.join('-'))

        for (let type of types) {
            if (type == 'child') $li.addClass('ghost_' + s)
            else if (type == 'sibling') $li.addClass('siblingghost_' + s)
            else if (type == 'badmove') $li.addClass('badmove')
            else if (type == 'doubtfulmove') $li.addClass('doubtfulmove')
            else if (type == 'interestingmove') $li.addClass('interestingmove')
            else if (type == 'goodmove') $li.addClass('goodmove')
        }
    }

    // Add lines

    $('#goban hr').remove()

    for (let [v1, v2, arrow] of board.lines) {
        $goban.append(
            $('<hr/>').addClass(arrow ? 'arrow' : 'line').data('v1', v1).data('v2', v2)
        )
    }

    view.updateBoardLines()
}

sabaki.getScoringMethod = function() {
    return $('#score .tabs .territory').hasClass('current') ? 'territory' : 'area'
}

sabaki.setScoringMethod = function(method) {
    $('#score .tabs li').removeClass('current')
    $('#score .tabs .' + method).addClass('current')
    $('#score tr > *').addClass('disabled')
    $('#score table .' + method).removeClass('disabled')

    setting.set('scoring.method', method)

    // Update UI

    for (let sign = -1; sign <= 1; sign += 2) {
        let $tr = $('#score tbody tr' + (sign < 0 ? ':last-child' : ''))
        let $tds = $tr.find('td')

        $tds.eq(4).text(0)

        for (let i = 0; i <= 3; i++) {
            if ($tds.eq(i).hasClass('disabled') || isNaN(+$tds.eq(i).text())) continue
            $tds.eq(4).text(+$tds.eq(4).text() + +$tds.eq(i).text())
        }
    }

    let results = $('#score tbody td:last-child').get().map(td => $(td).text())
    let diff = +results[0] - +results[1]
    let result = diff > 0 ? 'B+' :  diff < 0 ? 'W+' : 'Draw'
    if (diff != 0) result = result + Math.abs(diff)

    $('#score .result').text(result)
}

sabaki.getKomi = function() {
    let rootNode = sabaki.getRootTree().nodes[0]
    return 'KM' in rootNode ? +rootNode.KM[0] : 0
}

sabaki.getEngineName = function() {
    return $('#console').data('enginename')
}

sabaki.getEngineController = function() {
    return $('#console').data('controller')
}

sabaki.getEngineCommands = function() {
    return $('#console').data('commands')
}

sabaki.setUndoable = function(undoable, tooltip = 'Undo') {
    if (undoable) {
        let rootTree = gametree.clone(sabaki.getRootTree())
        let level = gametree.getLevel(...sabaki.getCurrentTreePosition())

        $('#bar header .undo').attr('title', tooltip)
        $('body')
        .addClass('undoable')
        .data('undodata-root', rootTree)
        .data('undodata-level', level)
    } else {
        $('body')
        .removeClass('undoable')
        .data('undodata-root', null)
        .data('undodata-level', null)
    }
}

sabaki.getHotspot = function() {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]

    return 'HO' in node
}

sabaki.setHotspot = function(bookmark) {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]

    if (bookmark) node.HO = [1]
    else delete node.HO

    sabaki.updateGraph()
    view.setShowHotspot(bookmark)
}

sabaki.getEmptyGameTree = function() {
    let handicap = setting.get('game.default_handicap')
    let size = setting.get('game.default_board_size').toString().split(':').map(x => +x)
    let stones = new Board(size[0], size.slice(-1)[0]).getHandicapPlacement(handicap).map(sgf.vertex2point)

    let buffer = [
        `;GM[1]FF[4]CA[UTF-8]`,
        `AP[${app.getName()}:${app.getVersion()}]`,
        `KM[${setting.get('game.default_komi')}]`,
        `SZ[${size[0]}:${size.slice(-1)[0]}]`,
        stones.length > 0 ? `HA[${handicap}]AB[${stones.join('][')}]` : ''
    ].join('')

    return sgf.parse(sgf.tokenize(buffer))
}

sabaki.getAutoplaying = function() {
    return view.getAutoplayMode() && $('#autoplay').hasClass('playing')
}

sabaki.setAutoplaying = function(playing) {
    let autoplay = () => {
        if (!sabaki.getAutoplaying()) return

        let tp = gametree.navigate(...sabaki.getCurrentTreePosition(), 1)
        if (!tp) {
            sabaki.setAutoplaying(false)
            return
        }

        let node = tp[0].nodes[tp[1]]

        if (!node.B && !node.W) {
            sabaki.setCurrentTreePosition(...tp, false, false, true)
        } else {
            let vertex = sgf.point2vertex(node.B ? node.B[0] : node.W[0])
            view.setCurrentPlayer(node.B ? 1 : -1)
            sabaki.makeMove(vertex, false, true)
        }

        let id = setTimeout(autoplay, setting.get('autoplay.sec_per_move') * 1000)
        $('#autoplay').data('timeoutid', id)
    }

    if (playing) {
        view.setAutoplayMode(playing)
        $('#autoplay').addClass('playing')
        autoplay()
    } else {
        clearTimeout($('#autoplay').data('timeoutid'))
        $('#autoplay').removeClass('playing')
    }
}

/**
 * Preparation Methods
 */

sabaki.loadSettings = function() {
    $('head link.userstyle').attr('href', setting.stylesPath)

    $('#goban')
    .toggleClass('fuzzy', setting.get('view.fuzzy_stone_placement'))
    .toggleClass('animation', setting.get('view.animated_stone_placement'))
    .toggleClass('coordinates', setting.get('view.show_coordinates'))
    .toggleClass('movecolorization', setting.get('view.show_move_colorization'))
    .toggleClass('variations', setting.get('view.show_next_moves'))
    .toggleClass('siblings', setting.get('view.show_siblings'))

    if (setting.get('view.show_leftsidebar')) {
        $('body').addClass('leftsidebar')
        view.setLeftSidebarWidth(setting.get('view.leftsidebar_width'))
    }

    if (setting.get('view.show_graph') || setting.get('view.show_comments')) {
        $('body').addClass('sidebar')
        view.setSidebarArrangement(setting.get('view.show_graph'), setting.get('view.show_comments'))
        view.setSidebarWidth(setting.get('view.sidebar_width'))
    }
}

sabaki.prepareBars = function() {
    // Handle close buttons

    let bars = ['edit', 'guess', 'autoplay', 'scoring', 'estimator', 'find']

    for (let id of bars) {
        let funcName = 'set' + id[0].toUpperCase() + id.slice(1) + 'Mode'
        $(`#${id} > .close`).on('click', () => view[funcName](false))
    }

    // Handle header bar

    $('header .undo').on('click', () => sabaki.undoBoard())
    $('#headermenu').on('click', () => view.openHeaderMenu())

    // Handle autoplay bar

    $('#autoplay .play').on('click', () => sabaki.setAutoplaying(!sabaki.getAutoplaying()))

    // Handle scoring/estimator bar and drawer

    $('#scoring button, #estimator button').on('click', evt => {
        evt.preventDefault()
        view.showScore()
    })

    $('#score .tabs .area a').on('click', () => sabaki.setScoringMethod('area'))
    $('#score .tabs .territory a').on('click', () => sabaki.setScoringMethod('territory'))
    $('#score button[type="reset"]').on('click', () => view.closeScore())
    $('#score button[type="submit"]').on('click', evt => {
        evt.preventDefault()
        sabaki.commitScore()
        view.closeScore()
    })

    // Handle find bar

    $('#find button').get().forEach((el, i) => {
        $(el).on('click', evt => {
            evt.preventDefault()
            sabaki.findMove(view.getIndicatorVertex(), view.getFindText(), 1 - i * 2)
        })
    })

    // Handle current player toggler

    $('header .current-player').on('click', () => {
        sabaki.setCurrentPlayer(-sabaki.getCurrentPlayer())
    })
}

sabaki.prepareEditTools = function() {
    $('#edit ul a').on('click', function() {
        let $a = $(this)
        let $img = $a.find('img')

        if (!$a.parent().hasClass('selected')) {
            $('#edit .selected').removeClass('selected')
            $a.parent().addClass('selected')
        } else if ($a.parent().hasClass('stone-tool')) {
            let black = $img.attr('src').includes('_1')
            $img.attr('src', black ? '../img/edit/stone_-1.svg' : '../img/edit/stone_1.svg')
        } else if ($a.parent().hasClass('line-tool')) {
            let line = $img.attr('src').includes('line')
            $img.attr('src', line ? '../img/edit/arrow.svg' : '../img/edit/line.svg')
        }
    })
}

sabaki.prepareAutoplay = function() {
    $('#autoplay input').on('input', function() {
        let value = Math.min(10, Math.max(1, +$(this).val()))
        value = Math.floor(value * 10) / 10
        setting.set('autoplay.sec_per_move', value)
    }).on('blur', function() {
        $(this).val(setting.get('autoplay.sec_per_move'))
    })
}

sabaki.prepareSidebar = function() {
    // Prepare comments section

    $('#properties .header .edit-button').on('click', () => view.setEditMode(true))

    $('#properties .edit .header img').on('click', function() {
        let pos = [Math.round($(this).offset().left),
                   Math.round($(this).offset().top + $(this).height())]
        view.openCommentMenu(pos)
    })

    $('#properties .edit .header input, #properties .edit textarea')
    .on('input', () => sabaki.commitCommentText())

    // Prepare game graph

    let $container = $('#graph')
    let s = new sigma({
        renderer: {
            container: $container.get(0),
            type: 'canvas'
        },
        settings: {
            defaultNodeColor: setting.get('graph.node_inactive_color'),
            defaultEdgeColor: setting.get('graph.node_inactive_color'),
            defaultNodeBorderColor: 'rgba(255,255,255,.2)',
            edgeColor: 'default',
            borderSize: 2,
            zoomMax: 1,
            zoomMin: 1,
            autoResize: false,
            autoRescale: false
        }
    })

    let getTreePos = evt => evt.data.node.data.slice(0, 2)

    s.bind('clickNode', function(evt) {
        sabaki.setCurrentTreePosition(...getTreePos(evt), true)
    }).bind('rightClickNode', function(evt) {
        let pos = [evt.data.captor.clientX, evt.data.captor.clientY]
        view.openNodeMenu(...getTreePos(evt), pos.map(x => Math.round(x)))
    })

    $container.data('sigma', s)
}

sabaki.prepareSlider = function() {
    let $slider = $('#sidebar .slider .inner')

    let changeSlider = percentage => {
        percentage = Math.min(1, Math.max(0, percentage))

        let level = Math.round((gametree.getHeight(sabaki.getRootTree()) - 1) * percentage)
        let tp = gametree.navigate(sabaki.getRootTree(), 0, level)

        if (!tp) tp = gametree.navigate(
            sabaki.getRootTree(),
            0,
            gametree.getCurrentHeight(sabaki.getRootTree()) - 1
        )

        if (helper.equals(tp, sabaki.getCurrentTreePosition())) return
        sabaki.setCurrentTreePosition(...tp)
        sabaki.updateSlider()
    }

    let mouseMoveHandler = evt => {
        if (evt.button != 0 || !$slider.data('mousedown'))
            return

        let percentage = (evt.clientY - $slider.offset().top) / $slider.height()
        changeSlider(percentage)
        document.onselectstart = function() { return false }
    }

    $slider.on('mousedown', function(evt) {
        if (evt.button != 0) return

        $(this).data('mousedown', true).addClass('active')
        mouseMoveHandler(evt)
    }).on('touchstart', function() {
        $(this).addClass('active')
    }).on('touchmove', function(evt) {
        let percentage = (evt.client.y - $slider.offset().top) / $slider.height()
        changeSlider(percentage)
    }).on('touchend', function() {
        $(this).removeClass('active')
    })

    $(document).on('mouseup', function() {
        $slider.data('mousedown', false)
            .removeClass('active')
        document.onselectstart = null
    }).on('mousemove', mouseMoveHandler)

    // Prepare previous/next buttons

    $('#sidebar .slider a').on('mousedown', function() {
        $(this).data('mousedown', true)
        sabaki.startAutoScroll($(this).hasClass('next') ? 1 : -1)
    })

    $(document).on('mouseup', function() {
        $('#sidebar .slider a').data('mousedown', false)
    })
}

sabaki.prepareDragDropFiles = function() {
    $('body').on('dragover', function(evt) {
        evt.preventDefault()
    }).on('drop', function(evt) {
        evt.preventDefault()

        if (evt.dataTransfer.files.length == 0) return
        sabaki.loadFile(evt.dataTransfer.files[0].path)
    })
}

sabaki.prepareConsole = function() {
    $('#console form').on('submit', function(evt) {
        evt.preventDefault()

        let $input = $(this).find('input')
        if ($input.val().trim() == '') return
        $input.get(0).blur()

        let command = gtp.parseCommand($input.val())
        sabaki.sendGTPCommand(command)
    })

    $('#console form input').on('keydown', function(evt) {
        if ([40, 38, 9].includes(evt.keyCode)) evt.preventDefault()
        let $inputs = $('#console form input')

        if ($(this).data('index') == null) $(this).data('index', $inputs.get().indexOf(this))
        let i = $(this).data('index')
        let length = $inputs.length

        if ([38, 40].includes(evt.keyCode)) {
            if (evt.keyCode == 38) {
                // Up
                i = Math.max(i - 1, 0)
            } else if (evt.keyCode == 40) {
                // Down
                i = Math.min(i + 1, length - 1)
            }

            $(this)
            .val(i == length - 1 ? '' : $inputs.eq(i).val())
            .data('index', i)
        } else if (evt.keyCode == 9) {
            // Tab
            let tokens = $(this).val().split(' ')
            let commands = sabaki.getEngineCommands()
            if (!commands) return

            let i = 0
            let selection = this.selectionStart
            while (selection > tokens[i].length && selection.length != 0 && i < tokens.length - 1)
                selection -= tokens[i++].length + 1

            let result = fuzzyfinder.find(tokens[i], sabaki.getEngineCommands())
            if (!result) return
            tokens[i] = result

            this.value = tokens.join(' ')
            this.selectionStart = this.selectionEnd = (() => {
                let sum = 0
                while (i >= 0) sum += tokens[i--].length + 1
                return sum - 1
            })()
        }
    })
}

sabaki.prepareGameInfo = function() {
    $('#info button[type="submit"]').on('click', function(evt) {
        evt.preventDefault()
        sabaki.commitGameInfo()
        view.closeGameInfo()
    })

    $('#info button[type="reset"]').on('click', function(evt) {
        evt.preventDefault()
        view.closeGameInfo()
    })

    $('#info .current-player').on('click', function() {
        let data = $('#info section input[type="text"]').get().map(el => $(el).val())

        $('#info section input[name="rank_1"]').val(data[3])
        $('#info section input[name="rank_-1"]').val(data[0])
        $('#info section input[name="name_1"]').val(data[2])
        $('#info section input[name="name_-1"]').val(data[1])

        data = $('#info section .menu').get().map(el => {
            return [$(el).hasClass('active'), $(el).data('engineindex')]
        })

        $('#info section .menu').eq(0)
        .toggleClass('active', data[1][0])
        .data('engineindex', data[1][1])

        $('#info section .menu').eq(1)
        .toggleClass('active', data[0][0])
        .data('engineindex', data[0][1])
    })

    $('#info section img.menu').on('click', function() {
        let $el = $(this)

        let selectEngine = (el, engine, i) => {
            let currentIndex = $(this).data('engineindex')
            if (currentIndex == null) currentIndex = -1
            if (i == currentIndex) return

            $(el).parent().find('input[name^="name_"]').val(engine ? engine.name : '')
            $(el).data('engineindex', i)

            if (engine) {
                let els = $('#info section .menu').get()
                let other = els[0] == el ? els[1] : els[0]
                if (other) selectEngine(other, null, -1)

                $(el).addClass('active')
            } else {
                $('#info').find('section .menu')
                .removeClass('active')
            }
        }

        view.openEnginesMenu($el, (engine, i) => selectEngine($el.get(0), engine, i))
    })

    // Prepare date input

    let $dateInput = $('#info input[name="date"]')

    let adjustPosition = pikaday => {
        $(pikaday.el)
        .css('position', 'absolute')
        .css('left', Math.round($dateInput.offset().left))
        .css('top', Math.round($dateInput.offset().top - $(pikaday.el).height()))
    }

    let markDates = pikaday => {
        let dates = (sgf.string2dates($dateInput.val()) || []).filter(x => x.length == 3)

        for (let el of $(pikaday.el).find('.pika-button').get()) {
            let year = +$(el).attr('data-pika-year')
            let month = +$(el).attr('data-pika-month')
            let day = +$(el).attr('data-pika-day')

            $(el).parent().toggleClass('is-multi-selected', dates.some(d => {
                return helper.equals(d, [year, month + 1, day])
            }))
        }
    }

    let pikaday = new Pikaday({
        position: 'top left',
        firstDay: 1,
        yearRange: 6,
        onOpen() {
            let dates = (sgf.string2dates($dateInput.val()) || []).filter(x => x.length == 3)

            if (dates.length > 0) {
                this.setDate(dates[0].join('-'), true)
            } else {
                this.gotoToday()
            }

            adjustPosition(this)
        },
        onDraw() {
            if (!this.isVisible()) return

            adjustPosition(this)
            markDates(this)

            $dateInput.get(0).focus()
        },
        onSelect() {
            let dates = sgf.string2dates($dateInput.val()) || []
            let date = this.getDate()
            date = [date.getFullYear(), date.getMonth() + 1, date.getDate()]

            if (!dates.some(x => helper.equals(x, date))) {
                dates.push(date)
            } else {
                dates = dates.filter(x => !helper.equals(x, date))
            }

            $dateInput.val(sgf.dates2string(dates.sort(helper.lexicalCompare)))
        }
    })

    $dateInput.data('pikaday', pikaday)
    pikaday.hide()

    $('body').append(pikaday.el).on('click', function(evt) {
        if (pikaday.isVisible()
        && document.activeElement != $dateInput.get(0)
        && evt.target != $dateInput.get(0)
        && $(evt.target).parents('.pika-lendar').length == 0)
            pikaday.hide()
    })

    $(window).on('resize', () => adjustPosition(pikaday))

    $dateInput.on('focus', function() {
        pikaday.show()
    }).on('blur', function() {
        setTimeout(() => {
            if ($(document.activeElement).parents('.pika-lendar').length == 0)
                pikaday.hide()
        }, 50)
    }).on('input', function() {
        markDates(pikaday)
    })

    // Handle size inputs

    $('#info input[name^="size-"]').attr('placeholder', setting.get('game.default_board_size'))

    $('#info input[name="size-width"]').on('focus', function() {
        let $input = $(this).parent().nextAll('input[name="size-height"]')
        $(this).data('link', this.value == $input.val())
    }).on('input', function() {
        if (!$(this).data('link')) return
        $(this).parent().nextAll('input[name="size-height"]').val(this.value)
    })

    $('#info span.size-swap').on('click', function() {
        if ($('#info').hasClass('disabled')) return

        let $widthInput = $('#info input[name="size-width"]')
        let $heightInput = $('#info input[name="size-height"]')
        let data = [$widthInput.val(), $heightInput.val()]
        $widthInput.val(data[1])
        $heightInput.val(data[0])
    })
}

sabaki.preparePreferences = function() {
    $('#preferences .tabs .general').on('click', () => view.setPreferencesTab('general'))
    $('#preferences .tabs .engines').on('click', () => view.setPreferencesTab('engines'))

    $('#preferences form .engines button').on('click', evt => {
        evt.preventDefault()
        view.addEngineItem()
    })

    $('#preferences button[type="submit"]').on('click', evt => {
        evt.preventDefault()
        sabaki.commitPreferences()
        view.closePreferences()
    })

    $('#preferences button[type="reset"]').on('click', evt => {
        evt.preventDefault()
        view.closePreferences()
    })
}

sabaki.prepareCleanMarkup = function() {
    $('#cleanmarkup button:not([type="reset"])').on('click', evt => {
        evt.preventDefault()
        sabaki.setUndoable(true, 'Undo Clean Markup')

        let data = {
            cross: ['MA'],
            triangle: ['TR'],
            square: ['SQ'],
            circle: ['CR'],
            line: ['LN'],
            arrow: ['AR'],
            label: ['LB'],
            comments: ['C', 'N'],
            annotations: ['DM', 'GB', 'GW', 'UC', 'BM', 'DO', 'IT', 'TE'],
            hotspots: ['HO']
        }

        for (let input of $('#cleanmarkup input[type="checkbox"]').get()) {
            let $input = $(input)
            setting.set($input.attr('name'), $input.prop('checked'))
        }

        let cleanWholeGame = $(evt.target).attr('class') == 'whole-game'
        let properties = $('#cleanmarkup input[type="checkbox"]').get()
            .filter(x => $(x).prop('checked'))
            .map(x => data[$(x).attr('name').replace('cleanmarkup.', '')])
            .reduce((sum, x) => [...sum, ...x], [])

        view.setIsBusy(true)

        setTimeout(() => {
            if (!cleanWholeGame) {
                let [tree, i] = sabaki.getCurrentTreePosition()

                for (let prop of properties) {
                    delete tree.nodes[i][prop]
                }
            } else {
                let trees = gametree.getTreesRecursive(sabaki.getRootTree())

                for (let tree of trees) {
                    for (let i = 0; i < tree.nodes.length; i++) {
                        for (let prop of properties) {
                            delete tree.nodes[i][prop]
                        }
                    }
                }
            }

            view.setIsBusy(false)
            view.closeCleanMarkup()
            sabaki.setCurrentTreePosition(...sabaki.getCurrentTreePosition(), true, true)
        }, 100)
    })

    $('#cleanmarkup button[type="reset"]').on('click', evt => {
        evt.preventDefault()
        view.closeCleanMarkup()
    })
}

/**
 * Engine Methods
 */

sabaki.loadEngines = function() {
    // Load engines list

    $('#preferences .engines-list ul').empty()

    for (let engine of setting.getEngines()) {
        view.addEngineItem(engine.name, engine.path, engine.args)
    }
}

sabaki.attachEngine = function(exec, args, genMove) {
    sabaki.detachEngine()
    view.setIsBusy(true)

    setTimeout(() => {
        let split = require('argv-split')
        let controller = new gtp.Controller(exec, split(args))

        if (controller.error) {
            view.showMessageBox('There was an error attaching the engine.', 'error')
            return
        }

        controller.on('quit', () => {
            $('#console').data('controller', null)
            view.setIsBusy(false)
        })

        $('#console').data('controller', controller)

        sabaki.sendGTPCommand(new gtp.Command(null, 'name'), true, response => {
            $('#console').data('enginename', response.content)
        })
        sabaki.sendGTPCommand(new gtp.Command(null, 'version'))
        sabaki.sendGTPCommand(new gtp.Command(null, 'protocol_version'))
        sabaki.sendGTPCommand(new gtp.Command(null, 'list_commands'), true, response => {
            $('#console').data('commands', response.content.split('\n'))
        })

        sabaki.syncEngine()
        view.setIsBusy(false)

        if (!!genMove) sabaki.generateMove()
    }, setting.get('gtp.attach_delay'))
}

sabaki.detachEngine = function() {
    sabaki.sendGTPCommand(new gtp.Command(null, 'quit'), true)

    $('#console')
    .data('controller', null)
    .data('boardhash', null)

    view.setIsBusy(false)
}

sabaki.syncEngine = function() {
    let board = sabaki.getBoard()

    if (!sabaki.getEngineController() || $('#console').data('boardhash') == board.getHash())
        return

    if (!board.isSquare()) {
        view.showMessageBox('GTP engines don’t support non-square boards.', 'warning')
        return sabaki.detachEngine()
    } else if (!board.isValid()) {
        view.showMessageBox('GTP engines don’t support invalid board positions.', 'warning')
        return sabaki.detachEngine()
    }

    view.setIsBusy(true)

    sabaki.sendGTPCommand(new gtp.Command(null, 'clear_board'), true)
    sabaki.sendGTPCommand(new gtp.Command(null, 'boardsize', [board.width]), true)
    sabaki.sendGTPCommand(new gtp.Command(null, 'komi', [sabaki.getKomi()]), true)

    // Replay
    for (let i = 0; i < board.width; i++) {
        for (let j = 0; j < board.height; j++) {
            let v = [i, j]
            let sign = board.get(v)
            if (sign == 0) continue

            let color = sign > 0 ? 'B' : 'W'
            let point = board.vertex2coord(v)

            sabaki.sendGTPCommand(new gtp.Command(null, 'play', [color, point]), true)
        }
    }

    $('#console').data('boardhash', board.getHash())
    view.setIsBusy(false)
}

sabaki.sendGTPCommand = function(command, ignoreBlocked = false, callback = () => {}) {
    if (!sabaki.getEngineController()) {
        $('#console form:last-child input').val('')
        return
    }

    let controller = sabaki.getEngineController()
    let $container = $('#console .inner')
    let $oldform = $container.find('form:last-child')
    let $form = $oldform.clone(true)
    let $pre = $('<pre/>').text(' ')

    $form.find('input').val('')
    $oldform.addClass('waiting').find('input').val(command.toString())
    $container.append($pre).append($form)
    if (view.getShowLeftSidebar()) $form.find('input').get(0).focus()

    // Cleanup
    let $forms = $('#console .inner form')
    if ($forms.length > setting.get('console.max_history_count')) {
        $forms.eq(0).next('pre').remove()
        $forms.eq(0).remove()
    }

    let listener = (response, c) => {
        $pre.html(response.toHtml())
        view.wireLinks($pre)
        $oldform.removeClass('waiting')
        callback(response)

        let $view = $('#console')
        $view.scrollTop($view.get(0).scrollHeight)
    }

    if (!ignoreBlocked && setting.get('console.blocked_commands').includes(command.name)) {
        listener(new gtp.Response(null, 'blocked command', true, true), command)
    } else {
        controller.once('response-' + command.internalId, listener)
        controller.sendCommand(command)
    }
}

sabaki.generateMove = function(ignoreBusy = false) {
    if (!sabaki.getEngineController() || !ignoreBusy && view.getIsBusy()) return

    view.closeDrawers()
    sabaki.syncEngine()
    view.setIsBusy(true)

    let color = view.getCurrentPlayer() > 0 ? 'B' : 'W'
    let opponent = view.getCurrentPlayer() > 0 ? 'W' : 'B'

    sabaki.sendGTPCommand(new gtp.Command(null, 'genmove', [color]), true, r => {
        view.setIsBusy(false)
        if (r.content.toLowerCase() == 'resign') {
            view.showMessageBox(sabaki.getEngineName() + ' has resigned.')
            sabaki.getRootTree().nodes[0].RE = [opponent + '+Resign']
            return
        }

        let v = [-1, -1]
        if (r.content.toLowerCase() != 'pass')
            v = sabaki.getBoard().coord2vertex(r.content)

        sabaki.makeMove(v, false)
        $('#console').data('boardhash', sabaki.getBoard().getHash())
    })
}

/**
 * File Hash Methods
 */

sabaki.generateTreeHash = function() {
    let trees = sabaki.getGameTrees()
    let hash = ''

    for (let i = 0; i < trees.length; i++) {
        hash += gametree.getHash(trees[i])
    }

    return hash
}

sabaki.updateTreeHash = function() {
    $('body').data('treehash', sabaki.generateTreeHash())
}

sabaki.generateFileHash = function() {
    let filename = view.getRepresentedFilename()
    if (!filename) return null

    try {
        let content = fs.readFileSync(filename, 'utf8')
        return helper.hash(content)
    } catch (err) {}

    return null
}

sabaki.updateFileHash = function() {
    $('body').data('filehash', sabaki.generateFileHash())
}

sabaki.askForSave = function() {
    if (!sabaki.getRootTree()) return true
    let hash = sabaki.generateTreeHash()

    if (hash != sabaki.getTreeHash()) {
        let answer = view.showMessageBox(
            'Your changes will be lost if you close this file without saving.',
            'warning',
            ['Save', 'Don’t Save', 'Cancel'], 2
        )

        if (answer == 0) return sabaki.saveFile(view.getRepresentedFilename())
        else if (answer == 2) return false
    }

    return true
}

sabaki.askForReload = function() {
    let hash = sabaki.generateFileHash()

    if (hash && hash != sabaki.getFileHash()) {
        let answer = view.showMessageBox([
            `This file has been changed outside of ${app.getName()}.`,
            'Do you want to reload the file? Your changes will be lost.'
        ].join('\n'), 'warning', ['Reload', 'Don’t Reload'], 1)

        if (answer == 0) {
            sabaki.loadFile(view.getRepresentedFilename(), true)
        }

        $('body').data('filehash', hash)
    }
}

/**
 * Game Board Methods
 */

sabaki.vertexClicked = function(vertex, buttonIndex = 0, ctrlKey = false, pos = null) {
    view.closeGameInfo()

    if (typeof vertex == 'string') {
        vertex = sabaki.getBoard().coord2vertex(vertex)
    }

    if (view.getScoringMode() || view.getEstimatorMode()) {
        if ($('#score').hasClass('show')) return
        if (buttonIndex != 0) return
        if (sabaki.getBoard().get(vertex) == 0) return

        let dead = !$('#goban .pos_' + vertex.join('-')).hasClass('dead')
        let stones = view.getEstimatorMode()
            ? sabaki.getBoard().getChain(vertex)
            : sabaki.getBoard().getRelatedChains(vertex)

        for (let v of stones) {
            $('#goban .pos_' + v.join('-')).toggleClass('dead', dead)
        }

        sabaki.updateAreaMap(view.getEstimatorMode())
    } else if (view.getEditMode()) {
        if (ctrlKey) {
            let coord = sabaki.getBoard().vertex2coord(vertex)

            view.setCommentText([view.getCommentText().trim(), coord].join(' ').trim())
            sabaki.commitCommentText()
        } else {
            sabaki.useTool(vertex, null, buttonIndex)
        }
    } else if (view.getFindMode()) {
        if (buttonIndex != 0) return

        view.setIndicatorVertex(vertex)
        sabaki.findMove(view.getIndicatorVertex(), view.getFindText(), 1)
    } else if (view.getGuessMode()) {
        if (buttonIndex != 0) return

        let tp = gametree.navigate(...sabaki.getCurrentTreePosition(), 1)
        if (!tp) {
            view.setGuessMode(false)
            return
        }

        let nextNode = tp[0].nodes[tp[1]]

        if ('B' in nextNode) view.setCurrentPlayer(1)
        else if ('W' in nextNode) view.setCurrentPlayer(-1)
        else {
            view.setGuessMode(false)
            return
        }

        let color = view.getCurrentPlayer() > 0 ? 'B' : 'W'
        let nextVertex = sgf.point2vertex(nextNode[color][0])
        let board = sabaki.getBoard()

        if (!board.hasVertex(nextVertex)) {
            view.setGuessMode(false)
            return
        }

        if (vertex[0] == nextVertex[0] && vertex[1] == nextVertex[1]) {
            sabaki.makeMove(vertex)
        } else {
            if (board.get(vertex) != 0) return
            if ($('#goban .pos_' + vertex.join('-')).hasClass('paint_1')) return

            let i = 0
            if (Math.abs(vertex[1] - nextVertex[1]) > Math.abs(vertex[0] - nextVertex[0]))
                i = 1

            for (let x = 0; x < board.width; x++) {
                for (let y = 0; y < board.height; y++) {
                    let z = i == 0 ? x : y
                    if (Math.abs(z - vertex[i]) < Math.abs(z - nextVertex[i]))
                        $('#goban .pos_' + x + '-' + y).addClass('paint_1')
                }
            }
        }
    } else if (view.getPlayMode() || view.getAutoplayMode()) {
        if (buttonIndex == 0) {
            let board = sabaki.getBoard()
            if (board.get(vertex) == 0) {
                sabaki.makeMove(vertex)
                view.closeDrawers()
            } else if (vertex in board.markups
                       && board.markups[vertex][0] == 'point'
                       && setting.get('edit.click_currentvertex_to_remove')) {
                sabaki.removeNode(...sabaki.getCurrentTreePosition())
            }
        } else if (buttonIndex == 2) {
            let board = sabaki.getBoard()
            if (vertex in board.markups && board.markups[vertex][0] == 'point') {
                view.openCommentMenu(pos)<commit_msg>Some refactoring<commit_after>require('./ipc')

const fs = require('fs')
const {ipcRenderer, clipboard, remote} = require('electron')
const {app, dialog, Menu} = remote
const EventEmitter = require('events')
const Pikaday = require('pikaday')

const view = require('./view')
const $ = require('../modules/sprint')
const sgf = require('../modules/sgf')
const fuzzyfinder = require('../modules/fuzzyfinder')
const gametree = require('../modules/gametree')
const sound = require('../modules/sound')
const helper = require('../modules/helper')
const setting = require('../modules/setting')
const gtp = require('../modules/gtp')
const Board = require('../modules/board')

window.sabaki = {
    view,
    events: new EventEmitter(),
    modules: {sgf, gametree, sound, setting}
}

/**
 * Getters & Setters
 */

sabaki.getGameTrees = function() {
    let trees = $('body').data('gametrees')
    return trees ? trees : [sabaki.getRootTree()]
}

sabaki.setGameTrees = function(trees) {
    trees.forEach(tree => tree.parent = null)
    $('body').data('gametrees', trees)
}

sabaki.getGameIndex = function() {
    return sabaki.getGameTrees().length == 1 ? 0 : $('body').data('gameindex')
}

sabaki.setGameIndex = function(index) {
    let trees = sabaki.getGameTrees()
    $('body').data('gameindex', index)

    sabaki.setGameTrees(trees)
    sabaki.setRootTree(trees[index])
    view.updateTitle()
    sabaki.setUndoable(false)
    if (setting.get('game.goto_end_after_loading')) sabaki.goToEnd()
}

sabaki.getRootTree = function() {
    if (!sabaki.getCurrentTreePosition()) return null
    return sabaki.getGameTrees()[sabaki.getGameIndex()]
}

sabaki.setRootTree = function(tree) {
    if (tree.nodes.length == 0) return

    let trees = sabaki.getGameTrees()
    trees[sabaki.getGameIndex()] = tree
    sabaki.setGameTrees(trees)

    tree.parent = null
    gametree.getBoard(tree)
    sabaki.setCurrentTreePosition(tree, 0, true, true)

    view.setPlayerName(1,
        gametree.getPlayerName(tree, 1, 'Black'),
        'BR' in tree.nodes[0] ? tree.nodes[0].BR[0] : ''
    )
    view.setPlayerName(-1,
        gametree.getPlayerName(tree, -1, 'White'),
        'WR' in tree.nodes[0] ? tree.nodes[0].WR[0] : ''
    )
}

sabaki.getTreeHash = function() {
    return $('body').data('treehash')
}

sabaki.getFileHash = function() {
    return $('body').data('filehash')
}

sabaki.getGraphMatrixDict = function() {
    return $('#graph').data('graphmatrixdict')
}

sabaki.setGraphMatrixDict = function(matrixdict) {
    if (!view.getShowSidebar()) return

    let s, graph

    try {
        s = $('#graph').data('sigma')
        graph = gametree.matrixdict2graph(matrixdict)
    } catch (err) { }

    try {
        if (s && graph) {
            s.graph.clear()
            s.graph.read(graph)
        }
    } catch (err) {
        sabaki.setGraphMatrixDict(matrixdict)
    }

    $('#graph').data('graphmatrixdict', matrixdict)
}

sabaki.setCurrentPlayer = function(sign) {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]
    let intendedSign = 'B' in node ? -1 : +('W' in node)

    if (intendedSign == sign) {
        delete node.PL
    } else {
        node.PL = [sign > 0 ? 'B' : 'W']
    }

    view.setCurrentPlayer(sign)
}

sabaki.getCurrentPlayer = function() {
    return view.getCurrentPlayer()
}

sabaki.getCurrentTreePosition = function() {
    return $('#goban').data('position')
}

sabaki.setCurrentTreePosition = function(tree, index, now = false, redraw = false, ignoreAutoplay = false) {
    if (!tree || view.getScoringMode() || view.getEstimatorMode()) return
    if (!ignoreAutoplay && sabaki.getAutoplaying()) sabaki.setAutoplaying(false)

    // Remove old graph node color

    let oldGraphNode = sabaki.getCurrentGraphNode()
    let oldPos = sabaki.getCurrentTreePosition()
    let graphNode = sabaki.getGraphNode(tree, index)

    if (oldGraphNode && oldGraphNode != graphNode)
        oldGraphNode.color = oldGraphNode.originalColor

    // Store new position

    $('#goban').data('position', [tree, index])
    redraw = !!redraw
        || !graphNode
        || !gametree.onCurrentTrack(tree)
        || tree.collapsed

    let t = tree
    t.collapsed = false
    while (t.parent && t.parent.collapsed) {
        redraw = true
        t.parent.collapsed = false
        t = t.parent
    }

    // Update bookmark, graph, slider and comment text

    let node = tree.nodes[index]

    sabaki.updateSidebar(redraw, now)
    view.setShowHotspot('HO' in node)
    sabaki.setBoard(gametree.getBoard(tree, index))

    // Determine current player

    let currentplayer = 1

    if ('B' in node || 'HA' in node && +node.HA[0] >= 1)
        currentplayer = -1

    if ('PL' in node)
        currentplayer = node.PL[0] == 'W' ? -1 : 1

    view.setCurrentPlayer(currentplayer)
}

sabaki.getCurrentGraphNode = function() {
    let pos = sabaki.getCurrentTreePosition()
    if (!pos) return null
    return sabaki.getGraphNode(...pos)
}

sabaki.getGraphNode = function(tree, index) {
    let id = typeof tree === 'object' ? tree.id + '-' + index : tree
    let s = $('#graph').data('sigma')
    return s.graph.nodes(id)
}

sabaki.getSelectedTool = function() {
    let $li = $('#edit .selected')
    let tool = $li.attr('class').replace('selected', '').replace('-tool', '').trim()

    if (tool == 'stone') {
        return $li.find('img').attr('src').includes('_1') ? 'stone_1' : 'stone_-1'
    } else if (tool == 'line') {
        return $li.find('img').attr('src').includes('line') ? 'line' : 'arrow'
    } else {
        return tool
    }
}

sabaki.setSelectedTool = function(tool) {
    if (!view.getEditMode()) {
        view.setEditMode(true)
        if (sabaki.getSelectedTool().includes(tool)) return
    }

    $('#goban').data('edittool-data', null)
    $('#edit .' + tool + '-tool a').trigger('click')
}

sabaki.getBoard = function() {
    return $('#goban').data('board')
}

sabaki.setBoard = function(board) {
    let $goban = $('#goban')

    if (!sabaki.getBoard()
    || sabaki.getBoard().width != board.width
    || sabaki.getBoard().height != board.height) {
        $goban.data('board', board)
        view.buildBoard()
    }

    $goban.data('board', board)
    view.setCaptures(board.captures)

    for (let x = 0; x < board.width; x++) {
        for (let y = 0; y < board.height; y++) {
            let $li = $goban.find('.pos_' + x + '-' + y)
            let $span = $li.find('.stone span')
            let sign = board.get([x, y])

            // Clean up

            let types = ['ghost_1', 'ghost_-1', 'siblingghost_1', 'siblingghost_-1',
                'circle', 'triangle', 'cross', 'square', 'label',
                'point', 'dimmed', 'paint_1', 'paint_-1',
                'badmove', 'doubtfulmove', 'interestingmove', 'goodmove']

            types.forEach(x => $li.hasClass(x) ? $li.removeClass(x) : null)
            $span.attr('title', '')

            // Add markups

            if ([x, y] in board.markups) {
                let [type, label] = board.markups[[x, y]]

                if (type != '') $li.addClass(type)
                if (label != '') $span.attr('title', label)

                $li.toggleClass('smalllabel', label.length >= 3)
            }

            // Set stone image

            if ($li.hasClass('sign_' + sign)) continue

            for (let i = -1; i <= 1; i++) {
                if ($li.hasClass('sign_' + i)) $li.removeClass('sign_' + i)
            }

            $li.addClass('sign_' + sign)
        }
    }

    // Add ghosts

    for (let [v, s, types] of board.ghosts) {
        let $li = $('#goban .pos_' + v.join('-'))

        for (let type of types) {
            if (type == 'child') $li.addClass('ghost_' + s)
            else if (type == 'sibling') $li.addClass('siblingghost_' + s)
            else if (type == 'badmove') $li.addClass('badmove')
            else if (type == 'doubtfulmove') $li.addClass('doubtfulmove')
            else if (type == 'interestingmove') $li.addClass('interestingmove')
            else if (type == 'goodmove') $li.addClass('goodmove')
        }
    }

    // Add lines

    $('#goban hr').remove()

    for (let [v1, v2, arrow] of board.lines) {
        $goban.append(
            $('<hr/>').addClass(arrow ? 'arrow' : 'line').data('v1', v1).data('v2', v2)
        )
    }

    view.updateBoardLines()
}

sabaki.getScoringMethod = function() {
    return $('#score .tabs .territory').hasClass('current') ? 'territory' : 'area'
}

sabaki.setScoringMethod = function(method) {
    $('#score .tabs li').removeClass('current')
    $('#score .tabs .' + method).addClass('current')
    $('#score tr > *').addClass('disabled')
    $('#score table .' + method).removeClass('disabled')

    setting.set('scoring.method', method)

    // Update UI

    for (let sign = -1; sign <= 1; sign += 2) {
        let $tr = $('#score tbody tr' + (sign < 0 ? ':last-child' : ''))
        let $tds = $tr.find('td')

        $tds.eq(4).text(0)

        for (let i = 0; i <= 3; i++) {
            if ($tds.eq(i).hasClass('disabled') || isNaN(+$tds.eq(i).text())) continue
            $tds.eq(4).text(+$tds.eq(4).text() + +$tds.eq(i).text())
        }
    }

    let results = $('#score tbody td:last-child').get().map(td => $(td).text())
    let diff = +results[0] - +results[1]
    let result = diff > 0 ? 'B+' :  diff < 0 ? 'W+' : 'Draw'
    if (diff != 0) result = result + Math.abs(diff)

    $('#score .result').text(result)
}

sabaki.getKomi = function() {
    let rootNode = sabaki.getRootTree().nodes[0]
    return 'KM' in rootNode ? +rootNode.KM[0] : 0
}

sabaki.getEngineName = function() {
    return $('#console').data('enginename')
}

sabaki.getEngineController = function() {
    return $('#console').data('controller')
}

sabaki.getEngineCommands = function() {
    return $('#console').data('commands')
}

sabaki.setUndoable = function(undoable, tooltip = 'Undo') {
    if (undoable) {
        let rootTree = gametree.clone(sabaki.getRootTree())
        let level = gametree.getLevel(...sabaki.getCurrentTreePosition())

        $('#bar header .undo').attr('title', tooltip)
        $('body')
        .addClass('undoable')
        .data('undodata-root', rootTree)
        .data('undodata-level', level)
    } else {
        $('body')
        .removeClass('undoable')
        .data('undodata-root', null)
        .data('undodata-level', null)
    }
}

sabaki.getHotspot = function() {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]

    return 'HO' in node
}

sabaki.setHotspot = function(bookmark) {
    let [tree, index] = sabaki.getCurrentTreePosition()
    let node = tree.nodes[index]

    if (bookmark) node.HO = [1]
    else delete node.HO

    sabaki.updateGraph()
    view.setShowHotspot(bookmark)
}

sabaki.getEmptyGameTree = function() {
    let handicap = setting.get('game.default_handicap')
    let size = setting.get('game.default_board_size').toString().split(':').map(x => +x)
    let stones = new Board(size[0], size.slice(-1)[0]).getHandicapPlacement(handicap).map(sgf.vertex2point)

    let buffer = [
        `;GM[1]FF[4]CA[UTF-8]`,
        `AP[${app.getName()}:${app.getVersion()}]`,
        `KM[${setting.get('game.default_komi')}]`,
        `SZ[${size[0]}:${size.slice(-1)[0]}]`,
        stones.length > 0 ? `HA[${handicap}]AB[${stones.join('][')}]` : ''
    ].join('')

    return sgf.parse(sgf.tokenize(buffer))
}

sabaki.getAutoplaying = function() {
    return view.getAutoplayMode() && $('#autoplay').hasClass('playing')
}

sabaki.setAutoplaying = function(playing) {
    let autoplay = () => {
        if (!sabaki.getAutoplaying()) return

        let tp = gametree.navigate(...sabaki.getCurrentTreePosition(), 1)
        if (!tp) {
            sabaki.setAutoplaying(false)
            return
        }

        let node = tp[0].nodes[tp[1]]

        if (!node.B && !node.W) {
            sabaki.setCurrentTreePosition(...tp, false, false, true)
        } else {
            let vertex = sgf.point2vertex(node.B ? node.B[0] : node.W[0])
            view.setCurrentPlayer(node.B ? 1 : -1)
            sabaki.makeMove(vertex, false, true)
        }

        let id = setTimeout(autoplay, setting.get('autoplay.sec_per_move') * 1000)
        $('#autoplay').data('timeoutid', id)
    }

    if (playing) {
        view.setAutoplayMode(playing)
        $('#autoplay').addClass('playing')
        autoplay()
    } else {
        clearTimeout($('#autoplay').data('timeoutid'))
        $('#autoplay').removeClass('playing')
    }
}

/**
 * Preparation Methods
 */

sabaki.loadSettings = function() {
    $('head link.userstyle').attr('href', setting.stylesPath)

    $('#goban')
    .toggleClass('fuzzy', setting.get('view.fuzzy_stone_placement'))
    .toggleClass('animation', setting.get('view.animated_stone_placement'))
    .toggleClass('coordinates', setting.get('view.show_coordinates'))
    .toggleClass('movecolorization', setting.get('view.show_move_colorization'))
    .toggleClass('variations', setting.get('view.show_next_moves'))
    .toggleClass('siblings', setting.get('view.show_siblings'))

    if (setting.get('view.show_leftsidebar')) {
        $('body').addClass('leftsidebar')
        view.setLeftSidebarWidth(setting.get('view.leftsidebar_width'))
    }

    if (setting.get('view.show_graph') || setting.get('view.show_comments')) {
        $('body').addClass('sidebar')
        view.setSidebarArrangement(setting.get('view.show_graph'), setting.get('view.show_comments'))
        view.setSidebarWidth(setting.get('view.sidebar_width'))
    }
}

sabaki.prepareBars = function() {
    // Handle close buttons

    let bars = ['edit', 'guess', 'autoplay', 'scoring', 'estimator', 'find']

    for (let id of bars) {
        let funcName = 'set' + id[0].toUpperCase() + id.slice(1) + 'Mode'
        $(`#${id} > .close`).on('click', () => view[funcName](false))
    }

    // Handle header bar

    $('header .undo').on('click', () => sabaki.undoBoard())
    $('#headermenu').on('click', () => view.openHeaderMenu())

    // Handle autoplay bar

    $('#autoplay .play').on('click', () => sabaki.setAutoplaying(!sabaki.getAutoplaying()))

    // Handle scoring/estimator bar and drawer

    $('#scoring button, #estimator button').on('click', evt => {
        evt.preventDefault()
        view.showScore()
    })

    $('#score .tabs .area a').on('click', () => sabaki.setScoringMethod('area'))
    $('#score .tabs .territory a').on('click', () => sabaki.setScoringMethod('territory'))
    $('#score button[type="reset"]').on('click', () => view.closeScore())
    $('#score button[type="submit"]').on('click', evt => {
        evt.preventDefault()
        sabaki.commitScore()
        view.closeScore()
    })

    // Handle find bar

    $('#find button').get().forEach((el, i) => {
        $(el).on('click', evt => {
            evt.preventDefault()
            sabaki.findMove(view.getIndicatorVertex(), view.getFindText(), 1 - i * 2)
        })
    })

    // Handle current player toggler

    $('header .current-player').on('click', () => {
        sabaki.setCurrentPlayer(-sabaki.getCurrentPlayer())
    })
}

sabaki.prepareEditTools = function() {
    $('#edit ul a').on('click', function() {
        let $a = $(this)
        let $img = $a.find('img')

        if (!$a.parent().hasClass('selected')) {
            $('#edit .selected').removeClass('selected')
            $a.parent().addClass('selected')
        } else if ($a.parent().hasClass('stone-tool')) {
            let black = $img.attr('src').includes('_1')
            $img.attr('src', black ? '../img/edit/stone_-1.svg' : '../img/edit/stone_1.svg')
        } else if ($a.parent().hasClass('line-tool')) {
            let line = $img.attr('src').includes('line')
            $img.attr('src', line ? '../img/edit/arrow.svg' : '../img/edit/line.svg')
        }
    })
}

sabaki.prepareAutoplay = function() {
    $('#autoplay input').on('input', function() {
        let value = Math.min(10, Math.max(1, +$(this).val()))
        value = Math.floor(value * 10) / 10
        setting.set('autoplay.sec_per_move', value)
    }).on('blur', function() {
        $(this).val(setting.get('autoplay.sec_per_move'))
    })
}

sabaki.prepareSidebar = function() {
    // Prepare comments section

    $('#properties .header .edit-button').on('click', () => view.setEditMode(true))

    $('#properties .edit .header img').on('click', function() {
        view.openCommentMenu([
            Math.round($(this).offset().left),
            Math.round($(this).offset().top + $(this).height())
        ])
    })

    $('#properties .edit .header input, #properties .edit textarea')
    .on('input', () => sabaki.commitCommentText())

    // Prepare game graph

    let $container = $('#graph')
    let s = new sigma({
        renderer: {
            container: $container.get(0),
            type: 'canvas'
        },
        settings: {
            defaultNodeColor: setting.get('graph.node_inactive_color'),
            defaultEdgeColor: setting.get('graph.node_inactive_color'),
            defaultNodeBorderColor: 'rgba(255,255,255,.2)',
            edgeColor: 'default',
            borderSize: 2,
            zoomMax: 1,
            zoomMin: 1,
            autoResize: false,
            autoRescale: false
        }
    })

    let getTreePos = evt => evt.data.node.data.slice(0, 2)

    s.bind('clickNode', function(evt) {
        sabaki.setCurrentTreePosition(...getTreePos(evt), true)
    }).bind('rightClickNode', function(evt) {
        let pos = [evt.data.captor.clientX, evt.data.captor.clientY]
        view.openNodeMenu(...getTreePos(evt), pos.map(x => Math.round(x)))
    })

    $container.data('sigma', s)
}

sabaki.prepareSlider = function() {
    let $slider = $('#sidebar .slider .inner')

    let changeSlider = percentage => {
        percentage = Math.min(1, Math.max(0, percentage))

        let level = Math.round((gametree.getHeight(sabaki.getRootTree()) - 1) * percentage)
        let tp = gametree.navigate(sabaki.getRootTree(), 0, level)

        if (!tp) tp = gametree.navigate(
            sabaki.getRootTree(),
            0,
            gametree.getCurrentHeight(sabaki.getRootTree()) - 1
        )

        if (helper.equals(tp, sabaki.getCurrentTreePosition())) return
        sabaki.setCurrentTreePosition(...tp)
        sabaki.updateSlider()
    }

    let mouseMoveHandler = evt => {
        if (evt.button != 0 || !$slider.data('mousedown'))
            return

        let percentage = (evt.clientY - $slider.offset().top) / $slider.height()
        changeSlider(percentage)
        document.onselectstart = function() { return false }
    }

    $slider.on('mousedown', function(evt) {
        if (evt.button != 0) return

        $(this).data('mousedown', true).addClass('active')
        mouseMoveHandler(evt)
    }).on('touchstart', function() {
        $(this).addClass('active')
    }).on('touchmove', function(evt) {
        let percentage = (evt.client.y - $slider.offset().top) / $slider.height()
        changeSlider(percentage)
    }).on('touchend', function() {
        $(this).removeClass('active')
    })

    $(document).on('mouseup', function() {
        $slider.data('mousedown', false)
            .removeClass('active')
        document.onselectstart = null
    }).on('mousemove', mouseMoveHandler)

    // Prepare previous/next buttons

    $('#sidebar .slider a').on('mousedown', function() {
        $(this).data('mousedown', true)
        sabaki.startAutoScroll($(this).hasClass('next') ? 1 : -1)
    })

    $(document).on('mouseup', function() {
        $('#sidebar .slider a').data('mousedown', false)
    })
}

sabaki.prepareDragDropFiles = function() {
    $('body').on('dragover', function(evt) {
        evt.preventDefault()
    }).on('drop', function(evt) {
        evt.preventDefault()

        if (evt.dataTransfer.files.length == 0) return
        sabaki.loadFile(evt.dataTransfer.files[0].path)
    })
}

sabaki.prepareConsole = function() {
    $('#console form').on('submit', function(evt) {
        evt.preventDefault()

        let $input = $(this).find('input')
        if ($input.val().trim() == '') return
        $input.get(0).blur()

        let command = gtp.parseCommand($input.val())
        sabaki.sendGTPCommand(command)
    })

    $('#console form input').on('keydown', function(evt) {
        if ([40, 38, 9].includes(evt.keyCode)) evt.preventDefault()
        let $inputs = $('#console form input')

        if ($(this).data('index') == null) $(this).data('index', $inputs.get().indexOf(this))
        let i = $(this).data('index')
        let length = $inputs.length

        if ([38, 40].includes(evt.keyCode)) {
            if (evt.keyCode == 38) {
                // Up
                i = Math.max(i - 1, 0)
            } else if (evt.keyCode == 40) {
                // Down
                i = Math.min(i + 1, length - 1)
            }

            $(this)
            .val(i == length - 1 ? '' : $inputs.eq(i).val())
            .data('index', i)
        } else if (evt.keyCode == 9) {
            // Tab
            let tokens = $(this).val().split(' ')
            let commands = sabaki.getEngineCommands()
            if (!commands) return

            let i = 0
            let selection = this.selectionStart
            while (selection > tokens[i].length && selection.length != 0 && i < tokens.length - 1)
                selection -= tokens[i++].length + 1

            let result = fuzzyfinder.find(tokens[i], sabaki.getEngineCommands())
            if (!result) return
            tokens[i] = result

            this.value = tokens.join(' ')
            this.selectionStart = this.selectionEnd = (() => {
                let sum = 0
                while (i >= 0) sum += tokens[i--].length + 1
                return sum - 1
            })()
        }
    })
}

sabaki.prepareGameInfo = function() {
    $('#info button[type="submit"]').on('click', function(evt) {
        evt.preventDefault()
        sabaki.commitGameInfo()
        view.closeGameInfo()
    })

    $('#info button[type="reset"]').on('click', function(evt) {
        evt.preventDefault()
        view.closeGameInfo()
    })

    $('#info .current-player').on('click', function() {
        let data = $('#info section input[type="text"]').get().map(el => $(el).val())

        $('#info section input[name="rank_1"]').val(data[3])
        $('#info section input[name="rank_-1"]').val(data[0])
        $('#info section input[name="name_1"]').val(data[2])
        $('#info section input[name="name_-1"]').val(data[1])

        data = $('#info section .menu').get().map(el => {
            return [$(el).hasClass('active'), $(el).data('engineindex')]
        })

        $('#info section .menu').eq(0)
        .toggleClass('active', data[1][0])
        .data('engineindex', data[1][1])

        $('#info section .menu').eq(1)
        .toggleClass('active', data[0][0])
        .data('engineindex', data[0][1])
    })

    $('#info section img.menu').on('click', function() {
        let $el = $(this)

        let selectEngine = (el, engine, i) => {
            let currentIndex = $(this).data('engineindex')
            if (currentIndex == null) currentIndex = -1
            if (i == currentIndex) return

            $(el).parent().find('input[name^="name_"]').val(engine ? engine.name : '')
            $(el).data('engineindex', i)

            if (engine) {
                let els = $('#info section .menu').get()
                let other = els[0] == el ? els[1] : els[0]
                if (other) selectEngine(other, null, -1)

                $(el).addClass('active')
            } else {
                $('#info').find('section .menu')
                .removeClass('active')
            }
        }

        view.openEnginesMenu($el, (engine, i) => selectEngine($el.get(0), engine, i))
    })

    // Prepare date input

    let $dateInput = $('#info input[name="date"]')

    let adjustPosition = pikaday => {
        $(pikaday.el)
        .css('position', 'absolute')
        .css('left', Math.round($dateInput.offset().left))
        .css('top', Math.round($dateInput.offset().top - $(pikaday.el).height()))
    }

    let markDates = pikaday => {
        let dates = (sgf.string2dates($dateInput.val()) || []).filter(x => x.length == 3)

        for (let el of $(pikaday.el).find('.pika-button').get()) {
            let year = +$(el).attr('data-pika-year')
            let month = +$(el).attr('data-pika-month')
            let day = +$(el).attr('data-pika-day')

            $(el).parent().toggleClass('is-multi-selected', dates.some(d => {
                return helper.equals(d, [year, month + 1, day])
            }))
        }
    }

    let pikaday = new Pikaday({
        position: 'top left',
        firstDay: 1,
        yearRange: 6,
        onOpen() {
            let dates = (sgf.string2dates($dateInput.val()) || []).filter(x => x.length == 3)

            if (dates.length > 0) {
                this.setDate(dates[0].join('-'), true)
            } else {
                this.gotoToday()
            }

            adjustPosition(this)
        },
        onDraw() {
            if (!this.isVisible()) return

            adjustPosition(this)
            markDates(this)

            $dateInput.get(0).focus()
        },
        onSelect() {
            let dates = sgf.string2dates($dateInput.val()) || []
            let date = this.getDate()
            date = [date.getFullYear(), date.getMonth() + 1, date.getDate()]

            if (!dates.some(x => helper.equals(x, date))) {
                dates.push(date)
            } else {
                dates = dates.filter(x => !helper.equals(x, date))
            }

            $dateInput.val(sgf.dates2string(dates.sort(helper.lexicalCompare)))
        }
    })

    $dateInput.data('pikaday', pikaday)
    pikaday.hide()

    $('body').append(pikaday.el).on('click', function(evt) {
        if (pikaday.isVisible()
        && document.activeElement != $dateInput.get(0)
        && evt.target != $dateInput.get(0)
        && $(evt.target).parents('.pika-lendar').length == 0)
            pikaday.hide()
    })

    $(window).on('resize', () => adjustPosition(pikaday))

    $dateInput.on('focus', function() {
        pikaday.show()
    }).on('blur', function() {
        setTimeout(() => {
            if ($(document.activeElement).parents('.pika-lendar').length == 0)
                pikaday.hide()
        }, 50)
    }).on('input', function() {
        markDates(pikaday)
    })

    // Handle size inputs

    $('#info input[name^="size-"]').attr('placeholder', setting.get('game.default_board_size'))

    $('#info input[name="size-width"]').on('focus', function() {
        let $input = $(this).parent().nextAll('input[name="size-height"]')
        $(this).data('link', this.value == $input.val())
    }).on('input', function() {
        if (!$(this).data('link')) return
        $(this).parent().nextAll('input[name="size-height"]').val(this.value)
    })

    $('#info span.size-swap').on('click', function() {
        if ($('#info').hasClass('disabled')) return

        let $widthInput = $('#info input[name="size-width"]')
        let $heightInput = $('#info input[name="size-height"]')
        let data = [$widthInput.val(), $heightInput.val()]
        $widthInput.val(data[1])
        $heightInput.val(data[0])
    })
}

sabaki.preparePreferences = function() {
    $('#preferences .tabs .general').on('click', () => view.setPreferencesTab('general'))
    $('#preferences .tabs .engines').on('click', () => view.setPreferencesTab('engines'))

    $('#preferences form .engines button').on('click', evt => {
        evt.preventDefault()
        view.addEngineItem()
    })

    $('#preferences button[type="submit"]').on('click', evt => {
        evt.preventDefault()
        sabaki.commitPreferences()
        view.closePreferences()
    })

    $('#preferences button[type="reset"]').on('click', evt => {
        evt.preventDefault()
        view.closePreferences()
    })
}

sabaki.prepareCleanMarkup = function() {
    $('#cleanmarkup button:not([type="reset"])').on('click', evt => {
        evt.preventDefault()
        sabaki.setUndoable(true, 'Undo Clean Markup')

        let data = {
            cross: ['MA'],
            triangle: ['TR'],
            square: ['SQ'],
            circle: ['CR'],
            line: ['LN'],
            arrow: ['AR'],
            label: ['LB'],
            comments: ['C', 'N'],
            annotations: ['DM', 'GB', 'GW', 'UC', 'BM', 'DO', 'IT', 'TE'],
            hotspots: ['HO']
        }

        for (let input of $('#cleanmarkup input[type="checkbox"]').get()) {
            let $input = $(input)
            setting.set($input.attr('name'), $input.prop('checked'))
        }

        let cleanWholeGame = $(evt.target).attr('class') == 'whole-game'
        let properties = $('#cleanmarkup input[type="checkbox"]').get()
            .filter(x => $(x).prop('checked'))
            .map(x => data[$(x).attr('name').replace('cleanmarkup.', '')])
            .reduce((sum, x) => [...sum, ...x], [])

        view.setIsBusy(true)

        setTimeout(() => {
            if (!cleanWholeGame) {
                let [tree, i] = sabaki.getCurrentTreePosition()

                for (let prop of properties) {
                    delete tree.nodes[i][prop]
                }
            } else {
                let trees = gametree.getTreesRecursive(sabaki.getRootTree())

                for (let tree of trees) {
                    for (let i = 0; i < tree.nodes.length; i++) {
                        for (let prop of properties) {
                            delete tree.nodes[i][prop]
                        }
                    }
                }
            }

            view.setIsBusy(false)
            view.closeCleanMarkup()
            sabaki.setCurrentTreePosition(...sabaki.getCurrentTreePosition(), true, true)
        }, 100)
    })

    $('#cleanmarkup button[type="reset"]').on('click', evt => {
        evt.preventDefault()
        view.closeCleanMarkup()
    })
}

/**
 * Engine Methods
 */

sabaki.loadEngines = function() {
    // Load engines list

    $('#preferences .engines-list ul').empty()

    for (let engine of setting.getEngines()) {
        view.addEngineItem(engine.name, engine.path, engine.args)
    }
}

sabaki.attachEngine = function(exec, args, genMove) {
    sabaki.detachEngine()
    view.setIsBusy(true)

    setTimeout(() => {
        let split = require('argv-split')
        let controller = new gtp.Controller(exec, split(args))

        if (controller.error) {
            view.showMessageBox('There was an error attaching the engine.', 'error')
            return
        }

        controller.on('quit', () => {
            $('#console').data('controller', null)
            view.setIsBusy(false)
        })

        $('#console').data('controller', controller)

        sabaki.sendGTPCommand(new gtp.Command(null, 'name'), true, response => {
            $('#console').data('enginename', response.content)
        })
        sabaki.sendGTPCommand(new gtp.Command(null, 'version'))
        sabaki.sendGTPCommand(new gtp.Command(null, 'protocol_version'))
        sabaki.sendGTPCommand(new gtp.Command(null, 'list_commands'), true, response => {
            $('#console').data('commands', response.content.split('\n'))
        })

        sabaki.syncEngine()
        view.setIsBusy(false)

        if (!!genMove) sabaki.generateMove()
    }, setting.get('gtp.attach_delay'))
}

sabaki.detachEngine = function() {
    sabaki.sendGTPCommand(new gtp.Command(null, 'quit'), true)

    $('#console')
    .data('controller', null)
    .data('boardhash', null)

    view.setIsBusy(false)
}

sabaki.syncEngine = function() {
    let board = sabaki.getBoard()

    if (!sabaki.getEngineController() || $('#console').data('boardhash') == board.getHash())
        return

    if (!board.isSquare()) {
        view.showMessageBox('GTP engines don’t support non-square boards.', 'warning')
        return sabaki.detachEngine()
    } else if (!board.isValid()) {
        view.showMessageBox('GTP engines don’t support invalid board positions.', 'warning')
        return sabaki.detachEngine()
    }

    view.setIsBusy(true)

    sabaki.sendGTPCommand(new gtp.Command(null, 'clear_board'), true)
    sabaki.sendGTPCommand(new gtp.Command(null, 'boardsize', [board.width]), true)
    sabaki.sendGTPCommand(new gtp.Command(null, 'komi', [sabaki.getKomi()]), true)

    // Replay
    for (let i = 0; i < board.width; i++) {
        for (let j = 0; j < board.height; j++) {
            let v = [i, j]
            let sign = board.get(v)
            if (sign == 0) continue

            let color = sign > 0 ? 'B' : 'W'
            let point = board.vertex2coord(v)

            sabaki.sendGTPCommand(new gtp.Command(null, 'play', [color, point]), true)
        }
    }

    $('#console').data('boardhash', board.getHash())
    view.setIsBusy(false)
}

sabaki.sendGTPCommand = function(command, ignoreBlocked = false, callback = () => {}) {
    if (!sabaki.getEngineController()) {
        $('#console form:last-child input').val('')
        return
    }

    let controller = sabaki.getEngineController()
    let $container = $('#console .inner')
    let $oldform = $container.find('form:last-child')
    let $form = $oldform.clone(true)
    let $pre = $('<pre/>').text(' ')

    $form.find('input').val('')
    $oldform.addClass('waiting').find('input').val(command.toString())
    $container.append($pre).append($form)
    if (view.getShowLeftSidebar()) $form.find('input').get(0).focus()

    // Cleanup
    let $forms = $('#console .inner form')
    if ($forms.length > setting.get('console.max_history_count')) {
        $forms.eq(0).next('pre').remove()
        $forms.eq(0).remove()
    }

    let listener = (response, c) => {
        $pre.html(response.toHtml())
        view.wireLinks($pre)
        $oldform.removeClass('waiting')
        callback(response)

        let $view = $('#console')
        $view.scrollTop($view.get(0).scrollHeight)
    }

    if (!ignoreBlocked && setting.get('console.blocked_commands').includes(command.name)) {
        listener(new gtp.Response(null, 'blocked command', true, true), command)
    } else {
        controller.once('response-' + command.internalId, listener)
        controller.sendCommand(command)
    }
}

sabaki.generateMove = function(ignoreBusy = false) {
    if (!sabaki.getEngineController() || !ignoreBusy && view.getIsBusy()) return

    view.closeDrawers()
    sabaki.syncEngine()
    view.setIsBusy(true)

    let color = view.getCurrentPlayer() > 0 ? 'B' : 'W'
    let opponent = view.getCurrentPlayer() > 0 ? 'W' : 'B'

    sabaki.sendGTPCommand(new gtp.Command(null, 'genmove', [color]), true, r => {
        view.setIsBusy(false)
        if (r.content.toLowerCase() == 'resign') {
            view.showMessageBox(sabaki.getEngineName() + ' has resigned.')
            sabaki.getRootTree().nodes[0].RE = [opponent + '+Resign']
            return
        }

        let v = [-1, -1]
        if (r.content.toLowerCase() != 'pass')
            v = sabaki.getBoard().coord2vertex(r.content)

        sabaki.makeMove(v, false)
        $('#console').data('boardhash', sabaki.getBoard().getHash())
    })
}

/**
 * File Hash Methods
 */

sabaki.generateTreeHash = function() {
    let trees = sabaki.getGameTrees()
    let hash = ''

    for (let i = 0; i < trees.length; i++) {
        hash += gametree.getHash(trees[i])
    }

    return hash
}

sabaki.updateTreeHash = function() {
    $('body').data('treehash', sabaki.generateTreeHash())
}

sabaki.generateFileHash = function() {
    let filename = view.getRepresentedFilename()
    if (!filename) return null

    try {
        let content = fs.readFileSync(filename, 'utf8')
        return helper.hash(content)
    } catch (err) {}

    return null
}

sabaki.updateFileHash = function() {
    $('body').data('filehash', sabaki.generateFileHash())
}

sabaki.askForSave = function() {
    if (!sabaki.getRootTree()) return true
    let hash = sabaki.generateTreeHash()

    if (hash != sabaki.getTreeHash()) {
        let answer = view.showMessageBox(
            'Your changes will be lost if you close this file without saving.',
            'warning',
            ['Save', 'Don’t Save', 'Cancel'], 2
        )

        if (answer == 0) return sabaki.saveFile(view.getRepresentedFilename())
        else if (answer == 2) return false
    }

    return true
}

sabaki.askForReload = function() {
    let hash = sabaki.generateFileHash()

    if (hash && hash != sabaki.getFileHash()) {
        let answer = view.showMessageBox([
            `This file has been changed outside of ${app.getName()}.`,
            'Do you want to reload the file? Your changes will be lost.'
        ].join('\n'), 'warning', ['Reload', 'Don’t Reload'], 1)

        if (answer == 0) {
            sabaki.loadFile(view.getRepresentedFilename(), true)
        }

        $('body').data('filehash', hash)
    }
}

/**
 * Game Board Methods
 */

sabaki.vertexClicked = function(vertex, buttonIndex = 0, ctrlKey = false, position = null) {
    view.closeGameInfo()

    if (typeof vertex == 'string') {
        vertex = sabaki.getBoard().coord2vertex(vertex)
    }

    if (view.getScoringMode() || view.getEstimatorMode()) {
        if ($('#score').hasClass('show')) return
        if (buttonIndex != 0) return
        if (sabaki.getBoard().get(vertex) == 0) return

        let dead = !$('#goban .pos_' + vertex.join('-')).hasClass('dead')
        let stones = view.getEstimatorMode()
            ? sabaki.getBoard().getChain(vertex)
            : sabaki.getBoard().getRelatedChains(vertex)

        for (let v of stones) {
            $('#goban .pos_' + v.join('-')).toggleClass('dead', dead)
        }

        sabaki.updateAreaMap(view.getEstimatorMode())
    } else if (view.getEditMode()) {
        if (ctrlKey) {
            let coord = sabaki.getBoard().vertex2coord(vertex)

            view.setCommentText([view.getCommentText().trim(), coord].join(' ').trim())
            sabaki.commitCommentText()
        } else {
            sabaki.useTool(vertex, null, buttonIndex)
        }
    } else if (view.getFindMode()) {
        if (buttonIndex != 0) return

        view.setIndicatorVertex(vertex)
        sabaki.findMove(view.getIndicatorVertex(), view.getFindText(), 1)
    } else if (view.getGuessMode()) {
        if (buttonIndex != 0) return

        let tp = gametree.navigate(...sabaki.getCurrentTreePosition(), 1)
        if (!tp) {
            view.setGuessMode(false)
            return
        }

        let nextNode = tp[0].nodes[tp[1]]

        if ('B' in nextNode) view.setCurrentPlayer(1)
        else if ('W' in nextNode) view.setCurrentPlayer(-1)
        else {
            view.setGuessMode(false)
            return
        }

        let color = view.getCurrentPlayer() > 0 ? 'B' : 'W'
        let nextVertex = sgf.point2vertex(nextNode[color][0])
        let board = sabaki.getBoard()

        if (!board.hasVertex(nextVertex)) {
            view.setGuessMode(false)
            return
        }

        if (vertex[0] == nextVertex[0] && vertex[1] == nextVertex[1]) {
            sabaki.makeMove(vertex)
        } else {
            if (board.get(vertex) != 0) return
            if ($('#goban .pos_' + vertex.join('-')).hasClass('paint_1')) return

            let i = 0
            if (Math.abs(vertex[1] - nextVertex[1]) > Math.abs(vertex[0] - nextVertex[0]))
                i = 1

            for (let x = 0; x < board.width; x++) {
                for (let y = 0; y < board.height; y++) {
                    let z = i == 0 ? x : y
                    if (Math.abs(z - vertex[i]) < Math.abs(z - nextVertex[i]))
                        $('#goban .pos_' + x + '-' + y).addClass('paint_1')
                }
            }
        }
    } else if (view.getPlayMode() || view.getAutoplayMode()) {
        let board = sabaki.getBoard()

        if (buttonIndex == 0) {
            if (board.get(vertex) == 0) {
                sabaki.makeMove(vertex)
                view.closeDrawers()
            } else if (vertex in board.markups
                       && board.markups[vertex][0] == 'point'
                       && setting.get('edit.click_currentvertex_to_remove')) {
                sabaki.removeNode(...sabaki.getCurrentTreePosition())
            }
        } else if (buttonIndex == 2) {
            if (vertex in board.markups && board.markups[vertex][0] == 'point') {
                view.openCommentMenu(position)
{'fraction_non_alphanumeric': 0.1790642425436059, 'fraction_numerical': 0.006499341449303905, 'mean_word_length': 2.3764182702298324, 'pattern_counts': {'":': 0, '<': 37, '<?xml version=': 0, '>': 149, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1627285', 'n_tokens_mistral': 26223, 'n_tokens_neox': 25739, 'n_words': 4869}