diff --git a/src/app.ts b/src/app.ts index 5e54602..ea8ba21 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,7 @@ // Webpack uses MiniCssExtractPlugin when it sees this. import './scss/app.scss'; -import { graytabby } from './app/ui'; +import { graytabby } from './app/graytabby'; import { PAGE_LOAD } from './lib/globals'; graytabby().then( diff --git a/src/app/debugger.ts b/src/app/debugger.ts new file mode 100644 index 0000000..349fa8e --- /dev/null +++ b/src/app/debugger.ts @@ -0,0 +1,23 @@ +import { DOCUMENT } from '../lib/globals'; +import { loadAllTabGroups } from './tabs_store'; +import { ingestTabs } from './tabs_ui'; + +export class Debugger { + async double(): Promise { + const groups = await loadAllTabGroups(); + let earliest = Math.min(...groups.map(g => g.date)); + const groupDiv: HTMLDivElement = DOCUMENT.get().querySelector('#groups'); + const promises: Promise[] = []; + for (const group of groups) { + earliest -= 1000; + await ingestTabs(group.tabs, groupDiv, () => earliest); + } + await Promise.all(promises); + } +} + +declare global { + interface Window { + gt: Debugger; + } +} diff --git a/src/app/dom.ts b/src/app/dom.ts new file mode 100644 index 0000000..a4df954 --- /dev/null +++ b/src/app/dom.ts @@ -0,0 +1,21 @@ +import { DOCUMENT } from '../lib/globals'; + +export function makeElement( + type: string, + attrs: { [key: string]: string } = {}, + children?: string | Element[], +): Element { + const elem = DOCUMENT.get().createElement(type); + for (const key in attrs) { + elem.setAttribute(key, attrs[key]); + } + + if (children === undefined) return elem; + + if (typeof children === 'string') { + elem.innerText = children; + } else { + children.map(c => elem.appendChild(c)); + } + return elem; +} diff --git a/src/app/graytabby.ts b/src/app/graytabby.ts new file mode 100644 index 0000000..0ca9d3e --- /dev/null +++ b/src/app/graytabby.ts @@ -0,0 +1,20 @@ +/** + * User interface code for graytabby. + * + * Anything responsible for responding to user input or for displaying data to the user. + */ + +import { DOCUMENT } from '../lib/globals'; +import { Debugger } from './debugger'; +import { bindOptions } from './options_ui'; +import { bindTabs } from './tabs_ui'; + +/** + * The main entry point for GrayTabby. + */ +export async function graytabby(): Promise { + DOCUMENT.get().title = 'GrayTabby'; + await bindOptions(); + await bindTabs(); + DOCUMENT.get().defaultView.gt = new Debugger(); +} diff --git a/src/app/info_ui.ts b/src/app/info_ui.ts new file mode 100644 index 0000000..13cb7b9 --- /dev/null +++ b/src/app/info_ui.ts @@ -0,0 +1,13 @@ +import { DOCUMENT } from '../lib/globals'; + +let totalTabs = 0; + +export function updateInfo(delta: number): void { + const infoNode = DOCUMENT.get().querySelector('#info'); + totalTabs += delta; + infoNode.innerText = 'Total tabs: ' + totalTabs.toString(); +} + +export function getTotalTabs(): number { + return totalTabs; +} diff --git a/src/app/options_ui.ts b/src/app/options_ui.ts new file mode 100644 index 0000000..b28193e --- /dev/null +++ b/src/app/options_ui.ts @@ -0,0 +1,59 @@ +import { getOptions, setOptions } from '../lib/options'; +import { DOCUMENT } from '../lib/globals'; + +export async function bindOptions(): Promise { + const document = DOCUMENT.get(); + const modal = document.querySelector('#optionsModal'); + const logo = document.querySelector('#logo'); + const content = document.querySelector('#optionsModal .content'); + logo.onclick = () => (modal.style.display = 'block'); + modal.onclick = event => { + if (!content.contains(event.target)) modal.style.display = 'none'; + }; + + const checkboxes: HTMLInputElement[] = Array.from( + content.querySelectorAll('label input[type="checkbox"]'), + ); + for (const checkbox of checkboxes) { + const label = checkbox.parentElement; + const span = label.querySelector('span'); + span.onclick = () => (checkbox.checked = !checkbox.checked); + } + + const optionsLimitNode = document.querySelector('#optionsLimit'); + const optionsDupesNode = document.querySelector('#optionsDupes'); + const optionsAtLoad = await getOptions(); + + optionsLimitNode.value = optionsAtLoad.tabLimit.toString(); + optionsDupesNode.checked = optionsAtLoad.archiveDupes; + + optionsDupesNode.onchange = async () => { + await setOptions({ + archiveDupes: optionsDupesNode.checked, + }); + }; + + // From https://stackoverflow.com/questions/469357/html-text-input-allow-only-numeric-input + // HTML5 validators have poor support at the moment. + optionsLimitNode.onkeydown = (e): boolean => { + return ( + e.ctrlKey || + e.altKey || + (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || + (95 < e.keyCode && e.keyCode < 106) || + e.keyCode == 8 || + e.keyCode == 9 || + (e.keyCode > 34 && e.keyCode < 40) || + e.keyCode == 46 + ); + }; + + optionsLimitNode.onkeyup = async () => { + const newLimit = Number(optionsLimitNode.value); + if (newLimit != NaN) { + await setOptions({ + tabLimit: newLimit, + }); + } + }; +} diff --git a/src/app/tabs.ts b/src/app/tabs_store.ts similarity index 100% rename from src/app/tabs.ts rename to src/app/tabs_store.ts diff --git a/src/app/tabs_ui.ts b/src/app/tabs_ui.ts new file mode 100644 index 0000000..e0b6a13 --- /dev/null +++ b/src/app/tabs_ui.ts @@ -0,0 +1,153 @@ +import { BROWSER, DOCUMENT, ARCHIVAL } from '../lib/globals'; +import { updateInfo, getTotalTabs } from './info_ui'; +import { + keyFromDate, + dateFromKey, + GrayTabGroup, + GrayTab, + eraseTabGroup, + saveTabGroup, + keyFromGroup, + loadAllTabGroups, +} from './tabs_store'; +import { makeElement } from './dom'; +import { getOptions } from '../lib/options'; +import { BrowserTab } from '../lib/types'; + +function prependInsideContainer(parent: Element, child: Element): Element { + if (parent.firstChild == null) parent.appendChild(child); + else parent.insertBefore(child, parent.firstChild); + return child; +} + +function countGroups(): number { + return DOCUMENT.get().querySelectorAll('#groups > div').length; +} + +function faviconLocation(url: string): string { + const domain = new URL(url).hostname; + if (domain) return `https://www.google.com/s2/favicons?domain=${domain}`; + return ''; +} + +function renderFavicon(url: string): HTMLImageElement { + const loc = faviconLocation(url); + return makeElement('img', { + src: loc, + width: '16', + height: '16', + }); +} + +function groupFromDiv(target: number | HTMLDivElement): GrayTabGroup { + let div: HTMLDivElement; + let date: number; + if (typeof target === 'number') { + date = target; + div = DOCUMENT.get().querySelector(`#${keyFromDate(date)}`); + } else { + div = target; + date = dateFromKey(div.id); + } + const group: GrayTabGroup = { + date: date, + tabs: [], + }; + if (div == null) return group; + const lis = div.querySelectorAll('li'); + lis.forEach(li => { + const a: HTMLAnchorElement = li.querySelector('a'); + const tab: GrayTab = { + key: Number(a.attributes.getNamedItem('key').value), + url: a.href, + title: a.innerText, + }; + group.tabs.push(tab); + }); + return group; +} + +/** + * Updates the backend to match the DOM for the tab group with the given date. + */ +async function syncGroupFromDOM(target: number | HTMLDivElement): Promise { + const group = groupFromDiv(target); + if (group.tabs.length == 0) { + return eraseTabGroup(group.date); + } + return saveTabGroup(group); +} + +async function linkRowClickhandler(event: MouseEvent): Promise { + const a = event.target; + const row = a.parentElement; + const li = row.parentElement; + const ul = li.parentElement; + const groupDiv = ul.parentElement; + + event.preventDefault(); + await BROWSER.get().tabs.create({ url: a.href, active: false }); + li.remove(); + updateInfo(-1); + if (groupDiv.querySelector('li') == null) groupDiv.remove(); + await syncGroupFromDOM(dateFromKey(groupDiv.id)); +} + +function renderLinkRow(group: GrayTabGroup, tab: GrayTab): HTMLLIElement { + const li = makeElement('li'); + const row = li.appendChild(makeElement('div')); + row.appendChild(renderFavicon(tab.url)); + const a = ( + row.appendChild(makeElement('a', { href: tab.url, key: tab.key.toString() }, tab.title)) + ); + a.onclick = linkRowClickhandler; + return li; +} + +function renderGroup(group: GrayTabGroup): HTMLDivElement { + const div = makeElement('div', { id: keyFromGroup(group) }); + div.appendChild(makeElement('span', {}, new Date(group.date).toLocaleString())); + const ul = div.appendChild(makeElement('ul')); + for (const tab of group.tabs) { + ul.appendChild(renderLinkRow(group, tab)); + } + return div; +} + +export async function ingestTabs( + tabSummaries: BrowserTab[], + groupsNode: HTMLDivElement, + now = () => new Date().getTime(), +): Promise { + if (tabSummaries.length == 0) return; + let counter = 0; + const group: GrayTabGroup = { + tabs: tabSummaries.map(ts => { + return { ...ts, key: counter++ }; + }), + date: now(), + }; + prependInsideContainer(groupsNode, renderGroup(group)); + await syncGroupFromDOM(group.date); + updateInfo(group.tabs.length); + + const tabLimit = (await getOptions()).tabLimit; + while (getTotalTabs() > tabLimit && countGroups() > 1) { + const victim = DOCUMENT.get().querySelector('#groups>div:last-child'); + const removed = victim.querySelectorAll('li').length; + updateInfo(-removed); + await eraseTabGroup(dateFromKey(victim.id)); + victim.remove(); + } +} + +export async function bindTabs(): Promise { + const groupsNode = DOCUMENT.get().querySelector('#groups'); + let counter = 0; + for (const group of await loadAllTabGroups()) { + counter += group.tabs.length; + groupsNode.appendChild(renderGroup(group)); + } + ARCHIVAL.get().sub(summaries => ingestTabs(summaries, groupsNode)); + updateInfo(counter); +} diff --git a/src/app/ui.ts b/src/app/ui.ts deleted file mode 100644 index d28cac7..0000000 --- a/src/app/ui.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * User interface code for graytabby. - * - * Anything responsible for responding to user input or for displaying data to the user. - */ - -import * as sizeof from 'object-sizeof'; -import * as vlq from 'vlq'; -import { ARCHIVAL, BROWSER, DOCUMENT } from '../lib/globals'; -import { getOptions, setOptions } from '../lib/options'; -import { - dateFromKey, - eraseTabGroup, - keyFromDate, - keyFromGroup, - loadAllTabGroups, - saveTabGroup, - GrayTabGroup, - GrayTab, -} from './tabs'; -import { BrowserTab } from '../lib/types'; - -function getDomain(url: string): string { - return new URL(url).hostname; -} - -function faviconLocation(url: string): string { - const domain = getDomain(url); - if (domain) return `https://www.google.com/s2/favicons?domain=${domain}`; - return ''; -} - -function makeElement( - type: string, - attrs: { [key: string]: string } = {}, - children?: string | Element[], -): Element { - const elem = DOCUMENT.get().createElement(type); - for (const key in attrs) { - elem.setAttribute(key, attrs[key]); - } - - if (children === undefined) return elem; - - if (typeof children === 'string') { - elem.innerText = children; - } else { - children.map(c => elem.appendChild(c)); - } - return elem; -} - -async function bindOptions(): Promise { - const document = DOCUMENT.get(); - const modal = document.querySelector('#optionsModal'); - const logo = document.querySelector('#logo'); - const content = document.querySelector('#optionsModal .content'); - logo.onclick = () => (modal.style.display = 'block'); - modal.onclick = event => { - if (!content.contains(event.target)) modal.style.display = 'none'; - }; - - const checkboxes: HTMLInputElement[] = Array.from( - content.querySelectorAll('label input[type="checkbox"]'), - ); - for (const checkbox of checkboxes) { - const label = checkbox.parentElement; - const span = label.querySelector('span'); - span.onclick = () => (checkbox.checked = !checkbox.checked); - } - - const optionsLimitNode = document.querySelector('#optionsLimit'); - const optionsDupesNode = document.querySelector('#optionsDupes'); - const optionsAtLoad = await getOptions(); - - optionsLimitNode.value = optionsAtLoad.tabLimit.toString(); - optionsDupesNode.checked = optionsAtLoad.archiveDupes; - - optionsDupesNode.onchange = async () => { - await setOptions({ - archiveDupes: optionsDupesNode.checked, - }); - }; - - // From https://stackoverflow.com/questions/469357/html-text-input-allow-only-numeric-input - // HTML5 validators have poor support at the moment. - optionsLimitNode.onkeydown = (e): boolean => { - return ( - e.ctrlKey || - e.altKey || - (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || - (95 < e.keyCode && e.keyCode < 106) || - e.keyCode == 8 || - e.keyCode == 9 || - (e.keyCode > 34 && e.keyCode < 40) || - e.keyCode == 46 - ); - }; - - optionsLimitNode.onkeyup = async () => { - const newLimit = Number(optionsLimitNode.value); - if (newLimit != NaN) { - await setOptions({ - tabLimit: newLimit, - }); - } - }; -} - -function renderFavicon(url: string): HTMLImageElement { - const loc = faviconLocation(url); - return makeElement('img', { - src: loc, - width: '16', - height: '16', - }); -} - -export function groupFromDiv(target: number | HTMLDivElement): GrayTabGroup { - let div: HTMLDivElement; - let date: number; - if (typeof target === 'number') { - date = target; - div = DOCUMENT.get().querySelector(`#${keyFromDate(date)}`); - } else { - div = target; - date = dateFromKey(div.id); - } - const group: GrayTabGroup = { - date: date, - tabs: [], - }; - if (div == null) return group; - const lis = div.querySelectorAll('li'); - lis.forEach(li => { - const a: HTMLAnchorElement = li.querySelector('a'); - const tab: GrayTab = { - key: Number(a.attributes.getNamedItem('key').value), - url: a.href, - title: a.innerText, - }; - group.tabs.push(tab); - }); - return group; -} - -/** - * Updates the backend to match the DOM for the tab group with the given date. - */ -export async function syncGroupFromDOM(target: number | HTMLDivElement): Promise { - const group = groupFromDiv(target); - if (group.tabs.length == 0) { - return eraseTabGroup(group.date); - } - return saveTabGroup(group); -} - -let totalTabs = 0; -function updateInfo(delta: number): void { - const infoNode = DOCUMENT.get().querySelector('#info'); - totalTabs += delta; - infoNode.innerText = 'Total tabs: ' + totalTabs.toString(); -} - -async function linkRowClickhandler(event: MouseEvent): Promise { - const a = event.target; - const row = a.parentElement; - const li = row.parentElement; - const ul = li.parentElement; - const groupDiv = ul.parentElement; - - event.preventDefault(); - await BROWSER.get().tabs.create({ url: a.href, active: false }); - li.remove(); - updateInfo(-1); - if (groupDiv.querySelector('li') == null) groupDiv.remove(); - await syncGroupFromDOM(dateFromKey(groupDiv.id)); -} - -function renderLinkRow(group: GrayTabGroup, tab: GrayTab): HTMLLIElement { - const li = makeElement('li'); - const row = li.appendChild(makeElement('div')); - row.appendChild(renderFavicon(tab.url)); - const a = ( - row.appendChild(makeElement('a', { href: tab.url, key: tab.key.toString() }, tab.title)) - ); - a.onclick = linkRowClickhandler; - return li; -} - -function renderGroup(group: GrayTabGroup): HTMLDivElement { - const div = makeElement('div', { id: keyFromGroup(group) }); - div.appendChild(makeElement('span', {}, new Date(group.date).toLocaleString())); - const ul = div.appendChild(makeElement('ul')); - for (const tab of group.tabs) { - ul.appendChild(renderLinkRow(group, tab)); - } - return div; -} - -function prependInsideContainer(parent: Element, child: Element): Element { - if (parent.firstChild == null) parent.appendChild(child); - else parent.insertBefore(child, parent.firstChild); - return child; -} - -function countGroups(): number { - return DOCUMENT.get().querySelectorAll('#groups > div').length; -} - -async function ingestTabs( - tabSummaries: BrowserTab[], - groupsNode: HTMLDivElement, - now = () => new Date().getTime(), -): Promise { - if (tabSummaries.length == 0) return; - let counter = 0; - const group: GrayTabGroup = { - tabs: tabSummaries.map(ts => { - return { ...ts, key: counter++ }; - }), - date: now(), - }; - prependInsideContainer(groupsNode, renderGroup(group)); - await syncGroupFromDOM(group.date); - updateInfo(group.tabs.length); - - const tabLimit = (await getOptions()).tabLimit; - while (totalTabs > tabLimit && countGroups() > 1) { - const victim = DOCUMENT.get().querySelector('#groups>div:last-child'); - const removed = victim.querySelectorAll('li').length; - updateInfo(-removed); - await eraseTabGroup(dateFromKey(victim.id)); - victim.remove(); - } -} - -class Debugger { - async double(): Promise { - const groups = await loadAllTabGroups(); - let earliest = Math.min(...groups.map(g => g.date)); - const groupDiv = DOCUMENT.get().querySelector('#groups'); - const promises: Promise[] = []; - for (const group of groups) { - earliest -= 1000; - group.date = earliest; - groupDiv.appendChild(renderGroup(group)); - await syncGroupFromDOM(group.date); - updateInfo(group.tabs.length); - } - await Promise.all(promises); - console.log(sizeof.default(await loadAllTabGroups())); - } - - async groupMeta(): Promise { - const retval: number[] = []; - let groups = await loadAllTabGroups(); - const first = groups[0]; - retval.push(first.date); - retval.push(first.tabs.length); - groups = groups.slice(1); - for (const group of groups) { - retval.push(group.date - first.date); - retval.push(group.tabs.length); - } - const encoding = vlq.encode(retval); - console.log(encoding); - console.log(sizeof.default(encoding)); - console.log(JSON.stringify(retval)); - } -} - -declare global { - interface Window { - gt: Debugger; - } -} - -/** - * The main entry point for GrayTabby. - */ -export async function graytabby(): Promise { - DOCUMENT.get().title = 'GrayTabby'; - await bindOptions(); - const groupsNode = DOCUMENT.get().querySelector('#groups'); - - // Begin binding. - let counter = 0; - for (const group of await loadAllTabGroups()) { - counter += group.tabs.length; - groupsNode.appendChild(renderGroup(group)); - } - ARCHIVAL.get().sub(summaries => ingestTabs(summaries, groupsNode)); - updateInfo(counter); - - const window = DOCUMENT.get().defaultView; - window.gt = new Debugger(); -} diff --git a/tests/archive.test.ts b/tests/archive.test.ts index 196a1fe..b7f1202 100644 --- a/tests/archive.test.ts +++ b/tests/archive.test.ts @@ -2,10 +2,10 @@ import { expect } from 'chai'; import { archivePlan } from '../src/bg/archive'; import { Broker, BrokerConsumer } from '../src/lib/brokers'; import { ARCHIVAL, DOCUMENT } from '../src/lib/globals'; -import { dateFromKey, INDEX_V2_KEY } from '../src/app/tabs'; +import { dateFromKey, INDEX_V2_KEY } from '../src/app/tabs_store'; import { assertElement, testTab, stubGlobals, unstubGlobals, mockedBrowser } from './utils'; import { BrowserTab } from '../src/lib/types'; -import { graytabby } from '../src/app/ui'; +import { graytabby } from '../src/app/graytabby'; import { dictOf } from '../src/lib/utils'; describe('archive operation', function() { diff --git a/tests/grayTabby.test.ts b/tests/grayTabby.test.ts index eb569a2..10ff939 100644 --- a/tests/grayTabby.test.ts +++ b/tests/grayTabby.test.ts @@ -1,6 +1,6 @@ import { stubGlobals, unstubGlobals } from './utils'; import { bindArchivalHandlers } from '../src/bg/archive'; -import { graytabby } from '../src/app/ui'; +import { graytabby } from '../src/app/graytabby'; describe('graytabby', function() { beforeEach(async function() { diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts index a279a1c..369a3e3 100644 --- a/tests/tabs.test.ts +++ b/tests/tabs.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { GrayTabGroup, INDEX_V1_KEY, loadAllTabGroups } from '../src/app/tabs'; +import { GrayTabGroup, INDEX_V1_KEY, loadAllTabGroups } from '../src/app/tabs_store'; import { dictOf } from '../src/lib/utils'; import { mockedBrowser, stubGlobals, unstubGlobals } from './utils'; diff --git a/tests/utils.ts b/tests/utils.ts index 3862498..d8577bd 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { JSDOM } from 'jsdom'; import * as mockBrowser from 'sinon-chrome'; -import { INDEX_V1_KEY, INDEX_V2_KEY } from '../src/app/tabs'; +import { INDEX_V1_KEY, INDEX_V2_KEY } from '../src/app/tabs_store'; import { BROWSER, DOCUMENT } from '../src/lib/globals'; import { OPTIONS_KEY } from '../src/lib/options'; import { BrowserTab } from '../src/lib/types';