DGS Utilities

Improvements of dragongoserver.net: conditional moves, grey skin, keyboard shortcuts.

// ==UserScript==
// @name         DGS Utilities
// @description  Improvements of dragongoserver.net: conditional moves, grey skin, keyboard shortcuts.
// @author       TPReal
// @namespace    https://gf.qytechs.cn/users/9113
// @version      0.4.8
// @match        *://www.dragongoserver.net/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

/* jshint ignore:start */
(()=>{
'use strict';

const EXTRA_CSS=`\
table.MessageForm textarea[name='message'] {
  height: 2.5em;
}

div#condMoves {
  margin: 1em;
}

div#condMoves input {
  width: 35em;
  margin-left: 0.5em;
}

div#condMoves > * {
  vertical-align: baseline;
}

span.game-info-char {
  font-size: 0.8em;
}

div#loadingInfo {
  position: fixed;
  bottom: 0;
  left: 0;
  background: #0c41c9;
  color: #fffc70;
  margin: 8px;
  padding: 0 0.3em;
  display: none;
}

table#PoolViewerTable td.Matrix > a[title~="[Timeout]"]::after {
  content: "T";
  font-size: 0.5em;
}
`;

const SKIN_GREY=`\
body {
  background: #f8f8f8;
  font-family: Georgia, Times, Times New Roman, serif;
}

table#pageHead, table#pageFoot {
  border: solid 1px #707070;
  background: #d0d0d0;
  color: #202020;
  margin-top: -2px;
}

table#pageHead a, table#pageFoot a {
  color: #202020;
}

table#pageMenu {
  background: #f8f8f8;
  border-color: #707070;
}

span.MainMenuCount {
  color: #a0a0a0;
}

table.GameInfos tr {
  background: #eeeeee;
}

table.Links a {
  color: #202020;
}

a[target] {
  color: initial;
}

a {
  color: initial;
}

table.Goban img.brdl, table.Goban img.brdn, table.Goban img[src='images/blank.gif'] {
  filter: grayscale() brightness(102%);
}

td.Logo1 img[alt='Dragon'] {
  filter: grayscale() brightness(101%);
}

img[alt='Dragon Go Server'] {
  filter: grayscale() brightness(68.5%) contrast(300%);
}

img[alt='Samuraj Logo'] {
  filter: grayscale() brightness(97%);
  margin-top: -10px;
}

img[alt='Rating graph'] {
  filter: grayscale();
}

table.Table tr.Row2, table.Table tr.Row4 {
  background: #f4f4f4;
}

table.Table tr.Row1 td.RemTimeWarn2, table.Table tr.Row2 td.RemTimeWarn2,
table.Table tr.Row3 td.RemTimeWarn2, table.Table tr.Row4 td.RemTimeWarn2 {
  background: initial;
}

table.Table tr.Row1 td.RemTimeWarn1, table.Table tr.Row2 td.RemTimeWarn1,
table.Table tr.Row3 td.RemTimeWarn1, table.Table tr.Row4 td.RemTimeWarn1 {
  background: #fa96a6;
}

h3.Header, h3.Header .Rating, h3.Header .User {
  color: initial;
}

table.Infos, table.InfoBox, table.MessageBox {
  border-collapse: collapse;
  border-color: #707070;
}

table.Infos td, table.InfoBox td, table.MessageBox td, table.Infos th, table.Infos td {
  border-color: #707070;
}

img[alt='RSS'] {
  display: none;
}

font[color] {
  color: initial;
}

table.Table td.Sgf a {
  color: initial;
}

td.ServerHome {
  color: rgba(0,0,0,0);
}

td.ServerHome select, td.ServerHome input {
  display: none;
}

table#PoolViewerTable tr.Empty, table#PoolViewerTable tr.Title {
  background: initial;
}

dl.ExtraInfos dd.Guidance, dl.ExtraInfos dd.Info {
  color: initial;
}

table.Infos th {
  color: initial;
}

table.GameNotes th {
  background: #d0d0d0;
  color: initial;
}

div#loadingInfo {
  background: #d0d0d0;
  color: #202020;
  border: solid 1px #707070;
}
`;

const RELOAD_INTERVAL=10*60*1000;

class LoadingIndicator{

  constructor(){
    this.counter_=0;
    this.element_=null;
  }

  static create(){
    return new LoadingIndicator();
  }

  init(){
    this.element_=document.createElement("div");
    this.element_.id="loadingInfo";
    this.element_.title="Working...";
    this.element_.innerHTML="...";
    document.body.appendChild(this.element_);
    this.setVisibility_();
  }

  registerPromise(loadingPromise){
    this.counter_++;
    this.setVisibility_();
    loadingPromise.catch(()=>null).then(()=>{
      setTimeout(()=>{
        this.counter_--;
        this.setVisibility_();
      },10);
    });
    return loadingPromise;
  }

  registerStart(){
    let done;
    this.registerPromise(new Promise(success=>{
      done=()=>success(null);
    }));
    return done;
  }

  setVisibility_(){
    if(this.element_)
      this.element_.style.display=this.counter_?"block":null;
  }

}

const LOADING_INDICATOR=LoadingIndicator.create();

async function ajax(path,params={},init={}){
  const searchParams=new URLSearchParams();
  for(const param of Object.keys(params))
    searchParams.set(param,params[param]);
  const paramsStr=searchParams.toString();
  const done=LOADING_INDICATOR.registerStart();
  try{
    const response=await fetch(
      `${path}${paramsStr?`?${paramsStr}`:``}`,
      Object.assign({credentials:"include"},init));
    return response.ok?response.text():Promise.reject(response);
  }finally{
    done();
  }
}

class ParseError extends Error{}

const coord={

  SGFPattern:"[a-s]{2}",

  fromSGF(str){
    if(!str.match(/^[a-s]{2}$/))
      throw new Error(`Bad SGF coordinates format: ${str}`);
    const x=str.charCodeAt(0)-96;
    const y=str.charCodeAt(1)-96;
    return `${String.fromCharCode((x>=9?x+1:x)+96)}${20-y}`;
  },

  validate(str){
    if(!str.match(/^[a-hj-t]([1-9]|1[0-9])$/))
      throw new ParseError(`Bad move coordinates: ${str||"(empty)"}`);
  },

  toSGF(str){
    this.validate(str);
    let xc=str.charCodeAt(0)-96;
    const x=xc>=9?xc-1:xc;
    const y=20-parseInt(str.substr(1),10);
    return String.fromCharCode(x+96,y+96);
  },

};

class CondMoves{

  constructor(gameId,atMoveNo,tree={}){
    this.gameId=gameId;
    this.atMoveNo=atMoveNo;
    this.tree=tree;
  }

  static parseUserString(gameId,atMoveNo,str){
    const condMoves=new CondMoves(gameId,atMoveNo);
    for(const path of str.trim().split(/\s*[,;\n]\s*/)){
      if(!path)
        continue;
      const moves=path.split(/\s+/);
      for(const move of moves)
        coord.validate(move);
      if(moves.length%2!==0)
        throw new ParseError(`Path with odd length (no response specified for opponent's last move): ${moves.join(" ")}`);
      let cm=condMoves;
      for(let i=0;i<moves.length;i+=2){
        const branch=cm.getBranch_(moves[i],moves[i+1]);
        cm=branch.condMoves;
      }
    }
    return condMoves;
  }

  getBranch_(move,response){
    let branch=this.tree[move];
    if(branch&&branch.response!==response)
      throw new ParseError(`Inconsistent response to ${move} in different branches: ${branch.response} vs ${response}`);
    if(!branch){
      branch={response,condMoves:new CondMoves(this.gameId,this.atMoveNo+2)};
      this.tree[move]=branch;
    }
    return branch;
  }

  hasMoves(){
    return !!Object.keys(this.tree).length;
  }

  toUserString(){
    return this.toUserStringHelper_().map(path=>path.join(" ")).join(", ");
  }

  toUserStringHelper_(){
    const paths=[];
    if(this.hasMoves()){
      for(const move of Object.keys(this.tree)){
        const branch=this.tree[move];
        for(const path of branch.condMoves.toUserStringHelper_())
          paths.push([move,branch.response,...path]);
      }
    }else
      paths.push([]);
    return paths;
  }

  toString(){
    return `CondMoves[${this.gameId}@${this.atMoveNo}: ${this.toUserString()}]`;
  }

  serialise(){
    return `@${this.atMoveNo} ${this.toUserString()}`;
  }

  static deserialise(gameId,str){
    const mat=str.match(/^\s*@(\d+) (.*)$/);
    if(!mat)
      throw new Error(`Bad serialised conditional moves: ${str}`);
    return CondMoves.parseUserString(gameId,parseInt(mat[1],10),mat[2]);
  }

}

/*
class Storage{

  constructor(base,fieldsWithDefaults,serialiser=JSON){
    const serialise=(serialiser.serialise||serialiser.stringify).bind(serialiser);
    for(const field of Object.keys(fieldsWithDefaults)){
      const defVal=fieldsWithDefaults[field];
      Object.defineProperty(this,field,{
        get:()=>{
          const str=base.getItem(field);
          if(str===null)
            return defVal;
          return serialiser.parse(str);
        },
        set:v=>{
          base.setItem(field,serialise(v));
        },
      });
    }
  }

}

const LOCAL_STORAGE=new Storage(localStorage,{});
*/

class PrivateNotes{

  constructor(gameId,base,condMoves){
    this.gameId=gameId;
    this.base=base;
    this.condMoves=condMoves;
  }

  static parse(gameId,text){
    const mat=text.match(/^([\s\S]*?)(?:(?:^|\n)Conditional moves: (.+)\n*)?$/);
    let base=mat[1];
    let condMoves=null;
    if(mat[2])
      try{
        condMoves=CondMoves.deserialise(gameId,mat[2]);
        if(!(condMoves instanceof CondMoves))
          throw new Error(`Expected CondMoves, got ${condMoves}`);
      }catch(e){
        console.warn(`Cannot parse conditional moves from private notes: ${mat[2]}, error: ${e}`);
        base=mat[0];
        condMoves=null;
      }
    base=base.trim();
    if(base)
      base+="\n";
    return new PrivateNotes(gameId,base,condMoves);
  }

  static empty(gameId){
    return new PrivateNotes(gameId,"",null);
  }

  async saveCondMoves(condMoves){
    this.condMoves=condMoves;
    let text=this.base;
    if(condMoves&&condMoves.hasMoves())
      text+=`\n\n\nConditional moves: ${condMoves.serialise()}\n`;
    await ajax("/quick_do.php",{
      obj:"game",
      cmd:"save_notes",
      gid:this.gameId,
      notes:text,
    });
    ajax("/quick_do.php",{
      obj:"game",
      cmd:"hide_notes",
      gid:this.gameId,
    });
  }

  toString(){
    return `PrivateNotes[condMoves=${this.condMoves}]`;
  }

}

class GameSGF{

  constructor(gameId,moveNo,lastMove,privateNotes,hasExtraComments){
    this.gameId=gameId;
    this.moveNo=moveNo;
    this.lastMove=lastMove;
    this.privateNotes=privateNotes;
    this.hasExtraComments=hasExtraComments;
  }

  get condMoves(){
    return this.privateNotes&&this.privateNotes.condMoves;
  }

  static parse(gameId,sgf){
    const nodes=sgf.split("\n;").slice(1);
    const firstNodeTags=GameSGF.parseTags_(nodes[0]);
    const moveNo=firstNodeTags.XM?parseInt(firstNodeTags.XM,10):null;
    const lastNodeTags=GameSGF.parseTags_(nodes[nodes.length-1]);
    const lastMoveSGF=lastNodeTags.B||lastNodeTags.W;
    const lastMove=lastMoveSGF?coord.fromSGF(lastMoveSGF):null;
    const lastMoveComment=lastNodeTags.C;
    let privateNotes=null;
    let hasExtraComments=false;
    if(lastMoveComment){
      const nMat=lastMoveComment.match(/(?:^|\n)Notes - .+? \(.+?\):\n([\s\S]*)/);
      if(nMat){
        privateNotes=PrivateNotes.parse(gameId,nMat[1]);
        hasExtraComments=nMat[0].trim()!==lastMoveComment.trim();
      }else
        hasExtraComments=true;
    }
    return new GameSGF(gameId,moveNo,lastMove,privateNotes,hasExtraComments);
  }

  static async load(gameId,allowCache=false){
    return GameSGF.parse(gameId,await ajax("/sgf.php",{
      gid:gameId,
      owned_comments:1,
      quick_mode:1,
      no_cache:allowCache?0:1,
    }));
  }

  static parseTags_(node){
    const regexp=/([A-Z]{1,2})\[/g;
    const result={};
    for(;;){
      const mat=regexp.exec(node);
      if(!mat)
        break;
      let value="";
      let i=regexp.lastIndex;
      for(;;){
        if(i>=node.length)
          break;
        if(node[i]==="]"&&!(i+1<node.length&&node[i+1]==="["))
          break;
        if(node[i]==="\\"&&i+1<node.length&&(node[i+1]==="\\"||node[i+1]==="["))
          value+=node[++i];
        else
          value+=node[i];
        i++;
      }
      result[mat[1]]=value;
      regexp.lastIndex=i;
    }
    return result;
  }

  async executeCondMoves(){
    if(!this.condMoves||!this.moveNo||!this.lastMove)
      return {};
    const {clearCondMoves,condMoves:newCondMoves,response}=this.analyseCondMoves_();
    let condMovesToSave=clearCondMoves?null:newCondMoves;
    let promise;
    if(response){
      console.debug(`Responding in game ${this.gameId} to ${this.lastMove} with ${response}`);
      promise=ajax("/quick_do.php",{
        obj:"game",
        cmd:"move",
        gid:this.gameId,
        move_id:this.moveNo,
        move:coord.toSGF(response),
      },{method:"POST"}).then(()=>({responded:true}));
      promise.catch(error=>{
        console.warn(`Responding in game ${this.gameId} at move ${this.moveNo} with ${response} failed: ${error}`);
        condMovesToSave=null;
      });
    }else
      promise=Promise.resolve({});
    promise.catch(()=>null).then(()=>this.saveCondMoves(condMovesToSave));
    return promise;
  }

  analyseCondMoves_(){
    if(this.hasExtraComments){
      console.debug(`In game ${this.gameId} additional information is associated with last move; clearing conditional moves`);
      return {clearCondMoves:true};
    }
    if(this.moveNo!==this.condMoves.atMoveNo){
      console.debug(`In game ${this.gameId} current move is ${this.moveNo}, but conditional moves defined for move ${this.condMoves.atMoveNo}; clearing conditional moves`);
      return {clearCondMoves:true};
    }
    const branch=this.condMoves.tree[this.lastMove];
    if(!branch){
      console.debug(`In game ${this.gameId} no response defined for ${this.lastMove}; clearing conditional moves`);
      return {clearCondMoves:true};
    }
    return branch;
  }

  async checkCondMovesOnOpponentTurn(){
    if(!this.condMoves||!this.moveNo)
      return null;
    if(this.moveNo>this.condMoves.atMoveNo){
      console.debug(`In game ${this.gameId} current move is ${this.moveNo}, but conditional moves defined for move ${this.condMoves.atMoveNo}; clearing conditional moves`);
      return this.saveCondMoves(null);
    }
    return null;
  }

  async saveCondMoves(condMoves){
    if(!this.privateNotes)
      this.privateNotes=PrivateNotes.empty(this.gameId);
    return this.privateNotes.saveCondMoves(condMoves);
  }

  toString(){
    return `GameSGF[gameId=${this.gameId}, @${this.moveNo}, lastMove=${this.lastMove
       }, privateNotes=${this.privateNotes}, hasExtraComments=${this.hasExtraComments}]`;
  }

}

class QuickStatus{

  constructor(objects){
    this.objects_=objects;
  }

  static parse(status){
    const errMat=status.match(/\[#Error: (.+?)\]$/m);
    if(errMat)
      throw new Error(`QuickStatus parse error: ${errMat[1]}`);
    const objectsByType=new Map();
    const headersByType=new Map();
    const getFields=str=>{
      const fields=[];
      let field="";
      let quoted=false;
      for(let i=0;i<str.length;i++){
        if(str[i]==="'"){
          if(quoted)
            quoted=false;
          else if(field)
            throw new Error(`Unexpected quote in string: ${str}`);
          else
            quoted=true;
        }else if(str[i]==="\\"&&quoted){
          if(++i>=str.length)
            throw new Error(`Unexpected end of string: ${str}`);
          field+=str[i];
        }else if(str[i]===","&&!quoted){
          fields.push(field);
          field="";
        }else
          field+=str[i];
      }
      fields.push(field);
      return fields;
    };
    const scan=function*(str,regexp){
      let mat;
      while(mat=regexp.exec(str))
        yield mat;
    };
    for(const mat of scan(status,/^## ([A-Z]),(.+?)$/mg))
      headersByType.set(mat[1],getFields(mat[2]));
    for(const mat of scan(status,/^([A-Z]),(.+?)$/mg)){
      const type=mat[1];
      let objects=objectsByType.get(type);
      if(!objects){
        objects=[];
        objectsByType.set(type,objects);
      }
      const headers=headersByType.get(type);
      const fields=getFields(mat[2]);
      const object={};
      objects.push(object);
      for(let i=0;i<headers.length;i++)
        object[headers[i]]=fields[i];
    }
    return new QuickStatus(objectsByType);
  }

  static async load(){
    return QuickStatus.parse(await ajax("/quick_status.php",{version:2,no_cache:1,order:0}));
  }

  get messages(){
    return this.objects_.get("M")||[];
  }

  get games(){
    return this.objects_.get("G")||[];
  }

}

class TitleUpdater{

  constructor(base,initialCount){
    this.base_=base;
    this.initialCount_=initialCount;
  }

  static create(){
    const mat=document.title.match(/^(.+?)(?: \((\d+)\))?$/)
    return new TitleUpdater(mat[1],mat[2]||null);
  }

  quickUpdate(){
    this.updateInternal_(this.initialCount_,0);
  }

  update(quickStatus){
    this.updateInternal_(quickStatus.games.length,quickStatus.messages.length);
  }

  updateInternal_(gamesCount,messagesCount){
    document.title=`${gamesCount==null?``:`[${gamesCount}${messagesCount?`, ${messagesCount}`:``}] `}${this.base_}`;
  }

}

class Manager{

  constructor(titleUpdater){
    this.titleUpdater_=titleUpdater;
    this.quickStatus=null;
  }

  static start(){
    const titleUpdater=TitleUpdater.create();
    titleUpdater.quickUpdate();
    const manager=new Manager(titleUpdater);
    const update=async()=>{
      try{
        await manager.update();
      }finally{
        scheduleUpdate();
      }
    };
    const scheduleUpdate=()=>setTimeout(update,RELOAD_INTERVAL);
    scheduleUpdate();
    return manager;
  }

  async update(){
    if(location.pathname==="/index.php")
      return;
    if(location.pathname==="/status.php")
      location.reload();
    else{
      this.quickStatus=await QuickStatus.load();
      this.titleUpdater_.update(this.quickStatus);
      Promise.all(this.quickStatus.games.map(game=>
        GameSGF.load(game.game_id).then(sgf=>sgf.executeCondMoves()).catch(error=>{
          console.warn(`Error while executing conditional move for game ${gameId}: ${error}`);
          return {error};
        }))).then(results=>{
          if(results.some(result=>result.responded))
            this.update();
        });
    }
  }

}

function addCSS(css){
  const styleElem=document.createElement("style");
  styleElem.innerText=css;
  document.head.appendChild(styleElem);
}

async function start(){

  const COND_MOVES_TR_INNER_HTML=`\
<td class="UnderBoard">
  <div id="condMoves" title="Specify branches of conditional moves, e.g.: f3 c6 d2 c3, c6 f3
Right-click on board to enter coordinates">
    <span>Conditional moves:</span>
    <input id="condMoves" type="text">
    <button id="saveCondMoves" type="button">Save</button>
  </div>
</td>`;

  LOADING_INDICATOR.init();

  const manager=Manager.start();

  const keyHandlers=new Map();

  if(location.pathname==="/status.php"){
    Promise.all([...document.querySelectorAll("table#gameTable tr td.Button:first-child")].map(gameIdElem=>{
      const gameId=gameIdElem.innerText.trim();
      return GameSGF.load(gameId).then(sgf=>sgf.executeCondMoves()).catch(error=>{
        console.warn(`Error while executing conditional move for game ${gameId}: ${error}`);
        return {error};
      });
    })).then(results=>{
      if(results.some(result=>result.responded))
        location.reload();
    });
  }else if(location.pathname==="/game.php"){
    const gameId=location.searchParams.get("g")||location.searchParams.get("gid");
    let gameState;
    if(location.searchParams.get("a")==="domove")
      gameState="confirmMove";
    else if(location.searchParams.get("a")==="resign")
      gameState="resigning";
    else if(document.querySelector("dl.ExtraInfos dd.Score"))
      gameState="finished";
    else if(document.querySelector("input[name='action'][value='choose_move']"))
      gameState="myMove";
    else
      gameState="theirMove";

    const eidogoLinkImg=document.querySelector("a.NoPrint > img[title='EidoGo Game Player']");
    if(eidogoLinkImg)
      eidogoLinkImg.parentElement.setAttribute("target","_blank");

    if(gameState==="confirmMove"){
      const linkifyField=(imageElement,href)=>{
        if(!imageElement)
          return;
        const td=imageElement.parentElement;
        const link=document.createElement("a");
        link.setAttribute("href",href);
        link.appendChild(imageElement);
        td.appendChild(link);
      };
      const moveParams=new URLSearchParams(location.searchParams);
      for(const mark of [".",","]){
        for(const img of document.querySelectorAll(`table#Goban td[id].brdx img[alt='${mark}'].brdx`)){
          moveParams.set("c",img.parentElement.id);
          linkifyField(img,`/game.php?${moveParams}`);
        }
      }
      moveParams.delete("a");
      moveParams.delete("c");
      for(const mark of ["@","#"])
        linkifyField(document.querySelector(`table#Goban td[id].brdx img[alt='${mark}'].brdx`),`/game.php?${moveParams}`);

      const cancelMove=()=>document.querySelector("input[name='cancel']").click();
      keyHandlers.set("ArrowLeft",cancelMove);
      keyHandlers.set("Home",cancelMove);
      keyHandlers.set("End",cancelMove);
    }else{
      const navigate=(selOptFunc)=>{
        const selMoveOption=document.querySelector("select[name='gotomove'] option[selected]");
        if(!selMoveOption)
          return;
        const newMoveOption=selOptFunc(selMoveOption);
        if(newMoveOption&&newMoveOption!==selMoveOption){
          selMoveOption.removeAttribute("selected");
          newMoveOption.setAttribute("selected","");
          document.querySelector("input[name='movechange']").click();
        }
      };
      keyHandlers.set("ArrowLeft",()=>navigate(o=>o.nextElementSibling));
      keyHandlers.set("ArrowRight",()=>navigate(o=>o.previousElementSibling));
      keyHandlers.set("Home",()=>navigate(o=>o.parentElement.lastElementChild));
      keyHandlers.set("End",()=>navigate(o=>o.parentElement.firstElementChild));
    }

    if(gameState==="confirmMove"||(gameState==="theirMove"&&
        document.querySelector("select[name='gotomove'] option[selected]:first-of-type"))){
      const condMovesRow=document.createElement("tr");
      condMovesRow.innerHTML=COND_MOVES_TR_INNER_HTML;
      const summaryRow=document.querySelector("table#GamePage > tbody > tr:nth-of-type(2)");
      condMovesRow.querySelector("td").setAttribute("colspan",summaryRow.querySelector("td").getAttribute("colspan"));
      if(gameState==="confirmMove")
        condMovesRow.querySelector("#saveCondMoves").style.display="none";
      const condMovesInput=condMovesRow.querySelector("input#condMoves");
      summaryRow.parentElement.insertBefore(condMovesRow,summaryRow);
      const sgfPromise=GameSGF.load(gameId);
      sgfPromise.then(sgf=>{
        if(sgf.condMoves)
          condMovesInput.value=sgf.condMoves.toUserString();
      });
      const moveNoOffset=gameState==="confirmMove"?2:1;
      const saveCondMoves=()=>{
        const promise=sgfPromise.then(sgf=>{
          const condMoves=CondMoves.parseUserString(gameId,sgf.moveNo+moveNoOffset,condMovesInput.value);
          condMovesInput.value=condMoves.toUserString();
          return sgf.saveCondMoves(condMoves);
        });
        promise.catch(error=>{
          let msg;
          if(error instanceof ParseError)
            msg=`Invalid conditional moves: ${error.message}`;
          else
            msg=`Error saving conditional moves: ${error}`;
          console.warn(msg);
          alert(msg);
        });
        return promise;
      };
      condMovesRow.querySelector("#saveCondMoves").addEventListener("click",()=>{
        saveCondMoves();
        return true;
      });
      condMovesInput.addEventListener("keydown",event=>{
        if(event.code==="Enter"){
          saveCondMoves();
          event.preventDefault();
        }
      });
      const rightClickHandler=event=>{
        if(event.button===2){
          const move=coord.fromSGF(event.currentTarget.id);
          const text=condMovesInput.value;
          const focused=document.activeElement===condMovesInput;
          let selRange;
          if(focused)
            selRange=[text.length,text.length];
          else
            selRange=[condMovesInput.selectionStart,condMovesInput.selectionEnd];
          const preText=text.substring(0,selRange[0]);
          const postText=text.substring(selRange[1]);
          let newText=preText;
          if(preText.match(/\S$/))
            newText+=" ";
          newText+=move;
          const newCursorPos=newText.length;
          if(postText.match(/^[^,\s]/))
             newText+=" ";
          newText+=postText;
          condMovesInput.value=newText;
          condMovesInput.focus();
          condMovesInput.setSelectionRange(newCursorPos,newCursorPos);
          event.preventDefault();
        }
      };
      for(const field of document.querySelectorAll("table#Goban td[id].brdx")){
        field.addEventListener("mouseup",rightClickHandler);
        field.addEventListener("contextmenu",event=>event.preventDefault());
      }
      let allowedSubmitButton=null;
      const submitHandler=event=>{
        if(event.target===allowedSubmitButton)
          return;
        saveCondMoves().then(()=>{
          allowedSubmitButton=event.target;
          event.target.click();
        });
        event.preventDefault();
      };
      for(const name of ["nextgame","nextstatus"]){
        const button=document.querySelector(`input[type='submit'][name='${name}']`);
        if(button)
          button.addEventListener("click",submitHandler);
      }
    }

    keyHandlers.set("Space",()=>{
      const skipParams=new URLSearchParams();
      skipParams.set("gid",gameId);
      skipParams.set("nextskip","t");
      location.href=`/confirm.php?${skipParams}`;
    });
  }else if(location.pathname==="/show_games.php"){
    for(const gameIdElem of document.querySelectorAll("table#runningTable tr td.Button:first-child")){
      const infoElem=gameIdElem.parentElement.querySelector("td.ImagesLeft");
      if(infoElem){
        const gameId=gameIdElem.innerText.trim();
        GameSGF.load(gameId).then(sgf=>{
          return sgf.checkCondMovesOnOpponentTurn().then(()=>{
            if(sgf.privateNotes&&sgf.privateNotes.base.trim())
              infoElem.innerHTML+=` <span class="game-info-char" title="There are private notes saved for this game">(p)</span>`;
            if(sgf.condMoves)
              infoElem.innerHTML+=` <span class="game-info-char" title="Conditional moves are defined for this game">(c)</span>`;
          });
        });
      }
    }
  }else if(location.pathname==="/error.php")
    setTimeout(()=>location.href="/status.php",3600*1000);
  keyHandlers.set("Escape",()=>location.href=`/status.php`);

  if(keyHandlers.size){
    document.addEventListener("keydown",event=>{
      if(["INPUT","TEXTAREA"].includes(event.target.tagName))
        return;
      const code=event.code;
      const handler=keyHandlers.get(code);
      if(handler){
        event.preventDefault();
        handler();
      }
    });
  }

}

async function onLoaded(){
  addCSS(EXTRA_CSS);
  addCSS(SKIN_GREY);
  location.searchParams=new URLSearchParams(location.search);
  try{
    await start();
  }catch(e){
    console.error(e);
  }
}

if(document.readyState==="loading")
  document.addEventListener("DOMContentLoaded",onLoaded,false);
else
  onLoaded();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址