Lots of refactoring to get a working list of checkboxes

This commit is contained in:
projectmoon 2023-03-21 22:12:09 +01:00
parent 32dfd7d7b1
commit 52583c4e32
12 changed files with 220 additions and 80 deletions

View File

@ -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"
}
}

View File

@ -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'
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');
}
}
type MessageSender = browser.runtime.MessageSender;
async function ensureState(message: EnsureStateMessage): Promise<EnsureStateReply> {
console.info('Ensuring state');
const clientState: PartialStateUpdate = message.clientState;;
let state = await State.current;
async function getAccounts(sender: MessageSender): Promise<any> {
//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 });
if (isStateClear(state)) {
await State.update({ status: Status.Updating });
const session = await landsbankinn.authSession();
const newState = await State.update({
authToken: session.accessToken, ...clientState
})
await State.update({
status: Status.AbleToRequest, authToken: session.accessToken, ...clientState
});
if (!newState.accounts) {
const accounts = await fetchAccounts(newState);
await State.update({ accounts });
console.info('Acquired account data');
console.info('Updated auth state from client and auth session');
}
console.info('Updated state from client and auth session');
console.log(await State.current);
}
state = await State.current;
await ensureDataPopulated(state);
return { state: 'Populated' };
}
browser.runtime.onMessage.addListener((message, sender) => {
console.log('received message', message);
async function getAccounts(): Promise<GetAccountsReply> {
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();
}
});

View File

@ -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<Account>,
error: Error | null
}
export interface EnsureStateMessage {
action: Actions.EnsureState;
clientState: PartialStateUpdate;
}
export interface EnsureStateReply {
state: 'Populated' | 'Error';
}

View File

@ -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<GetAccountsReply> {
const message: GetAccountsMessage = { action: Actions.GetAccounts };
return await browser.runtime.sendMessage(message);
}
export async function ensureState(clientState: PartialStateUpdate): Promise<EnsureStateReply> {
const message: EnsureStateMessage = { action: Actions.EnsureState, clientState };
return await browser.runtime.sendMessage(message);
}

View File

@ -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 { }

View File

@ -0,0 +1,7 @@
import { createNanoEvents } from 'nanoevents';
export enum Event {
StatePopulated = 'StatePopulated'
}
export const ClientEvents = createNanoEvents();

View File

@ -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<Account>;
}
//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(
<div class="content-box fill shadow ui-form">
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 =>
<li>
<input id="export-{acct.acountNumber}" type="checkbox" />
&nbsp;
<label htmlFor="export-{acct.acountNumber}">
{acct.accountNumber} - {acct.name}
</label>
</li>
);
return (
<div id="account-export-list">
<ul>
<li>
<input id="check-all" type="checkbox" onClick={selectAllHandler} />
&nbsp;
<label htmlFor="check-all">Select All</label>
</li>
{accountList}
</ul>
</div>
);
}
window.addEventListener('DOMContentLoaded', async () => {
ClientEvents.on(Event.StatePopulated, async () => {
const response = await background.getAccounts();
if (response.error == null) {
const exportDiv = (
<div id="account-export" class="content-box fill shadow ui-form">
<h2>Download Transaction Statements</h2>
<p>
Here, you can export statements from one or more of your accounts
over a specified date range.
</p>
<AccountList accounts={response.accounts} />
</div>
);
await populateAccounts();
const contentDiv = document.querySelector(
'div[class="table-data content-box fill shadow"]'
);
contentDiv?.prepend(exportDiv);
}
});
browser.runtime.onMessage.addListener((message, sender) => {
console.log('message received!', message);
});

View File

@ -4,6 +4,6 @@
export interface Account {
accountNumber: string;
name: String;
name: string;
currency: string;
}

View File

@ -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<Account>;
ready: boolean;
status: Status
}
export interface PartialStateUpdate {
@ -13,7 +20,7 @@ export interface PartialStateUpdate {
authToken?: string;
fingerprintValue?: string;
accounts?: Array<Account>;
ready?: boolean;
status?: Status
}
// TODO export it and replace direct references to level with it
@ -23,3 +30,12 @@ interface StateStore {
clear(): Promise<any>;
}
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;

View File

@ -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() {

View File

@ -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

View File

@ -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"