<!DOCTYPE html>
<!--
  This file is generated by WebScrapBook and is not intended to be edited.
  Create search.css and/or search.js for customization.
-->
<html dir="{{ i18n('@@bidi_dir') }}" data-scrapbook-tree-page="search">
<head>
<meta charset="UTF-8">
<title>{{ format_string(i18n('cache_search_title'), {'book': title}) }}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html {
  height: 100%;
}

body {
  margin: 0;
  padding: 0;
  line-height: 1.35em;
}

#searchForm {
  display: flex;
  flex-direction: row;
  padding: .25em;
}

#keyword {
  flex: auto;
  min-width: 150px;
}

#helper {
  width: 2em;
}

#help {
  padding: .25em;
  font-size: .9em;
}

#help > div {
  white-space: pre-wrap;
}

#result {
  margin: 0;
  margin-top: 1em;
  padding: 0;
}

#support {
  margin-top: 2em;
  padding: .5em;
  background-color: #FEE;
  font-size: .85em;
}

ul {
  margin: 0;
  padding: 0;
}

li {
  list-style-type: none;
  margin: .2em;
  padding-{{ i18n('@@bidi_start_edge') }}: 1em;
}

li > div {
  white-space: nowrap;
}

li > div:hover {
  background-color: rgba(196, 221, 252, 0.3);
}

a {
  text-decoration: none;
  color: #000000;
}

a:focus {
  outline-style: auto;
  background-color: rgba(196, 221, 252, 1);
}

a > img {
  display: inline-block;
  margin: 0 .2em;
  border: none;
  width: 1em;
  height: 1em;
  vertical-align: middle;
}

a.scrapbook-toggle {
  margin-{{ i18n('@@bidi_start_edge') }}: -1.45em;
}

a.scrapbook-toggle > img {
  margin: 0;
}

a.scrapbook-external > img {
  margin: 0 .1em;
  width: .7em;
  height: .7em;
  vertical-align: top;
}

.scrapbook-type-bookmark > div > a {
  color: rgb(32,192,32);
}

.scrapbook-type-note > div > a {
  color: rgb(80,0,32);
}

.scrapbook-type-combine > div > a {
  color: blue;
}

.scrapbook-type-separator > div > fieldset {
  margin: 0;
  border: none;
  border-top: 1px solid #aaa;
  padding: 0 0 0 1em;
  text-indent: 0;
}

.scrapbook-type-separator > div > fieldset > legend {
  padding: 0;
}

.scrapbook-marked > div > a {
  font-weight: bold;
}
</style>
<link rel="stylesheet" href="search.css">
<script>
const conf = {
  scrapbooks: [
    {
      name: "",
      path: {{ path | tojson }},
      dataDir: {{ data_dir | tojson }},
      treeDir: {{ tree_dir | tojson }},
      indexPage: {{ index | tojson }}
    }
  ],
  allowHttp: 0,  // whether to load js cache from HTTP(S)? -1: deny, 0: ask; 1: allow
  defaultSearch: "-type:folder -type:separator",  // the constant string to add before the input keyword
  viewInMapTitle: {{ i18n('cache_search_view_in_map') | tojson }},  // title for "view in map"
};

