@accounter/scraper-app 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useState, type ReactElement } from 'react';
|
|
2
|
+
import type { TaskState } from '../lib/ws.js';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
sourceId: string;
|
|
6
|
+
nickname: string;
|
|
7
|
+
state: TaskState;
|
|
8
|
+
onNavigateAccounts?: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const BADGE_STYLES: Record<string, { background: string; color: string }> = {
|
|
12
|
+
pending: { background: '#e5e7eb', color: '#374151' },
|
|
13
|
+
running: { background: '#dbeafe', color: '#1d4ed8' },
|
|
14
|
+
done: { background: '#dcfce7', color: '#15803d' },
|
|
15
|
+
error: { background: '#fee2e2', color: '#b91c1c' },
|
|
16
|
+
blocked: { background: '#fef9c3', color: '#854d0e' },
|
|
17
|
+
'otp-required': { background: '#ede9fe', color: '#6d28d9' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
21
|
+
pending: 'Pending',
|
|
22
|
+
running: 'Running…',
|
|
23
|
+
done: 'Done',
|
|
24
|
+
error: 'Error',
|
|
25
|
+
blocked: 'Blocked',
|
|
26
|
+
'otp-required': 'OTP Required',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function TaskRow({
|
|
30
|
+
sourceId: _sourceId,
|
|
31
|
+
nickname,
|
|
32
|
+
state,
|
|
33
|
+
onNavigateAccounts,
|
|
34
|
+
}: Props): ReactElement {
|
|
35
|
+
const [expanded, setExpanded] = useState(false);
|
|
36
|
+
const badgeStyle = BADGE_STYLES[state.status] ?? BADGE_STYLES['pending']!;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
style={{
|
|
41
|
+
display: 'flex',
|
|
42
|
+
flexDirection: 'column',
|
|
43
|
+
gap: 4,
|
|
44
|
+
padding: '10px 12px',
|
|
45
|
+
border: '1px solid #e5e7eb',
|
|
46
|
+
borderRadius: 6,
|
|
47
|
+
marginBottom: 8,
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
51
|
+
<span
|
|
52
|
+
style={{
|
|
53
|
+
...badgeStyle,
|
|
54
|
+
padding: '2px 8px',
|
|
55
|
+
borderRadius: 12,
|
|
56
|
+
fontSize: '0.8em',
|
|
57
|
+
fontWeight: 600,
|
|
58
|
+
minWidth: 80,
|
|
59
|
+
textAlign: 'center',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{state.status === 'running' ? '⟳ ' : ''}
|
|
63
|
+
{STATUS_LABELS[state.status] ?? state.status}
|
|
64
|
+
</span>
|
|
65
|
+
<span style={{ fontWeight: 500 }}>{nickname}</span>
|
|
66
|
+
|
|
67
|
+
{state.status === 'done' && (
|
|
68
|
+
<>
|
|
69
|
+
<span style={{ marginLeft: 'auto', fontSize: '0.9em', color: '#555' }}>
|
|
70
|
+
↑ {state.inserted ?? 0} new / {state.skipped ?? 0} skipped
|
|
71
|
+
{(state.changedTransactions?.length ?? 0) > 0 && (
|
|
72
|
+
<> / {state.changedTransactions!.length} changed</>
|
|
73
|
+
)}
|
|
74
|
+
</span>
|
|
75
|
+
{((state.insertedTransactions?.length ?? 0) > 0 ||
|
|
76
|
+
(state.changedTransactions?.length ?? 0) > 0) && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => setExpanded(e => !e)}
|
|
80
|
+
style={{
|
|
81
|
+
background: 'none',
|
|
82
|
+
border: 'none',
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
color: '#374151',
|
|
85
|
+
fontSize: '0.85em',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{expanded ? '▲ Hide' : '▼ Details'}
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{state.status === 'error' && (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => setExpanded(e => !e)}
|
|
98
|
+
style={{
|
|
99
|
+
marginLeft: 'auto',
|
|
100
|
+
background: 'none',
|
|
101
|
+
border: 'none',
|
|
102
|
+
cursor: 'pointer',
|
|
103
|
+
color: '#b91c1c',
|
|
104
|
+
fontSize: '0.85em',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{expanded ? '▲ Hide' : '▼ Details'}
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{state.status === 'error' && expanded && (
|
|
113
|
+
<div
|
|
114
|
+
style={{
|
|
115
|
+
background: '#fef2f2',
|
|
116
|
+
border: '1px solid #fecaca',
|
|
117
|
+
borderRadius: 4,
|
|
118
|
+
padding: 10,
|
|
119
|
+
fontSize: '0.85em',
|
|
120
|
+
fontFamily: 'monospace',
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<div style={{ fontWeight: 600, marginBottom: 4 }}>{state.error}</div>
|
|
124
|
+
{state.stack && (
|
|
125
|
+
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', color: '#7f1d1d' }}>{state.stack}</pre>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{state.status === 'done' && expanded && (
|
|
131
|
+
<div style={{ fontSize: '0.85em', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
132
|
+
{(state.insertedTransactions?.length ?? 0) > 0 && (
|
|
133
|
+
<div>
|
|
134
|
+
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
135
|
+
New transactions ({state.insertedTransactions!.length})
|
|
136
|
+
</div>
|
|
137
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
138
|
+
<thead>
|
|
139
|
+
<tr style={{ color: '#6b7280', textAlign: 'left' }}>
|
|
140
|
+
<th style={{ padding: '2px 8px 2px 0' }}>Date</th>
|
|
141
|
+
<th style={{ padding: '2px 8px 2px 0' }}>Description</th>
|
|
142
|
+
<th style={{ padding: '2px 8px 2px 0' }}>Amount</th>
|
|
143
|
+
<th style={{ padding: '2px 0' }}>Account</th>
|
|
144
|
+
</tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
{state.insertedTransactions!.map(t => (
|
|
148
|
+
<tr key={t.id} style={{ borderTop: '1px solid #f3f4f6' }}>
|
|
149
|
+
<td style={{ padding: '2px 8px 2px 0', color: '#6b7280' }}>
|
|
150
|
+
{t.date ?? '—'}
|
|
151
|
+
</td>
|
|
152
|
+
<td style={{ padding: '2px 8px 2px 0' }}>{t.description ?? '—'}</td>
|
|
153
|
+
<td style={{ padding: '2px 8px 2px 0', fontVariantNumeric: 'tabular-nums' }}>
|
|
154
|
+
{t.amount ?? '—'}
|
|
155
|
+
</td>
|
|
156
|
+
<td style={{ padding: '2px 0', color: '#6b7280' }}>{t.account ?? '—'}</td>
|
|
157
|
+
</tr>
|
|
158
|
+
))}
|
|
159
|
+
</tbody>
|
|
160
|
+
</table>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{(state.changedTransactions?.length ?? 0) > 0 && (
|
|
165
|
+
<div>
|
|
166
|
+
<div style={{ fontWeight: 600, marginBottom: 4, color: '#b45309' }}>
|
|
167
|
+
Changed transactions ({state.changedTransactions!.length})
|
|
168
|
+
</div>
|
|
169
|
+
{state.changedTransactions!.map(ct => (
|
|
170
|
+
<div
|
|
171
|
+
key={ct.id}
|
|
172
|
+
style={{
|
|
173
|
+
marginBottom: 6,
|
|
174
|
+
padding: '6px 8px',
|
|
175
|
+
background: '#fffbeb',
|
|
176
|
+
border: '1px solid #fde68a',
|
|
177
|
+
borderRadius: 4,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<div style={{ color: '#6b7280', marginBottom: 4, fontFamily: 'monospace' }}>
|
|
181
|
+
{ct.id}
|
|
182
|
+
</div>
|
|
183
|
+
<table style={{ borderCollapse: 'collapse' }}>
|
|
184
|
+
<thead>
|
|
185
|
+
<tr style={{ color: '#6b7280', textAlign: 'left' }}>
|
|
186
|
+
<th style={{ padding: '1px 12px 1px 0' }}>Field</th>
|
|
187
|
+
<th style={{ padding: '1px 12px 1px 0' }}>Old</th>
|
|
188
|
+
<th style={{ padding: '1px 0' }}>New</th>
|
|
189
|
+
</tr>
|
|
190
|
+
</thead>
|
|
191
|
+
<tbody>
|
|
192
|
+
{ct.changedFields.map(f => (
|
|
193
|
+
<tr key={f.field} style={{ borderTop: '1px solid #fde68a' }}>
|
|
194
|
+
<td style={{ padding: '1px 12px 1px 0', fontFamily: 'monospace' }}>
|
|
195
|
+
{f.field}
|
|
196
|
+
</td>
|
|
197
|
+
<td style={{ padding: '1px 12px 1px 0', color: '#b91c1c' }}>
|
|
198
|
+
{f.oldValue ?? '—'}
|
|
199
|
+
</td>
|
|
200
|
+
<td style={{ padding: '1px 0', color: '#15803d' }}>
|
|
201
|
+
{f.newValue ?? '—'}
|
|
202
|
+
</td>
|
|
203
|
+
</tr>
|
|
204
|
+
))}
|
|
205
|
+
</tbody>
|
|
206
|
+
</table>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{state.status === 'blocked' && (
|
|
215
|
+
<div style={{ fontSize: '0.85em', color: '#713f12', paddingLeft: 4 }}>
|
|
216
|
+
<div>Unknown accounts: {(state.blockedAccounts ?? []).join(', ')}</div>
|
|
217
|
+
{onNavigateAccounts ? (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onNavigateAccounts}
|
|
221
|
+
style={{
|
|
222
|
+
marginTop: 4,
|
|
223
|
+
background: 'none',
|
|
224
|
+
border: 'none',
|
|
225
|
+
cursor: 'pointer',
|
|
226
|
+
color: '#1d4ed8',
|
|
227
|
+
padding: 0,
|
|
228
|
+
textDecoration: 'underline',
|
|
229
|
+
fontSize: '0.95em',
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
Go to Accounts tab
|
|
233
|
+
</button>
|
|
234
|
+
) : (
|
|
235
|
+
<span style={{ color: '#1d4ed8' }}>Go to Accounts tab</span>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
vaultCreate as apiCreate,
|
|
12
|
+
ApiError,
|
|
13
|
+
vaultStatus as apiStatus,
|
|
14
|
+
vaultUnlock as apiUnlock,
|
|
15
|
+
} from '../lib/api.js';
|
|
16
|
+
|
|
17
|
+
export type VaultStatus = 'loading' | 'locked' | 'no-file' | 'unlocked';
|
|
18
|
+
|
|
19
|
+
type VaultContextValue = {
|
|
20
|
+
status: VaultStatus;
|
|
21
|
+
error: string | null;
|
|
22
|
+
unlock(password: string): Promise<void>;
|
|
23
|
+
create(password: string, serverUrl: string, apiKey: string): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const VaultContext = createContext<VaultContextValue | null>(null);
|
|
27
|
+
|
|
28
|
+
export function VaultProvider({ children }: { children: ReactNode }): ReactElement {
|
|
29
|
+
const [status, setStatus] = useState<VaultStatus>('loading');
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
apiStatus()
|
|
34
|
+
.then(s => {
|
|
35
|
+
if (!s.hasFile) setStatus('no-file');
|
|
36
|
+
else if (s.locked) setStatus('locked');
|
|
37
|
+
else setStatus('unlocked');
|
|
38
|
+
})
|
|
39
|
+
.catch(() => setStatus('locked'));
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const unlock = useCallback(async (password: string) => {
|
|
43
|
+
setError(null);
|
|
44
|
+
try {
|
|
45
|
+
await apiUnlock(password);
|
|
46
|
+
setStatus('unlocked');
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(
|
|
49
|
+
e instanceof ApiError && e.status === 404
|
|
50
|
+
? 'Vault not found.'
|
|
51
|
+
: 'Wrong password. Please try again.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const create = useCallback(async (password: string, serverUrl: string, apiKey: string) => {
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await apiCreate(password, serverUrl, apiKey);
|
|
60
|
+
setStatus('unlocked');
|
|
61
|
+
} catch {
|
|
62
|
+
setError('Failed to create vault. Please try again.');
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<VaultContext.Provider value={{ status, error, unlock, create }}>
|
|
68
|
+
{children}
|
|
69
|
+
</VaultContext.Provider>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useVault(): VaultContextValue {
|
|
74
|
+
const ctx = useContext(VaultContext);
|
|
75
|
+
if (!ctx) throw new Error('useVault must be used inside VaultProvider');
|
|
76
|
+
return ctx;
|
|
77
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BankAccount, Settings } from '../../server/vault.js';
|
|
2
|
+
import type { RunRecord } from '../../shared/types.js';
|
|
3
|
+
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public readonly status: number,
|
|
7
|
+
message: string,
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
|
15
|
+
const res = await fetch(url, init);
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
18
|
+
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
|
|
19
|
+
}
|
|
20
|
+
return res.json() as Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function vaultStatus(): Promise<{ locked: boolean; hasFile: boolean }> {
|
|
24
|
+
return apiFetch('/api/vault/status');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function vaultUnlock(password: string): Promise<{ ok: boolean }> {
|
|
28
|
+
return apiFetch('/api/vault/unlock', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ password }),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function vaultCreate(
|
|
36
|
+
password: string,
|
|
37
|
+
serverUrl: string,
|
|
38
|
+
apiKey: string,
|
|
39
|
+
): Promise<{ ok: boolean }> {
|
|
40
|
+
return apiFetch('/api/vault/create', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ password, serverUrl, apiKey }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Sources ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function getSources<T>(): Promise<T[]> {
|
|
50
|
+
return apiFetch('/api/vault/sources');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSource<T>(data: unknown): Promise<T[]> {
|
|
54
|
+
return apiFetch('/api/vault/sources', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify(data),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function updateSource<T>(id: string, data: unknown): Promise<T[]> {
|
|
62
|
+
return apiFetch(`/api/vault/sources/${id}`, {
|
|
63
|
+
method: 'PUT',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify(data),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function deleteSource<T>(id: string): Promise<T[]> {
|
|
70
|
+
return apiFetch(`/api/vault/sources/${id}`, { method: 'DELETE' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadSettings(): Promise<Settings> {
|
|
74
|
+
return apiFetch('/api/vault/settings');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function saveSettings(patch: Partial<Settings>): Promise<Settings> {
|
|
78
|
+
return apiFetch('/api/vault/settings', {
|
|
79
|
+
method: 'PUT',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify(patch),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fetchAccounts(): Promise<BankAccount[]> {
|
|
86
|
+
return apiFetch('/api/vault/accounts');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateStatus(id: string, status: 'accepted' | 'ignored'): Promise<BankAccount[]> {
|
|
90
|
+
return apiFetch(`/api/vault/accounts/${id}`, {
|
|
91
|
+
method: 'PUT',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ status }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function deleteAccount(id: string): Promise<BankAccount[]> {
|
|
98
|
+
return apiFetch(`/api/vault/accounts/${id}`, { method: 'DELETE' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Vault path ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export function getVaultPath(): Promise<{ path: string }> {
|
|
104
|
+
return apiFetch('/api/vault/path');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Connection test ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export function testConnection(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
|
110
|
+
return apiFetch('/api/vault/test-connection');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── History ───────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export function getHistory(): Promise<RunRecord[]> {
|
|
116
|
+
return apiFetch('/api/history');
|
|
117
|
+
}
|
package/src/ui/lib/ws.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { ClientMessage, RunCompleteMessage } from '../../shared/ws-protocol.js';
|
|
3
|
+
import { ServerMessageSchema } from '../../shared/ws-protocol.js';
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = 'pending' | 'running' | 'done' | 'error' | 'blocked' | 'otp-required';
|
|
6
|
+
|
|
7
|
+
export type InsertedTransactionSummary = {
|
|
8
|
+
id: string;
|
|
9
|
+
date?: string | null | undefined;
|
|
10
|
+
description?: string | null | undefined;
|
|
11
|
+
amount?: string | null | undefined;
|
|
12
|
+
account?: string | null | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ChangedTransaction = {
|
|
16
|
+
id: string;
|
|
17
|
+
changedFields: {
|
|
18
|
+
field: string;
|
|
19
|
+
oldValue?: string | null | undefined;
|
|
20
|
+
newValue?: string | null | undefined;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TaskState = {
|
|
25
|
+
status: TaskStatus;
|
|
26
|
+
inserted?: number;
|
|
27
|
+
skipped?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
stack?: string;
|
|
30
|
+
blockedAccounts?: string[];
|
|
31
|
+
otpSourceId?: string;
|
|
32
|
+
insertedTransactions?: InsertedTransactionSummary[];
|
|
33
|
+
changedTransactions?: ChangedTransaction[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type RunStatus = 'idle' | 'running' | 'complete';
|
|
37
|
+
|
|
38
|
+
export type UseRunSocketResult = {
|
|
39
|
+
send: (msg: ClientMessage) => void;
|
|
40
|
+
taskStates: Map<string, TaskState>;
|
|
41
|
+
runStatus: RunStatus;
|
|
42
|
+
summary: RunCompleteMessage | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function wsUrl(): string {
|
|
46
|
+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
47
|
+
return `${proto}//${window.location.host}/ws`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useRunSocket(): UseRunSocketResult {
|
|
51
|
+
const [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
|
|
52
|
+
const [runStatus, setRunStatus] = useState<RunStatus>('idle');
|
|
53
|
+
const [summary, setSummary] = useState<RunCompleteMessage | null>(null);
|
|
54
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const ws = new WebSocket(wsUrl());
|
|
58
|
+
wsRef.current = ws;
|
|
59
|
+
|
|
60
|
+
ws.onmessage = event => {
|
|
61
|
+
let raw: unknown;
|
|
62
|
+
try {
|
|
63
|
+
raw = JSON.parse(event.data as string);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = ServerMessageSchema.safeParse(raw);
|
|
69
|
+
if (!result.success) return;
|
|
70
|
+
const msg = result.data;
|
|
71
|
+
|
|
72
|
+
setTaskStates(prev => {
|
|
73
|
+
const next = new Map(prev);
|
|
74
|
+
switch (msg.type) {
|
|
75
|
+
case 'task-pending':
|
|
76
|
+
next.set(msg.sourceId, { status: 'pending' });
|
|
77
|
+
break;
|
|
78
|
+
case 'task-running':
|
|
79
|
+
next.set(msg.sourceId, { ...next.get(msg.sourceId), status: 'running' });
|
|
80
|
+
break;
|
|
81
|
+
case 'task-done':
|
|
82
|
+
next.set(msg.sourceId, {
|
|
83
|
+
status: 'done',
|
|
84
|
+
inserted: msg.inserted,
|
|
85
|
+
skipped: msg.skipped,
|
|
86
|
+
insertedTransactions: msg.insertedTransactions,
|
|
87
|
+
changedTransactions: msg.changedTransactions,
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
case 'task-error':
|
|
91
|
+
next.set(msg.sourceId, { status: 'error', error: msg.message, stack: msg.stack });
|
|
92
|
+
break;
|
|
93
|
+
case 'task-blocked':
|
|
94
|
+
next.set(msg.sourceId, {
|
|
95
|
+
status: 'blocked',
|
|
96
|
+
blockedAccounts: msg.unknownAccounts,
|
|
97
|
+
});
|
|
98
|
+
break;
|
|
99
|
+
case 'otp-required':
|
|
100
|
+
next.set(msg.sourceId, {
|
|
101
|
+
...next.get(msg.sourceId),
|
|
102
|
+
status: 'otp-required',
|
|
103
|
+
otpSourceId: msg.sourceId,
|
|
104
|
+
});
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (msg.type === 'run-complete') {
|
|
113
|
+
setRunStatus('complete');
|
|
114
|
+
setSummary(msg);
|
|
115
|
+
} else if (msg.type === 'task-running') {
|
|
116
|
+
setRunStatus('running');
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
wsRef.current = null;
|
|
122
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
123
|
+
ws.onopen = () => ws.close();
|
|
124
|
+
} else {
|
|
125
|
+
ws.close();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
const send = useCallback((msg: ClientMessage) => {
|
|
131
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
132
|
+
wsRef.current.send(JSON.stringify(msg));
|
|
133
|
+
}
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
return { send, taskStates, runStatus, summary };
|
|
137
|
+
}
|