Voz save threads

Save your favorite threads

// ==UserScript==
// @name          Voz save threads
// @description   Save your favorite threads
// @namespace     Violentmonkey Scripts
// @match         *://voz.vn/*
// @version       0.1
// @run-at        document-idle
// @license       MIT
// ==/UserScript==

const wT = 10; //in ms
const getSync=true; //true de tai trang lan luot, false de lay tat ca cug luc
const sleep = (ms) => new Promise((rs) => setTimeout(rs, ms));

async function unZip(data) {  //return Blob, lấy text thì them await .text()
  let blob=new Blob([data]);
  const ds = new DecompressionStream("gzip");
  const decompressedStream = blob.stream().pipeThrough(ds);
  return await new Response(decompressedStream).blob();
}

async function zip(data) {  // return Blob
  let blob=new Blob([data]);
  const cs = new CompressionStream("gzip");
  const compressedStream = blob.stream().pipeThrough(cs);
  return await new Response(compressedStream).blob();
}

HTMLElement.prototype.directText=function (){
  let el=this.cloneNode(true);
  while (el.children[0]) el.children[0].remove();
  return el.textContent;
}

let threadId, dump;

class IDB {
  constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
    this.db = null;
    if (args.length>1) this.open(...args).then(this.close());
  }

  typeOf = v => Object.prototype.toString.call(v).slice(8,-1);

  async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.version = version;

    return new Promise((resolve, reject) => {
      const request = indexedDB.open(dbName, version);
      request.onupgradeneeded = (event) => {
        this.db = event.target.result;
        if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
        else this.db.createObjectStore(storeName);
        resolve(true);
      };

      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(true);
      };

      request.onerror = (e)=>{console.log(e); reject(e);}
    });
  }

  async setItem(key, value) { return this._justDoIt('put', value, key);}
  async getItem(key) { return this._justDoIt('get', key);}
  async getAll() { return this._justDoIt('getAll');}
  async listAll() { return this._justDoIt('getAllKeys');}
  async deleteItem(key) { return this._justDoIt('delete', key);}
  async deleteAll() { return this._justDoIt('clear');}

  _justDoIt(operation, key, value) {
    const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
    return new Promise((resolve, reject) => {
      const request = store[operation](key, value);
      request.onsuccess = () => resolve(request.result);
      request.onerror = reject;
    });
  }

  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
      this.dbName = null;
      this.storeName = null;
    }
  }
}

function convertContent(htmlStr) {
  dump=new DOMParser();
  let html=dump.parseFromString(htmlStr,'text/html');

  html.querySelectorAll('[href]').forEach(el=> {
    let href=el.getAttribute('href');
    if (href.startsWith('/')) el.setAttribute('href','https://voz.vn'+ href);
  });

  html.querySelectorAll('[src]').forEach(el=> {
    // if(el.tagName=='SCRIPT') return; //skip script;
    let src=el.getAttribute('src');
    if (src.startsWith('data:image')) el.setAttribute('src',el.getAttribute('data-src'));
    if (src.startsWith('/')) el.setAttribute('src','https://voz.vn'+ src);
  });

  html.querySelectorAll('[srcset]').forEach(el=> {
      src=el.getAttribute('srcset').split(',').map(a=>{ if (a.startsWith('/')) return 'https://voz.vn'+a }).join(',');
      el.setAttribute('srcset',src);
    });

  //Spoiler
  html.querySelectorAll('.bbCodeSpoiler-button,.bbCodeSpoiler-content').forEach(el=>el.classList.add('is-active'))

  // Sửa link trang
  html.querySelectorAll('div.pageNav a').forEach(el=>{
    el.removeAttribute('href');
  });

  htmlStr=new XMLSerializer().serializeToString(html);
  return htmlStr;
}

dump =new IDB('VozSaveThreads',['Threads','Pages']);