const scrapbook = {
  books: [],

  data: null,

  toc(data) {
    this.data.toc = Object.assign(this.data.toc, data);
  },

  meta(data) {
    this.data.meta = Object.assign(this.data.meta, data);
  },

  fulltext(data) {
    this.data.fulltext = Object.assign(this.data.fulltext, data);
  },

  init() {
    document.getElementById('searchForm').addEventListener('submit', (event) => {
      event.preventDefault();
      this.search();
    });
 
    document.getElementById('helper').addEventListener('change', (event) => {
      event.preventDefault();
      this.helperFill();
    });

    scrapbook.books = conf.scrapbooks.map(
      book => Object.assign({}, book, {
        toc: {},
        meta: {},
        fulltext: {},
      })
    );

    return this.loadBooks().then(() => {
      document.getElementById('search').disabled = false;
    });
  },

  loadBooks() {
    let p = Promise.resolve();
    scrapbook.books.forEach((book) => {
      p = p.then(() => {
        return this.loadBook(book);
      }).catch((ex) => {
        console.error(ex);
        this.addMsg("Error: " + ex.message);
      });
    });
    return p;
  },

  loadBook(book) {
    return Promise.resolve().then(() => {
      let base = this.resolveUrl(book.path, location.href);

      const loadMeta = () => {
        const loop = () => {
          const url = this.resolveUrl(book.treeDir + "meta" + (i || "") + ".js", base);
          return this.loadScript(url, true).then(() => {
            i += 1;
            return loop();
          }).catch((ex) => {
            if (i === 0) { throw ex; }
            console.log("Unable to load '" + url + "'");
          });
        };

        let i = 0;
        return loop();
      };

      const loadToc = () => {
        const loop = () => {
          const url = this.resolveUrl(book.treeDir + "toc" + (i || "") + ".js", base);
          return this.loadScript(url, true).then(() => {
            i += 1;
            return loop();
          }).catch((ex) => {
            if (i === 0) { throw ex; }
            console.log("Unable to load '" + url + "'");
          });
        };

        let i = 0;
        return loop();
      };

      const loadFulltext = () => {
        const loop = () => {
          const url = this.resolveUrl(book.treeDir + "fulltext" + (i || "") + ".js", base);
          return this.loadScript(url, true).then(() => {
            i += 1;
            return loop();
          }).catch((ex) => {
            console.log("Unable to load '" + url + "'");
          });
        };

        let i = 0;
        return loop();
      };

      scrapbook.data = book;
      if (!this.checkHttp(base)) {
        this.addMsg("Rejected to load remote fulltext cache: " + base);
        return Promise.all([
          loadMeta(),
          loadToc(),
        ]);
      } else {
        return Promise.all([
          loadMeta(),
          loadToc(),
          loadFulltext(),
        ]);
      }
    });
  },

  loadScript(url, noCache) {
    return new Promise((resolve, reject) => {
      const elem = document.createElement("script");
      document.getElementsByTagName("head")[0].appendChild(elem);
      elem.onload = (event) => {
        console.log("Loaded '" + url + "'");
        resolve();
      };
      elem.onerror = (event) => {
        elem.remove();
        reject(new Error("Failed to load '" + url + "'"));
      };
      elem.src = url + (noCache ? "?ts=" + Date.now() : "");
    });
  },

  checkHttp(url) {
    const targetUrl = this.resolveUrl(url, location.href);
    const targetUrlObj = new URL(targetUrl);
    if (['http:', 'https:'].indexOf(targetUrlObj.protocol) !== -1 &&
        ['localhost', '127.0.0.1'].indexOf(targetUrlObj.hostname) === -1) {
      if (conf.allowHttp === 0) {
        if (confirm("Loading remote fulltext cache may require large network flow. Continue?")) {
          conf.allowHttp = 1;
        } else {
          conf.allowHttp = -1;
        }
      }
      if (conf.allowHttp > 0) { return true; }
      return false;
    }
    return true;
  },

  search() {
    return Promise.resolve().then(() => {
      this.clearResult();

      // set query string
      let queryStr = document.getElementById("keyword").value;
      if (conf.defaultSearch) {
        queryStr = conf.defaultSearch + " " + queryStr;
      }

      // parse query
      const query = searchEngine.parseQuery(queryStr);
      if (query.error.length) {
        for (const err of query.error) {
          this.addMsg("Error: " + err);
        }
        return;
      }
      console.log("Search:", query);

      // search and get result
      return searchEngine.search(query);
    }).catch((ex) => {
      console.error(ex);
      this.addMsg("Error: " + ex.message);
    });
  },

  showResults(results, book) {
    const info = {
      "": "%",
      name: book.name,
      length: results.length,
    };

    if (book.name) {
      this.addMsg(this.formatString({{ i18n('cache_search_result_named') | tojson }}, info));
    } else {
      this.addMsg(this.formatString({{ i18n('cache_search_result') | tojson }}, info));
    }

    const wrapper = document.getElementById("result");
    for (const item of results) {
      this.addResult(item, book, wrapper);
    }

    this.addMsg("\u00A0");
  },

  addResult(item, book, wrapper) {
    const {id, file, meta, fulltext} = item;

    const li = document.createElement("li");
    const classes = [];
    if (meta.type) { classes.push('scrapbook-type-' + meta.type); }
    if (meta.marked) { classes.push('scrapbook-marked'); }
    li.className = classes.join(' ');

    const div = li.appendChild(document.createElement("div"));

    var a = div.appendChild(document.createElement("a"));
    if (meta.type !== "bookmark") {
      if (meta.index) {
        let subpath = 
            (!file || file === '.' || this.isZipFile(meta.index)) ? 
            meta.index : 
            meta.index.replace(/[^/]+$/, '') + file;
        subpath = this.escapeFilename(subpath || "");
        if (subpath) {
          a.href = book.path + book.dataDir + subpath;
        }
      }
    } else {
      if (meta.source) {
        a.href = meta.source;
      }
    }
    a.target = "main";
    a.textContent = meta.title || id;
    a.title = (meta.title || id) + (meta.source ? "\n" + meta.source : "");

    if (file && !(
        file === "." || 
        (this.isZipFile(meta.index) && file === "index.html") || 
        (!this.isZipFile(meta.index) && file === meta.index.replace(/^.*[/]/, ''))
        )) {
      const span = div.appendChild(document.createElement("span"));
      span.textContent = " (" + file + ")";
    }

    var icon = a.insertBefore(document.createElement('img'), a.firstChild);
    if (meta.icon) {
      icon.src = /^(?:[a-z][a-z0-9+.-]*:|[/])/i.test(meta.icon || "") ? 
          meta.icon : 
          (book.path + book.dataDir + this.escapeFilename(meta.index || "")).replace(/[/][^/]+$/, '/') + meta.icon;
    } else {
      icon.src = {
        'folder': 'icon/fclose.png',
        'note': 'icon/note.png',
        'postit': 'icon/postit.png',
      }[meta.type] || 'icon/item.png';
    }
    icon.alt = "";

    var a = div.appendChild(document.createElement("a"));
    a.href = book.path + book.indexPage + "#item-" + id;
    a.target = "_blank";
    a.className = "scrapbook-external";
    a.title = conf.viewInMapTitle;
    var img = a.appendChild(document.createElement("img"));
    img.src = "icon/external.png";
    img.alt = "";

    wrapper.appendChild(li);
  },

  clearResult() {
    document.getElementById("result").innerHTML = "";
  },

  addMsg(msg) {
    let wrapper = document.getElementById("result");
    let result = document.createElement("li");
    result.appendChild(document.createTextNode(msg));
    wrapper.appendChild(result);
  },

  formatString(text, map) {
    map[""] = "%";
    return text.replace(/%(\w*)%/g, (m, k) => {
      return typeof map[k] !== 'undefined' ? map[k] : "";
    });
  },

  resolveUrl(url, base) {
    try {
      return new URL(url, base).href;
    } catch(ex) {
      // unable to resolve
    }
    return url;
  },

  isZipFile(path) {
    const p = path.toLowerCase();
    return p.endsWith('.htz') || p.endsWith('.maff');
  },

  escapeRegExp(str) {
    return str.replace(/[-/\\^$*+?.|()[\]{}]/g, "\\$&");
  },

  escapeFilename(filename) {
    return filename.replace(/[^/]+/g, m => encodeURIComponent(m));
  },

  helperFill() {
    let helper = document.getElementById("helper");
    let keyword = document.getElementById("keyword");
    keyword.value = keyword.value + (keyword.value === "" ? "" : " ") + helper.value;
    helper.selectedIndex = 0;
    keyword.focus();
    keyword.setSelectionRange(keyword.value.length, keyword.value.length);
  },
};

