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 "maxParallelRequests": 20
}, },
"dependencies": { "dependencies": {
"cash-dom": "^8.1.4",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"graphql-request": "^5.2.0", "graphql-request": "^5.2.0",
"jsx-dom": "^8.0.5", "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 { State } from '../state/level';
import * as landsbankinn from '../landsbankinn'; import * as landsbankinn from '../landsbankinn';
import { fetchAccounts } from "../landsbankinn"; import { fetchAccounts } from "../landsbankinn";
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsReply } from './messages';
export enum Actions { async function ensureDataPopulated(state: ExporterState) {
EnsureState = 'ENSURE_STATE', if (isStateAbleToRequest(state) && !isStatePopulated(state)) {
GetAccounts = 'GET_ACCOUNTS', const accounts = await fetchAccounts(state);
DownloadTransactions = 'DOWNLOAD_TRANSACTIONS' await State.update({ status: Status.Populated, accounts });
} console.info('Populated account data');
type MessageSender = browser.runtime.MessageSender;
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 });
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);
} }
} }
browser.runtime.onMessage.addListener((message, sender) => { async function ensureState(message: EnsureStateMessage): Promise<EnsureStateReply> {
console.log('received message', message); 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<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) { switch (message.action) {
case Actions.EnsureState: case Actions.EnsureState:
ensureState(message, sender); return await ensureState(message);
case Actions.GetAccounts: 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 { 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 // The actual information we care about
interface ApiInformation { interface ApiInformation {
@ -38,6 +42,7 @@ function isApiInformation(liData: ApiInformation) {
liData.apikey !== undefined && liData.apikey !== undefined &&
liData.locale !== undefined; liData.locale !== undefined;
} }
function isRegularWindow(win: AnyWindow): win is LIWindowRegular { function isRegularWindow(win: AnyWindow): win is LIWindowRegular {
const liWindow = win as LIWindowRegular; const liWindow = win as LIWindowRegular;
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
@ -59,6 +64,10 @@ function isLandsbankinnWindow(win: AnyWindow): win is LIWindow {
return isRegularWindow(win) || isMegaHeaderWindow(win); 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 { function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined {
if (isRegularWindow(win)) { if (isRegularWindow(win)) {
return { return {
@ -75,17 +84,16 @@ function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined {
} }
} }
// Communicates data about the GraphQL URL and API key to the window.addEventListener('DOMContentLoaded', async () => {
// extension.
document.addEventListener('DOMContentLoaded', async () => {
console.log(window);
if (isLandsbankinnWindow(window)) { if (isLandsbankinnWindow(window)) {
console.info('Ensuring extension state'); console.info('Ensuring extension state');
const clientState = extractStateUpdate(window); 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() { interface AccountListProps {
const resp = await browser.runtime.sendMessage({ accounts: Array<Account>;
action: 'GET_ACCOUNTS',
});
} }
//Add an element to the statement page function AccountList(props: AccountListProps) {
document.addEventListener('DOMContentLoaded', async () => { const selectAllHandler = () => {
const contentDiv = document.querySelector('div[class="table-data content-box fill shadow"]'); const checked = $('#check-all').prop('checked');
contentDiv?.prepend( $('#account-export-list input[type="checkbox"]').prop('checked', checked);
<div class="content-box fill shadow ui-form"> };
<h2>Download Transaction Statements</h2>
<p> const accountList = props.accounts.map(acct =>
Here, you can export statements from one or more of your accounts <li>
over a specified date range. <input id="export-{acct.acountNumber}" type="checkbox" />
</p> &nbsp;
</div> <label htmlFor="export-{acct.acountNumber}">
{acct.accountNumber} - {acct.name}
</label>
</li>
); );
await populateAccounts(); 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>
);
}
browser.runtime.onMessage.addListener((message, sender) => { window.addEventListener('DOMContentLoaded', async () => {
console.log('message received!', message); 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>
);
const contentDiv = document.querySelector(
'div[class="table-data content-box fill shadow"]'
);
contentDiv?.prepend(exportDiv);
}
});
}); });

View File

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

View File

@ -1,11 +1,18 @@
import { Account } from '../landsbankinn/models'; 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 { export interface ExporterState {
apiKey: string; apiKey: string;
authToken: string; authToken: string;
fingerprintValue: string; fingerprintValue: string;
accounts: Array<Account>; accounts: Array<Account>;
ready: boolean; status: Status
} }
export interface PartialStateUpdate { export interface PartialStateUpdate {
@ -13,7 +20,7 @@ export interface PartialStateUpdate {
authToken?: string; authToken?: string;
fingerprintValue?: string; fingerprintValue?: string;
accounts?: Array<Account>; accounts?: Array<Account>;
ready?: boolean; status?: Status
} }
// TODO export it and replace direct references to level with it // TODO export it and replace direct references to level with it
@ -23,3 +30,12 @@ interface StateStore {
clear(): Promise<any>; 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 { Level } from 'level';
import { ExporterState, PartialStateUpdate } from './index'; import { ExporterState, PartialStateUpdate, Status } from './index';
const FIVE_MINUTES = 0.25 * 60 * 1000; const FIVE_MINUTES = 0.25 * 60 * 1000;
@ -33,7 +33,7 @@ class LevelState {
async clear() { async clear() {
console.info('Cleared state'); 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() { private async clearTTL() {

View File

@ -1,11 +1,14 @@
How to get it working: How to get it working:
- [X] Landsbankinn API client for arbitrary transaction lists - [X] Landsbankinn API client for arbitrary transaction lists
- [ ] Transform raw transactions into friendlier ones (i.e. date objects) - [ ] 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. - [ ] Background page that downloads all the shit and combines into CSV, then 'downloads' file.
- [ ] Build up communication message passing to background page for downloads - [X] Build up communication message passing to background page for downloads
- [ ] Investigate persistent = false + setTimeout - [ ] Don't run level on client.
- [-] Investigate persistent = false + setTimeout
- [ ] Maybe can force clear state when page reloads itself? - [ ] 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] Graphql API client to get information about user, like listing accounts.
- [X] Get working client - [X] Get working client
- [X] Set API key - [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" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001468.tgz#0101837c6a4e38e6331104c33dcfb3bdf367a4b7"
integrity sha512-zgAo8D5kbOyUcRAgSmgyuvBkjrGk5CGYG5TYgFdpQv+ywcyEpo1LOWoG8YmoflGnh+V+UsNuKYedsoYs0hzV5A== 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: catering@^2.1.0, catering@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/catering/-/catering-2.1.1.tgz#66acba06ed5ee28d5286133982a927de9a04b510" resolved "https://registry.yarnpkg.com/catering/-/catering-2.1.1.tgz#66acba06ed5ee28d5286133982a927de9a04b510"
@ -1480,6 +1485,11 @@ msgpackr@^1.5.4:
optionalDependencies: optionalDependencies:
msgpackr-extract "^3.0.1" 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: napi-macros@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b"