async function saveThread() {
  let threadDB = new IDB(); await threadDB.open('VozSaveThreads','Threads');
  let pageDB = new IDB(); await pageDB.open('VozSaveThreads','Pages');

  const maxPage = parseInt(document.querySelector("ul.pageNav-main>li:last-of-type>a").textContent);
  let title = document.querySelector("div.p-body-header > div.p-title > h1").directText();
  let thread = await threadDB.getItem(threadId);
  if (!thread) thread = { threadId, title, addTime: Date.now(), maxPage: 1};

  async function saveContent(pageId) {
    let pageUrl = `https://voz.vn/t/${threadId}/${pageId}`;
    console.log(pageUrl);
    let res= await fetch(pageUrl);
    const data=await res.text();
    await pageDB.setItem(`${threadId}_${pageId.split('-')[1]}`, await zip(convertContent(data)));
  }

  if (maxPage >= (thread.maxPage ?? 1)) {
    if(getSync) {
      for (let i = thread.maxPage??1; i <= maxPage; i++) {
        let pageUrl = `https://voz.vn/t/${threadId}/page-${i}`;
        console.log(pageUrl);
        let res= await fetch(pageUrl);
        if(!res.ok) return false;
        const data=await res.text();
        if (!data) return false;
        await pageDB.setItem(`${threadId}_${i}`, await zip(convertContent(data)));
      //Vi chay o content script nen o day cho dong bo
        thread.maxPage=i;
        await threadDB.setItem(threadId,thread)
        await sleep(wT);
      }
    } else {
      let run=[]
      for (let i = thread.maxPage??1; i <= maxPage; i++) run.push(saveContent('page-'+i));
      await Promise.all(run);
      thread.maxPage=maxPage;
      await threadDB.setItem(threadId,thread);
    }
  }
  threadDB.close();
  pageDB.close();
}

(async function main() {
  //Create Show Saved Threads button
  dump = `<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>`;
  document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
  document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=showSavedThreads;

  //create SaveThread Button
  dump = location.href.match(/https:\/\/(?:.*\.)?voz.vn\/(f|t)\/[a-z\d\-]+.(\d+)\/?(page-(\d+))?/);
  let fOrT;
  if (dump) { fOrT = dump[1]; threadId = dump[2]; }

  if (fOrT == "t") {
    const btnSave = document.createElement("a");
    btnSave.classList.add("pageNav-jump", "pageNav-jump--next");
    btnSave.textContent = "Save Thread";
    btnSave.onclick = saveThread;
    btnSave.style = "cursor:pointer;";
    document.querySelectorAll("ul.pageNav-main")
      .forEach((el, i) =>i == 0 ? el.parentElement.appendChild(btnSave) : ((dump = btnSave.cloneNode(true)), (dump.onclick = saveThread),el.parentElement.appendChild(dump)));
  }
})();