const searchEngine = {
  get supportRegexUnicodeFlag() {
    let support = false;
    try {
      new RegExp('', 'u');
      support = true;
    } catch (ex) {}
    delete(this.supportRegexUnicodeFlag);
    return this.supportRegexUnicodeFlag = support;
  },

  parseQuery(queryStr) {
    const query = {
      error: [],
      rules: {},
      sorts: [],
      books: {
        include: [],
        exclude: [],
      },
      roots: {
        include: [],
        exclude: [],
      },
      mc: false,
      re: false,
      default: "tcc",
    };

    const addRule = (name, type, value) => {
      if (typeof query.rules[name] === "undefined") {
        query.rules[name] = {"include": [], "exclude": []};
      }
      query.rules[name][type].push(value);
    };

    const addSort = (key, order) => {
      switch (key) {
        case "id": case "file":
          query.sorts.push({key, order});
          break;
        case "content":
          query.sorts.push({key: "fulltext", subkey: key, order});
          break;
        default:
          query.sorts.push({key: "meta", subkey: key, order});
          break;
      }
    };

    const addError = (msg) => {
      query.error.push(msg);
    };

    const parseStr = (term, exactMatch = false) => {
      let flags = query.mc ? "m" : "im";
      if (this.supportRegexUnicodeFlag) { flags += "u"; }
      let regex = "";
      if (query.re) {
        try {
          regex = new RegExp(term, flags);
        } catch(ex) {
          addError("Invalid RegExp: " + term);
          return null;
        }
      } else {
        let key = scrapbook.escapeRegExp(term);
        if (exactMatch) { key = "^" + key + "$"; }
        regex = new RegExp(key, flags);
      }
      return regex;
    };

    const parseDate = (term) => {
      const match = term.match(/^(\d{0,17})(?:-(\d{0,17}))?$/);
      if (!match) {
        addError("Invalid date format: " + term);
        return null;
      }
      const since = match[1] ? this.idLocalToUtc(pad(match[1], 17)) : pad("", 17);
      const until = match[2] ? this.idLocalToUtc(pad(match[2], 17)) : pad("", 17, "9");
      return [since, until];
    };

    const pad = (n, width, z) => {
      z = z || "0";
      n = n + "";
      return n.length >= width ? n : n + new Array(width - n.length + 1).join(z);
    };

    queryStr.replace(/(-*[A-Za-z]+:|-+)(?:"([^"]*(?:""[^"]*)*)"|([^"\s]*))|(?:"([^"]*(?:""[^"]*)*)"|([^"\s]+))/g, (match, cmd, qterm, term, qterm2, term2) => {
      let pos = true;
      if (cmd) {
        term = (qterm !== undefined) ? qterm.replace(/""/g, '"') : term;
        let m = /^(-*)(.*)$/.exec(cmd);
        if (m[1].length % 2 === 1) { pos = false; }
        cmd = m[2];
      } else {
        term = (qterm2 !== undefined) ? qterm2.replace(/""/g, '"') : term2;
      }

      if (cmd) {
        cmd = cmd.slice(0, -1);
      } else {
        cmd = query.default;
      }

      switch (cmd) {
        case "default":
          query.default = String(term);
          break;
        case "mc":
          query.mc = pos;
          break;
        case "re":
          query.re = pos;
          break;
        case "book":
          query.books[pos ? 'include' : 'exclude'].push(term);
          break;
        case "root":
          query.roots[pos ? 'include' : 'exclude'].push(term);
          break;
        case "sort":
          addSort(term, pos ? 1 : -1);
          break;
        case "type":
          addRule("type", pos ? "include" : "exclude", parseStr(term, true));
          break;
        case "id":
          addRule("id", pos ? "include" : "exclude", parseStr(term, true));
          break;
        case "file":
          addRule("file", pos ? "include" : "exclude", parseStr(term));
          break;
        case "source":
          addRule("source", pos ? "include" : "exclude", parseStr(term));
          break;
        case "icon":
          addRule("icon", pos ? "include" : "exclude", parseStr(term));
          break;
        case "tcc":
          addRule("tcc", pos ? "include" : "exclude", parseStr(term));
          break;
        case "title":
          addRule("title", pos ? "include" : "exclude", parseStr(term));
          break;
        case "comment":
          addRule("comment", pos ? "include" : "exclude", parseStr(term));
          break;
        case "content":
          addRule("content", pos ? "include" : "exclude", parseStr(term));
          break;
        case "index":
          addRule("index", pos ? "include" : "exclude", parseStr(term));
          break;
        case "create":
          addRule("create", pos ? "include" : "exclude", parseDate(term));
          break;
        case "modify":
          addRule("modify", pos ? "include" : "exclude", parseDate(term));
          break;
        case "marked":
          addRule("marked", pos ? "include" : "exclude", true);
          break;
        case "locked":
          addRule("locked", pos ? "include" : "exclude", true);
          break;
      }

      return "";
    });
    return query;
  },

  search(query) {
    const books = new Set(scrapbook.books);
    if (query.books.include.length) {
      for (const book of books) {
        if (!query.books.include.includes(book.name)) {
          books.delete(book);
        }
      }
    }
    for (const book of books) {
      if (query.books.exclude.includes(book.name)) {
        books.delete(book);
      }
    }

    let p = Promise.resolve();
    books.forEach((book) => {
      p = p.then(() => {
        return this.searchBook(query, book).then((results) => {
          scrapbook.showResults(results, book);
        });
      });
    });
    return p;
  },

  searchBook(query, book) {
    return Promise.resolve().then(() => {
      const results = [];

      const idPool = new Set();
      {
        if (!query.roots.include.length) {
          query.roots.include.push('root');
        }

        for (const root of query.roots.include) {
          for (const id of this.getReachableItems(book, root)) {
            idPool.add(id);
          }
        }

        for (const root of query.roots.exclude) {
          for (const id of this.getReachableItems(book, root)) {
            idPool.delete(id);
          }
        }
      }

      for (const id of idPool) {
        let subfiles = book.fulltext[id] || {};
        if (!Object.keys(subfiles).length) { subfiles[""] = {}; }

        for (const file in subfiles) {
          const item = {
            id,
            file,
            meta: book.meta[id],
            fulltext: subfiles[file],
          };
          if (this.matchItem(item, query)) {
            results.push(item);
          }
        }
      }

      // sort results
      for (const {key, subkey, order} of query.sorts) {
        results.sort((a, b) => {
          a = a[key]; if (subkey) { a = a[subkey]; } a = a || "";
          b = b[key]; if (subkey) { b = b[subkey]; } b = b || "";
          if (a > b) { return order; }
          if (a < b) { return -order; }
          return 0;
        });
      }

      return results;
    });
  },

  getReachableItems(book, root, set = new Set()) {
    const addIdRecursively = (id) => {
      for (const refId of book.toc[id]) {
        if (book.meta[refId] && !set.has(refId)) {
          set.add(refId);
          if (book.toc[refId]) {
            addIdRecursively(refId);
          }
        }
      }
    };
    if (book.meta[root]) {
      set.add(root);
    }
    if (book.toc[root]) {
      addIdRecursively(root);
    }
    return set;
  },

  matchItem(item, query) {
    if (!item.meta) {
      return false;
    }

    for (const i in query.rules) {
      if (!this["_match_" + i](query.rules[i], item)) { return false; }
    }

    return true;
  },

  _match_tcc(rule, item) {
    return this.matchText(rule, [item.meta.title, item.meta.comment, item.fulltext.content].join("\n"));
  },

  _match_content(rule, item) {
    return this.matchText(rule, item.fulltext.content);
  },

  _match_id(rule, item) {
    return this.matchTextOr(rule, item.id);
  },

  _match_file(rule, item) {
    return this.matchText(rule, item.file);
  },

  _match_title(rule, item) {
    return this.matchText(rule, item.meta.title);
  },

  _match_comment(rule, item) {
    return this.matchText(rule, item.meta.comment);
  },

  _match_index(rule, item) {
    return this.matchText(rule, item.meta.index);
  },

  _match_source(rule, item) {
    return this.matchText(rule, item.meta.source);
  },

  _match_icon(rule, item) {
    return this.matchText(rule, item.meta.icon);
  },

  _match_type(rule, item) {
    return this.matchTextOr(rule, item.meta.type);
  },

  _match_create(rule, item) {
    return this.matchDate(rule, item.meta.create);
  },

  _match_modify(rule, item) {
    return this.matchDate(rule, item.meta.modify);
  },

  _match_marked(rule, item) {
    return this.matchBool(rule, item.meta.marked);
  },

  _match_locked(rule, item) {
    return this.matchBool(rule, item.meta.locked);
  },

  matchBool(rule, bool) {
    if (rule.exclude.length) {
      if (bool) {
        return false;
      }
    }

    if (rule.include.length) {
      if (!bool) {
        return false;
      }
    }

    return true;
  },

  matchText(rule, text) {
    text = text || "";

    for (const key of rule.exclude) {
      if (key.test(text)) {
        return false;
      }
    }

    for (const key of rule.include) {
      if (!key.test(text)) {
        return false;
      }
    }

    return true;
  },

  matchTextOr(rule, text) {
    text = text || "";
    
    for (const key of rule.exclude) {
      if (key.test(text)) {
        return false;
      }
    }

    if (!rule.include.length) { return true; }
    for (const key of rule.include) {
      if (key.test(text)) {
        return true;
      }
    }
    return false;
  },

  matchDate(rule, date) {
    if (!date) { return false; }

    for (const key of rule.exclude) {
      if (key[0] <= date && date <= key[1]) {
        return false;
      }
    }

    for (const key of rule.include) {
      if (!(key[0] <= date && date <= key[1])) {
        return false;
      }
    }

    return true;
  },

  idLocalToUtc(id) {
    if (/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})$/.test(id)) {
      const dd = new Date(
          parseInt(RegExp.$1, 10), Math.max(parseInt(RegExp.$2, 10), 1) - 1, Math.max(parseInt(RegExp.$3, 10), 1),
          parseInt(RegExp.$4, 10), parseInt(RegExp.$5, 10), parseInt(RegExp.$6, 10), parseInt(RegExp.$7, 10)
          );
      return dd.getUTCFullYear() +
          this.intToFixedStr(dd.getUTCMonth() + 1, 2) +
          this.intToFixedStr(dd.getUTCDate(), 2) +
          this.intToFixedStr(dd.getUTCHours(), 2) +
          this.intToFixedStr(dd.getUTCMinutes(), 2) +
          this.intToFixedStr(dd.getUTCSeconds(), 2) +
          this.intToFixedStr(dd.getUTCMilliseconds(), 3);
    }
    return null;
  },

  intToFixedStr(number, width, padder) {
    padder = padder || "0";
    number = number.toString(10);
    return number.length >= width ? number : new Array(width - number.length + 1).join(padder) + number;
  },
};
</script>
<script src="search.js"></script>
</head>
<body>
<form id="searchForm">
  <input id="keyword" type="text">
  <select id="helper">
    <option value="" selected="selected"></option>
    <option value="id:">id:</option>
    <option value="title:">title:</option>
    <option value="comment:">comment:</option>
    <option value="content:">content:</option>
    <option value="tcc:">tcc:</option>
    <option value="source:">source:</option>
    <option value="icon:">icon:</option>
    <option value="type:">type:</option>
    <option value="create:">create:</option>
    <option value="modify:">modify:</option>
    <option value="re:">re:</option>
    <option value="mc:">mc:</option>
    <option value="file:">file:</option>
    <option value="root:">root:</option>
    <option value="sort:">sort:</option>
    <option value="-sort:modify">{{ i18n('cache_search_sort_last_modified') }}</option>
    <option value="-sort:create">{{ i18n('cache_search_sort_last_created') }}</option>
    <option value="sort:title">{{ i18n('cache_search_sort_title') }}</option>
    <option value="sort:id">{{ i18n('cache_search_sort_id') }}</option>
  </select>
  <input id="search" type="submit" value="{{ i18n('cache_search_start') }}" disabled autocomplete="off">
</form>
<div>
<details id="help">
<summary>{{ i18n('cache_search_help_label') }}</summary>
<div>{{ i18n('cache_search_help_desc') }}</div>
</details>
<ul id="result"></ul>
</div>
<div id="support">
Supported browsers: Chromium ≥ 49, Firefox ≥ 41, Edge ≥ 14, Safari ≥ 8, with JavaScript enabled.
</div>
<script>scrapbook.init();</script>
</body>
</html>
