diff --git a/package.json b/package.json index 45687f7..e25ae79 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,12 @@ "maxParallelRequests": 20 }, "dependencies": { + "cash-dom": "^8.1.4", "date-fns": "^2.29.3", "graphql": "^16.6.0", "graphql-request": "^5.2.0", "jsx-dom": "^8.0.5", - "level": "^8.0.0" + "level": "^8.0.0", + "nanoevents": "^7.0.1" } } diff --git a/src/background/message-handler.ts b/src/background/message-handler.ts index 940edb4..84a489f 100644 --- a/src/background/message-handler.ts +++ b/src/background/message-handler.ts @@ -1,54 +1,67 @@ -import { PartialStateUpdate } from "../state"; +import { isStatePopulated, isStateClear, PartialStateUpdate, ExporterState, isStateAbleToRequest } from "../state"; +import { Status } from '../state'; import { State } from '../state/level'; import * as landsbankinn from '../landsbankinn'; import { fetchAccounts } from "../landsbankinn"; +import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsReply } from './messages'; -export enum Actions { - EnsureState = 'ENSURE_STATE', - GetAccounts = 'GET_ACCOUNTS', - DownloadTransactions = 'DOWNLOAD_TRANSACTIONS' -} - -type MessageSender = browser.runtime.MessageSender; - -async function getAccounts(sender: MessageSender): Promise { - //TODO somehow ensure state exists. - const message = { - accounts: (await State.current).accounts - }; - - browser.tabs.sendMessage(sender.tab?.id!, message); -} - -async function ensureState(message: any, sender: MessageSender) { - const clientState: PartialStateUpdate = message.clientState; - const state = await State.current; - - if (!state.ready) { - await State.update({ ready: true }); - const session = await landsbankinn.authSession(); - const newState = await State.update({ - authToken: session.accessToken, ...clientState - }) - - if (!newState.accounts) { - const accounts = await fetchAccounts(newState); - await State.update({ accounts }); - console.info('Acquired account data'); - } - - console.info('Updated state from client and auth session'); - console.log(await State.current); +async function ensureDataPopulated(state: ExporterState) { + if (isStateAbleToRequest(state) && !isStatePopulated(state)) { + const accounts = await fetchAccounts(state); + await State.update({ status: Status.Populated, accounts }); + console.info('Populated account data'); } } -browser.runtime.onMessage.addListener((message, sender) => { - console.log('received message', message); +async function ensureState(message: EnsureStateMessage): Promise { + console.info('Ensuring state'); + const clientState: PartialStateUpdate = message.clientState;; + let state = await State.current; + + if (isStateClear(state)) { + await State.update({ status: Status.Updating }); + const session = await landsbankinn.authSession(); + await State.update({ + status: Status.AbleToRequest, authToken: session.accessToken, ...clientState + }); + + console.info('Updated auth state from client and auth session'); + } + + state = await State.current; + await ensureDataPopulated(state); + return { state: 'Populated' }; +} + +async function getAccounts(): Promise { + const state = await State.current; + let message: any; + + await ensureDataPopulated(state); + + // Should always be populated, but we can be careful anyway. + if (isStatePopulated(state)) { + message = { + accounts: (await State.current).accounts, + error: null + }; + } + else { + message = { + accounts: undefined, + error: new Error('state not yet populated') + }; + } + + return message; +} + +browser.runtime.onMessage.addListener(async (message) => { switch (message.action) { case Actions.EnsureState: - ensureState(message, sender); + return await ensureState(message); case Actions.GetAccounts: - getAccounts(sender); + return await getAccounts(); } }); diff --git a/src/background/messages.ts b/src/background/messages.ts new file mode 100644 index 0000000..67e377c --- /dev/null +++ b/src/background/messages.ts @@ -0,0 +1,29 @@ +// Request and replies for messages passed between the background page +// and the content script. + +import { PartialStateUpdate } from "~state"; +import { Account } from "../landsbankinn/models"; + +export enum Actions { + EnsureState = 'ENSURE_STATE', + GetAccounts = 'GET_ACCOUNTS', + DownloadTransactions = 'DOWNLOAD_TRANSACTIONS' +} + +export interface GetAccountsMessage { + action: Actions.GetAccounts; +} + +export interface GetAccountsReply { + accounts: Array, + error: Error | null +} + +export interface EnsureStateMessage { + action: Actions.EnsureState; + clientState: PartialStateUpdate; +} + +export interface EnsureStateReply { + state: 'Populated' | 'Error'; +} diff --git a/src/content-scripts/background.ts b/src/content-scripts/background.ts new file mode 100644 index 0000000..0491a8c --- /dev/null +++ b/src/content-scripts/background.ts @@ -0,0 +1,14 @@ +// Functions for communicating with the background page. + +import { PartialStateUpdate } from '../state'; +import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsMessage, GetAccountsReply } from '../background/messages'; + +export async function getAccounts(): Promise { + const message: GetAccountsMessage = { action: Actions.GetAccounts }; + return await browser.runtime.sendMessage(message); +} + +export async function ensureState(clientState: PartialStateUpdate): Promise { + const message: EnsureStateMessage = { action: Actions.EnsureState, clientState }; + return await browser.runtime.sendMessage(message); +} diff --git a/src/content-scripts/ensure-state.ts b/src/content-scripts/ensure-state.ts index f447bda..0c36dc0 100644 --- a/src/content-scripts/ensure-state.ts +++ b/src/content-scripts/ensure-state.ts @@ -1,5 +1,9 @@ +// Communicates data about the GraphQL URL and API key to the +// extension. + import { PartialStateUpdate } from "../state"; // TODO do not run state on content scripts -import { Actions } from '../background/message-handler'; +import { Event, ClientEvents } from './events'; +import * as background from './background'; // The actual information we care about interface ApiInformation { @@ -38,6 +42,7 @@ function isApiInformation(liData: ApiInformation) { liData.apikey !== undefined && liData.locale !== undefined; } + function isRegularWindow(win: AnyWindow): win is LIWindowRegular { const liWindow = win as LIWindowRegular; return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && @@ -59,6 +64,10 @@ function isLandsbankinnWindow(win: AnyWindow): win is LIWindow { return isRegularWindow(win) || isMegaHeaderWindow(win); } +// Extract data from a page where the LIPageData field is prseent. +// Supports extraction from both the normal data structure found on +// most pages, and the "megaHeader" structure foud on the statements +// page. function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined { if (isRegularWindow(win)) { return { @@ -75,17 +84,16 @@ function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined { } } -// Communicates data about the GraphQL URL and API key to the -// extension. -document.addEventListener('DOMContentLoaded', async () => { - console.log(window); +window.addEventListener('DOMContentLoaded', async () => { if (isLandsbankinnWindow(window)) { console.info('Ensuring extension state'); const clientState = extractStateUpdate(window); - await browser.runtime.sendMessage({ - action: Actions.EnsureState, clientState - }); + + if (clientState) { + await background.ensureState(clientState); + ClientEvents.emit(Event.StatePopulated); + } else { + console.error('Could not find auth info on page!'); + } } }); - -export { } diff --git a/src/content-scripts/events.ts b/src/content-scripts/events.ts new file mode 100644 index 0000000..0cf5a0a --- /dev/null +++ b/src/content-scripts/events.ts @@ -0,0 +1,7 @@ +import { createNanoEvents } from 'nanoevents'; + +export enum Event { + StatePopulated = 'StatePopulated' +} + +export const ClientEvents = createNanoEvents(); diff --git a/src/content-scripts/statement-page.tsx b/src/content-scripts/statement-page.tsx index 72190af..198f782 100644 --- a/src/content-scripts/statement-page.tsx +++ b/src/content-scripts/statement-page.tsx @@ -1,27 +1,65 @@ -import React from 'jsx-dom'; +import React from 'jsx-dom'; // necessary for react to work +import { Actions, GetAccountsReply } from '../background/messages'; +import { Event, ClientEvents } from './events'; +import * as background from './background'; +import { Account } from '../landsbankinn/models'; +import $ from 'cash-dom'; -async function populateAccounts() { - const resp = await browser.runtime.sendMessage({ - action: 'GET_ACCOUNTS', - }); +interface AccountListProps { + accounts: Array; } -//Add an element to the statement page -document.addEventListener('DOMContentLoaded', async () => { - const contentDiv = document.querySelector('div[class="table-data content-box fill shadow"]'); - contentDiv?.prepend( -
-

Download Transaction Statements

-

- Here, you can export statements from one or more of your accounts - over a specified date range. -

-
+function AccountList(props: AccountListProps) { + const selectAllHandler = () => { + const checked = $('#check-all').prop('checked'); + $('#account-export-list input[type="checkbox"]').prop('checked', checked); + }; + + const accountList = props.accounts.map(acct => +
  • + +   + +
  • ); - await populateAccounts(); -}); + return ( +
    +
      +
    • + +   + +
    • + {accountList} +
    +
    + ); +} -browser.runtime.onMessage.addListener((message, sender) => { - console.log('message received!', message); +window.addEventListener('DOMContentLoaded', async () => { + ClientEvents.on(Event.StatePopulated, async () => { + const response = await background.getAccounts(); + + if (response.error == null) { + const exportDiv = ( +
    +

    Download Transaction Statements

    +

    + Here, you can export statements from one or more of your accounts + over a specified date range. +

    + + +
    + ); + + const contentDiv = document.querySelector( + 'div[class="table-data content-box fill shadow"]' + ); + contentDiv?.prepend(exportDiv); + } + }); }); diff --git a/src/landsbankinn/models.ts b/src/landsbankinn/models.ts index db9c100..1ae8bbd 100644 --- a/src/landsbankinn/models.ts +++ b/src/landsbankinn/models.ts @@ -4,6 +4,6 @@ export interface Account { accountNumber: string; - name: String; + name: string; currency: string; } diff --git a/src/state/index.ts b/src/state/index.ts index c78c891..80857fa 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,11 +1,18 @@ import { Account } from '../landsbankinn/models'; +export enum Status { + NotReady, // completely clear + Updating, // in process of gathering auth data + AbleToRequest, // able to make requests + Populated // has all necessary information +} + export interface ExporterState { apiKey: string; authToken: string; fingerprintValue: string; accounts: Array; - ready: boolean; + status: Status } export interface PartialStateUpdate { @@ -13,7 +20,7 @@ export interface PartialStateUpdate { authToken?: string; fingerprintValue?: string; accounts?: Array; - ready?: boolean; + status?: Status } // TODO export it and replace direct references to level with it @@ -23,3 +30,12 @@ interface StateStore { clear(): Promise; } + +export const isStateClear = (state: ExporterState) => + state.status === undefined || state.status == Status.NotReady; + +export const isStatePopulated = (state: ExporterState) => + state.status == Status.Populated; + +export const isStateAbleToRequest = (state: ExporterState) => + state.status == Status.AbleToRequest || state.status == Status.Populated; diff --git a/src/state/level.ts b/src/state/level.ts index 6ac9d1d..7033d03 100644 --- a/src/state/level.ts +++ b/src/state/level.ts @@ -1,5 +1,5 @@ import { Level } from 'level'; -import { ExporterState, PartialStateUpdate } from './index'; +import { ExporterState, PartialStateUpdate, Status } from './index'; const FIVE_MINUTES = 0.25 * 60 * 1000; @@ -33,7 +33,7 @@ class LevelState { async clear() { console.info('Cleared state'); - return await this._db.put('state', {} as any); + return await this._db.put('state', { status: Status.NotReady } as any); } private async clearTTL() { diff --git a/todo.org b/todo.org index f98a4ce..8d56ee8 100644 --- a/todo.org +++ b/todo.org @@ -1,11 +1,14 @@ How to get it working: - [X] Landsbankinn API client for arbitrary transaction lists - [ ] Transform raw transactions into friendlier ones (i.e. date objects) - - [ ] Content script that can run all the time and puts a button somewhere (statements tab, and extension icon) + - [X] Content script that can run all the time and puts a button somewhere (statements tab, and extension icon) + - [ ] Use config.ts to set TTL for state - [ ] Background page that downloads all the shit and combines into CSV, then 'downloads' file. - - [ ] Build up communication message passing to background page for downloads - - [ ] Investigate persistent = false + setTimeout + - [X] Build up communication message passing to background page for downloads + - [ ] Don't run level on client. + - [-] Investigate persistent = false + setTimeout - [ ] Maybe can force clear state when page reloads itself? + - [X] Change ready true/false to lock state, of not updated, updating, ready - [X] Graphql API client to get information about user, like listing accounts. - [X] Get working client - [X] Set API key diff --git a/yarn.lock b/yarn.lock index 52a47e3..46bb6aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -938,6 +938,11 @@ caniuse-lite@^1.0.30001449: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001468.tgz#0101837c6a4e38e6331104c33dcfb3bdf367a4b7" integrity sha512-zgAo8D5kbOyUcRAgSmgyuvBkjrGk5CGYG5TYgFdpQv+ywcyEpo1LOWoG8YmoflGnh+V+UsNuKYedsoYs0hzV5A== +cash-dom@^8.1.4: + version "8.1.4" + resolved "https://registry.yarnpkg.com/cash-dom/-/cash-dom-8.1.4.tgz#445c2a509cffa8b1c99094634418b4b439d57718" + integrity sha512-bFLMk+r3lv+sDwxlAFfRlMxpRls7zMnSQePVpNouwnpm9G4MbLYZZtIUG2urUgfmIaKlc/hqG8o7yZg3+nFKRA== + catering@^2.1.0, catering@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/catering/-/catering-2.1.1.tgz#66acba06ed5ee28d5286133982a927de9a04b510" @@ -1480,6 +1485,11 @@ msgpackr@^1.5.4: optionalDependencies: msgpackr-extract "^3.0.1" +nanoevents@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/nanoevents/-/nanoevents-7.0.1.tgz#181580b47787688d8cac775b977b1cf24e26e570" + integrity sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q== + napi-macros@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b"