async function showSavedThreads(latestFirst = true) {
  let html = `<html>
  <head>
    <title>Voz Saved Threads</title>
  </head>
  <body>
    <div id="list"></div>
    <div id="screen"></div>
    <style>
      :root { font-size: 1.25rem;}
      ul[page] {
        display: none;
        line-height: 1.7rem;
      }
      ul>input[type="radio"] {display: none;}
      ul:has(input[type="radio"]) {display: none;}
      ul:has(input[type="radio"]:checked) {display: block;}
      #options {border-radius: 2px; border: 1px solid black;margin: 3px;padding: 3px;}
      label>input[type="checkbox"] {display:none;}
      label:has(input[type="checkbox"])+div {display:none;}
      .page_number>span{cursor: pointer;}
      ul>li{cursor: pointer;}
      li>button {float:right;}
      .odd_line {background-color: rgb(237, 237, 235);}
      .even_line {background-color: rgb(251, 252, 245);}
      #screen {display:none;}
      #screen div.pageNav a{cursor: pointer;}
    </style>
  <script>
class IDB {
  constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
    this.db = null;
    if (args.length>1) this.open(...args).then(this.close());
  }
  typeOf = v => Object.prototype.toString.call(v).slice(8,-1);
  async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.version = version;

    return new Promise((resolve, reject) => {
      const request = indexedDB.open(dbName, version);

      request.onupgradeneeded = (event) => {
        this.db = event.target.result;
        if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
        else this.db.createObjectStore(storeName);
      };

      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(true);
      };
      request.onerror = (e)=>{ console.log('Request Error: ',e); reject(e); }
    });
  }

  async setItem(key, value) { return this._justDoIt('put', value, key);}
  async getItem(key) { return this._justDoIt('get', key);}
  async getAll() { return this._justDoIt('getAll');}
  async listAll() { return this._justDoIt('getAllKeys');}
  async deleteItem(key) { return this._justDoIt('delete', key);}
  async deleteAll() { return this._justDoIt('clear');}

  _justDoIt(operation, key, value) {
    const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
    return new Promise((resolve, reject) => {
      const request = store[operation](key, value);
      request.onsuccess = () => resolve(request.result);
      request.onerror = reject;
    });
  }

  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
      this.dbName = null;
      this.storeName = null;
    }
  }
}

async function unZip(data) {  //return Blob, lấy text thì them await .text()
  let blob=new Blob([data]);
  const ds = new DecompressionStream("gzip");
  const decompressedStream = blob.stream().pipeThrough(ds);
  return await new Response(decompressedStream).blob();
}


// let dump =new IDB('VozSaveThreads',['Threads','Pages']);
let list = document.getElementById('list');
let screen = document.getElementById('screen');
const threadDB = new IDB();
let pageDB = new IDB();

  //Create topics list
async function listTopics(tpp=15) {
  document.querySelector('head').insertAdjacentHTML('beforeend','<title>Voz Saved Threads</title>')
  const threads = await threadDB.getAll();
  let pageHeader='<div class="page_number"> Page: ';
  let ul='';
  for (let i=0; i<threads.length/tpp; i++){
    if(i==0) pageHeader+='<span style="font-weight:700"> '+(i+1)+' </span>';
    else pageHeader+='<span> '+(i+1)+' </span>';

    ul+='<ul page=' + (i+1) + '><input type="radio" name="MyNameIsRadioButton" ' + (i==0?"checked":"") + '>'
    for (let j=0; j<tpp && i*tpp+j<threads.length; j++)
      ul+='<li class="'+ (j%2==0?'even_line':'odd_line') +'"><a threadid="'+ threads[i*tpp+j].threadId + '">'+ threads[i*tpp+j].title + '</a><button threadid="'+ threads[i*tpp+j].threadId+ '">Delete</button></li>';
    ul+='</ul>';
  }
  pageHeader+='</div>'
  list.innerHTML=pageHeader+ul+pageHeader;

  /// Click page number
  list.querySelectorAll('div.page_number>span').forEach(span=>span.addEventListener('click',e=>{
    let pageNo=e.target.textContent.trim();
    list.querySelectorAll('div.page_number>span').forEach(el=> (el.textContent.trim()== pageNo) ? el.style.fontWeight='700':el.style.fontWeight='400');
    list.querySelector('ul[page="'+pageNo+'"]>input[type="radio"]').click();
  }))

  //Click topics links
  list.querySelectorAll('li>a').forEach(el=>{el.addEventListener('click',async (e) =>await showPage(e.target.getAttribute('threadid')))});

  //Delete button
  list.querySelectorAll('li>button').forEach(el=>{el.addEventListener('click',async (e) =>{
    let thread= await threadDB.getItem(e.target.getAttribute('threadid'));
    for (let i=1; i<=thread.maxPage; i++) await pageDB.deleteItem(thread.threadId+'_'+i);
    threadDB.deleteItem(thread.threadId);
    listTopics();
  }) });

  //show it;
    list.style.display='block';
    screen.style.display='none';
}

  async function showPage(pageId) {
    pageId.split('_').length==1 ? pageId+='_1':pageId;

    let pageContent= await (await unZip(await pageDB.getItem(pageId))).text();

    screen.innerHTML=pageContent;

    //Add "Saved Threads" on Menu
    dump = '<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>';
    screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
    screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=()=>{
      screen.innerHTML='';
      list.style.display='block';
      screen.style.display='none';
      listTopics();}

    //Page number click
    screen.querySelectorAll('ul.pageNav-main a:not([id])').forEach(el=> el.addEventListener('click',e=> {
      e.preventDefault();
      !isNaN(e.target.innerHTML) ? showPage(pageId.split('_')[0]+'_'+e.target.textContent.trim()):''

    }));

    //Goto page Click
    screen.querySelectorAll('ul.pageNav-main a[title="Go to page"]')?.forEach(el => el.addEventListener('click',e=>{
        let pageNo = prompt('Enter page number','1');
        if (isNaN(pageNo)) return;
        showPage(pageId.split('_')[0]+'_'+pageNo);
        })
      )

    //Next Click
    screen.querySelectorAll('.pageNav-jump.pageNav-jump--next')?.forEach(el=>el.addEventListener('click',(e)=> {
      const [a,b]=pageId.split('_');
      showPage(a+'_'+ (parseInt(b)+1));
    }   ));

    //Next Click
    screen.querySelectorAll('.pageNav-jump.pageNav-jump--prev')?.forEach(el=>el.addEventListener('click',(e)=> {
      const [a,b]=pageId.split('_');
      showPage(a+'_'+ (parseInt(b)-1));
    }   ));

    //Show it
    document.querySelector('title')?.remove();
    list.style.display='none';
    screen.style.display='block';
  }

  (async function main()  {
    await pageDB.open('VozSaveThreads','Pages');
    await threadDB.open('VozSaveThreads','Threads');
    await listTopics();
  })();
  </script>
</body>
</html>`

  window.open(URL.createObjectURL(new Blob([html],{type:'text/html'}),'_blank'));
} //End ShowSavedThreads