Download data and create CSVs
This commit is contained in:
parent
3f884f0923
commit
a8cfd41892
|
@ -2,3 +2,4 @@ node_modules/
|
|||
yarn-error.log
|
||||
.parcel-cache/
|
||||
dist/
|
||||
src/**/**.js
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 90,
|
||||
"semi": true
|
||||
}
|
14
package.json
14
package.json
|
@ -6,9 +6,15 @@
|
|||
"@parcel/transformer-inline-string": "2.8.3",
|
||||
"@types/firefox-webext-browser": "^111.0.0",
|
||||
"@types/level-ttl": "^3.1.2",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"buffer": "^5.5.0",
|
||||
"events": "^3.1.0",
|
||||
"parcel": "^2.8.3"
|
||||
"parcel": "^2.12.0",
|
||||
"prettier": "^3.3.3",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"string_decoder": "^1.3.0",
|
||||
"typescript": "^5.6.3",
|
||||
"util": "^0.12.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
|
||||
|
@ -27,6 +33,8 @@
|
|||
"graphql-request": "^5.2.0",
|
||||
"jsx-dom": "^8.0.5",
|
||||
"level": "^8.0.0",
|
||||
"nanoevents": "^7.0.1"
|
||||
}
|
||||
"nanoevents": "^7.0.1",
|
||||
"papaparse": "^5.4.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
|
||||
}
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] };
|
||||
|
||||
import { fetchAccounts } from '../landsbankinn';
|
||||
import { PartialStateUpdate } from '../state';
|
||||
import { State } from '../state/level';
|
||||
|
||||
type HttpHeaders = browser.webRequest.HttpHeaders;
|
||||
|
||||
function extractAuthStateFromHeaders(headers: HttpHeaders): PartialStateUpdate {
|
||||
let apiKey = '';
|
||||
let authToken = '';
|
||||
let fingerprintValue = '';
|
||||
|
||||
for (const header of headers) {
|
||||
switch (header.name.toLowerCase()) {
|
||||
case "authorization":
|
||||
authToken = header.value || '';
|
||||
break;
|
||||
case "apikey":
|
||||
apiKey = header.value || '';
|
||||
break;
|
||||
case "fingerprintvalue":
|
||||
fingerprintValue = header.value || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { apiKey, authToken, fingerprintValue };
|
||||
}
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(e => {
|
||||
// TODO if request errors, then clear state and try again.
|
||||
(async function() {
|
||||
let state = await State.current;
|
||||
if (!state.ready && e.requestHeaders) {
|
||||
console.info('Will update auth info from headers');
|
||||
|
||||
// Lock ready to true so we don't have multiple attempts
|
||||
// at getting account info.
|
||||
await State.update({ ready: true });
|
||||
let newState = extractAuthStateFromHeaders(e.requestHeaders);
|
||||
state = await State.update(newState);
|
||||
|
||||
if (!newState.accounts) {
|
||||
const accounts = await fetchAccounts(state);
|
||||
await State.update({ accounts });
|
||||
console.info('Acquired account data');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, matchGraphQL, ["requestHeaders"]);
|
||||
|
||||
export { }
|
||||
|
||||
// Here we intercept and record the necessary auth info. But what if
|
||||
// we cannot do that, if user logs directly into a page where there
|
||||
// are no graphql calls?
|
||||
|
||||
// We can get device fingerprint via content_script, and call session.
|
||||
|
||||
// Have a message called ENSURE_STATE.
|
||||
// Sent to background page w/ device_fingerprint.
|
||||
// If state not ready, call session endpoint, and parse output.
|
||||
// Record state.
|
||||
// Content script for ensure state runs everywhere.
|
||||
// There is a value on the page that has everything we need!
|
|
@ -1,9 +1,33 @@
|
|||
import { isStatePopulated, isStateClear, PartialStateUpdate, ExporterState, isStateAbleToRequest } from "../state";
|
||||
import Papa from 'papaparse';
|
||||
import {
|
||||
isStatePopulated,
|
||||
isStateClear,
|
||||
PartialStateUpdate,
|
||||
ExporterState,
|
||||
isStateAbleToRequest
|
||||
} from '../state';
|
||||
import { Status } from '../state';
|
||||
import { LevelState } from '../state/level';
|
||||
import * as landsbankinn from '../landsbankinn';
|
||||
import { fetchAccounts } from "../landsbankinn";
|
||||
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsReply } from './messages';
|
||||
import {
|
||||
fetchAccounts,
|
||||
downloadTransactions,
|
||||
OutputFormat,
|
||||
RawTransaction
|
||||
} from '../landsbankinn';
|
||||
import {
|
||||
Actions,
|
||||
DownloadTransactionReply,
|
||||
DownloadTransactionsMessage,
|
||||
EnsureStateMessage,
|
||||
EnsureStateReply,
|
||||
GetAccountsReply
|
||||
} from './messages';
|
||||
|
||||
type GroupedRawTransaction = RawTransaction & { txnAccountNumber: string };
|
||||
type EnsureStateAction = Required<Pick<EnsureStateMessage, 'clientState'>>;
|
||||
type DownloadTransactionsAction = Omit<DownloadTransactionsMessage, 'action'>;
|
||||
type TransactionsByAccount = Record<string, RawTransaction[] | undefined>;
|
||||
|
||||
// State singleton (move to its own file if we eventually get more
|
||||
// than 1 background script)
|
||||
|
@ -17,22 +41,28 @@ async function ensureDataPopulated(state: ExporterState) {
|
|||
}
|
||||
}
|
||||
|
||||
async function ensureState(message: EnsureStateMessage): Promise<EnsureStateReply> {
|
||||
console.info('Ensuring state');
|
||||
const clientState: PartialStateUpdate = message.clientState;;
|
||||
async function ensureAuthState(message: EnsureStateAction) {
|
||||
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
|
||||
status: Status.AbleToRequest,
|
||||
authToken: session.accessToken,
|
||||
...clientState
|
||||
});
|
||||
|
||||
console.info('Updated auth state from client and auth session');
|
||||
}
|
||||
|
||||
state = await State.current;
|
||||
return await State.current;
|
||||
}
|
||||
|
||||
async function ensureState(message: EnsureStateAction): Promise<EnsureStateReply> {
|
||||
console.info('Ensuring state');
|
||||
const state = await ensureAuthState(message);
|
||||
await ensureDataPopulated(state);
|
||||
return { state: 'Populated' };
|
||||
}
|
||||
|
@ -49,8 +79,7 @@ async function getAccounts(): Promise<GetAccountsReply> {
|
|||
accounts: (await State.current).accounts,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
message = {
|
||||
accounts: undefined,
|
||||
error: new Error('state not yet populated')
|
||||
|
@ -60,13 +89,58 @@ async function getAccounts(): Promise<GetAccountsReply> {
|
|||
return message;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
accountNumber: string;
|
||||
transactions: landsbankinn.RawTransaction[];
|
||||
}
|
||||
|
||||
const getAllTransactions = async (message: DownloadTransactionsAction) =>
|
||||
await Promise.all(
|
||||
message.accounts.map(async (accountNumber) => {
|
||||
const transactions = (
|
||||
await downloadTransactions({
|
||||
accountNumber: accountNumber,
|
||||
dateFrom: message.dateFrom,
|
||||
dateTo: message.dateTo,
|
||||
outputFormat: OutputFormat.CSV
|
||||
})
|
||||
).map((txn) => ({ txnAccountNumber: accountNumber, ...txn }));
|
||||
|
||||
return transactions;
|
||||
})
|
||||
);
|
||||
|
||||
const mapToCsv = (transactions: GroupedRawTransaction[]) => Papa.unparse(transactions);
|
||||
|
||||
async function downloadStatements(
|
||||
message: DownloadTransactionsMessage
|
||||
): Promise<DownloadTransactionReply> {
|
||||
console.info('Initiating download for', message.accounts);
|
||||
const state = await ensureAuthState({ clientState: await State.current });
|
||||
await ensureDataPopulated(state);
|
||||
|
||||
// Should always be populated, but we can be careful anyway.
|
||||
if (isStatePopulated(state)) {
|
||||
const transactions = (await getAllTransactions(message)).flat();
|
||||
return { csv: mapToCsv(transactions), error: null };
|
||||
} else {
|
||||
return {
|
||||
csv: undefined,
|
||||
error: new Error('state not yet populated')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message) => {
|
||||
console.log('Received message:', message);
|
||||
switch (message.action) {
|
||||
case Actions.EnsureState:
|
||||
return await ensureState(message);
|
||||
return await ensureState(message as EnsureStateMessage);
|
||||
case Actions.GetAccounts:
|
||||
return await getAccounts();
|
||||
case Actions.DownloadTransactions:
|
||||
return await downloadStatements(message);
|
||||
}
|
||||
});
|
||||
|
||||
export { }
|
||||
export {};
|
||||
|
|
|
@ -27,3 +27,15 @@ export interface EnsureStateMessage {
|
|||
export interface EnsureStateReply {
|
||||
state: 'Populated' | 'Error';
|
||||
}
|
||||
|
||||
export interface DownloadTransactionsMessage {
|
||||
action: Actions.DownloadTransactions;
|
||||
accounts: string[];
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
}
|
||||
|
||||
export interface DownloadTransactionReply {
|
||||
csv: string | undefined;
|
||||
error: Error | null;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,38 @@
|
|||
// Functions for communicating with the background page.
|
||||
|
||||
import { PartialStateUpdate } from '../state';
|
||||
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsMessage, GetAccountsReply } from '../background/messages';
|
||||
import {
|
||||
Actions,
|
||||
DownloadTransactionReply,
|
||||
DownloadTransactionsMessage,
|
||||
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> {
|
||||
export async function ensureState(
|
||||
clientState: PartialStateUpdate
|
||||
): Promise<EnsureStateReply> {
|
||||
const message: EnsureStateMessage = { action: Actions.EnsureState, clientState };
|
||||
return await browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
export async function downloadTransactions(
|
||||
accounts: string[],
|
||||
dateFrom: Date,
|
||||
dateTo: Date
|
||||
): Promise<DownloadTransactionReply> {
|
||||
const message: DownloadTransactionsMessage = {
|
||||
accounts,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
action: Actions.DownloadTransactions
|
||||
};
|
||||
return await browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// 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 { Event, ClientEvents } from './events';
|
||||
import * as background from './background';
|
||||
|
||||
|
@ -13,7 +13,7 @@ interface ApiInformation {
|
|||
}
|
||||
|
||||
interface RegularWindowProperties {
|
||||
add_deviceprint(): string
|
||||
add_deviceprint(): string;
|
||||
LIPageData: ApiInformation;
|
||||
}
|
||||
|
||||
|
@ -35,26 +35,32 @@ interface LIWindowMega extends Window {
|
|||
}
|
||||
|
||||
type LIWindow = LIWindowRegular | LIWindowMega;
|
||||
type AnyWindow = Window | LIWindowRegular | LIWindowMega
|
||||
type AnyWindow = Window | LIWindowRegular | LIWindowMega;
|
||||
|
||||
function isApiInformation(liData: ApiInformation) {
|
||||
return liData.graphQLUrl !== undefined &&
|
||||
return (
|
||||
liData.graphQLUrl !== undefined &&
|
||||
liData.apikey !== undefined &&
|
||||
liData.locale !== undefined;
|
||||
liData.locale !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function isRegularWindow(win: AnyWindow): win is LIWindowRegular {
|
||||
const liWindow = win as LIWindowRegular;
|
||||
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
|
||||
return (
|
||||
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
|
||||
liWindow.wrappedJSObject.LIPageData !== undefined &&
|
||||
isApiInformation(liWindow.wrappedJSObject.LIPageData);
|
||||
isApiInformation(liWindow.wrappedJSObject.LIPageData)
|
||||
);
|
||||
}
|
||||
|
||||
function isMegaHeaderWindow(win: AnyWindow): win is LIWindowRegular {
|
||||
const liWindow = win as LIWindowMega;
|
||||
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
|
||||
return (
|
||||
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
|
||||
liWindow.wrappedJSObject.LIPageData !== undefined &&
|
||||
isApiInformation(liWindow.wrappedJSObject.LIPageData.megaHeader);
|
||||
isApiInformation(liWindow.wrappedJSObject.LIPageData.megaHeader)
|
||||
);
|
||||
}
|
||||
|
||||
// Type guard that coerces the global window object to the specialized
|
||||
|
@ -66,7 +72,7 @@ function isLandsbankinnWindow(win: AnyWindow): win is LIWindow {
|
|||
|
||||
// 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
|
||||
// most pages, and the "megaHeader" structure found on the statements
|
||||
// page.
|
||||
function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined {
|
||||
if (isRegularWindow(win)) {
|
||||
|
@ -90,6 +96,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
const clientState = extractStateUpdate(window);
|
||||
|
||||
if (clientState) {
|
||||
console.info('Acquired client state');
|
||||
await background.ensureState(clientState);
|
||||
ClientEvents.emit(Event.StatePopulated);
|
||||
} else {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React, { FormEventHandler } from 'jsx-dom'; // necessary for react to work
|
||||
import * as React from 'jsx-dom'; // necessary for React to work?
|
||||
import { Account } from '../../landsbankinn/models';
|
||||
import $ from 'cash-dom';
|
||||
|
||||
interface AccountListProps {
|
||||
accounts: Array<Account>;
|
||||
|
||||
}
|
||||
|
||||
const formatAccountNumber = (accountNumber: string) =>
|
||||
|
@ -129,14 +130,19 @@ interface ExportDivProps {
|
|||
fromLabel: string;
|
||||
toLabel: string;
|
||||
accounts: Account[];
|
||||
onDownload: (accountNumbers: string[]) => void;
|
||||
onDownload: (accountNumbers: string[], dateFrom: Date, dateTo: Date) => void;
|
||||
}
|
||||
|
||||
export const ExportDiv = (props: ExportDivProps) => {
|
||||
const submitHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const accountNumbers = getValues($('#account-export').get(0)!);
|
||||
props.onDownload(accountNumbers);
|
||||
const dateFrom = $(`#${datePickerID(props.fromLabel)}`).val() as string;
|
||||
const dateTo = $(`#${datePickerID(props.toLabel)}`).val() as string;
|
||||
|
||||
console.log(dateFrom);
|
||||
props.onDownload(accountNumbers, new Date(dateFrom), new Date(dateTo));
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,14 +1,31 @@
|
|||
import React from 'jsx-dom'; // necessary for react to work
|
||||
import * as React from 'jsx-dom'; // necessary for react to work
|
||||
import { Event, ClientEvents } from '../events';
|
||||
import * as background from '../background';
|
||||
import { ExportDiv } from './components';
|
||||
|
||||
const logDownload = (accountNumbers: string[], dateFrom: Date, dateTo: Date) =>
|
||||
console.info(
|
||||
`Downloading transactions from ${dateFrom} to ${dateTo} ` +
|
||||
`for: ${accountNumbers.join(', ')}`
|
||||
);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
ClientEvents.on(Event.StatePopulated, async () => {
|
||||
const response = await background.getAccounts();
|
||||
|
||||
const downloadHandler = (accountNumbers: string[]) => {
|
||||
console.log('downloading txns for', accountNumbers);
|
||||
const downloadHandler = async (
|
||||
accountNumbers: string[], dateFrom: Date, dateTo: Date
|
||||
) => {
|
||||
logDownload(accountNumbers, dateFrom, dateTo);
|
||||
const reply = await background.downloadTransactions(
|
||||
accountNumbers, dateFrom, dateTo
|
||||
);
|
||||
|
||||
if (reply.error == null) {
|
||||
console.log(reply.csv);
|
||||
} else {
|
||||
console.error(reply.error);
|
||||
}
|
||||
};
|
||||
|
||||
if (response.error == null) {
|
||||
|
|
|
@ -43,15 +43,20 @@ export class LevelState {
|
|||
|
||||
private async prepTTL() {
|
||||
console.info(`Will clear state in ${Config.StateTTL} milliseconds`);
|
||||
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), Config.StateTTL);
|
||||
this.currentTTLHandle = window.setTimeout(
|
||||
async () => await this.clear(),
|
||||
Config.StateTTL
|
||||
);
|
||||
}
|
||||
|
||||
async update(stateUpdate: ExporterState | PartialStateUpdate): Promise<ExporterState> {
|
||||
async update(
|
||||
stateUpdate: ExporterState | PartialStateUpdate
|
||||
): Promise<ExporterState> {
|
||||
this.clearTTL();
|
||||
const currentState: ExporterState = await this.current;
|
||||
let newState = { ...currentState, ...stateUpdate };
|
||||
await this._db.put('state', newState as any);
|
||||
this.prepTTL();
|
||||
return this.current;
|
||||
return await this.current;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "es6",
|
||||
"module": "es6",
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [ "dom", "es6" ],
|
||||
"lib": [ "dom", "es2022", "esnext" ],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "jsx-dom",
|
||||
"esModuleInterop": true,
|
||||
|
|
Loading…
Reference in New Issue