spendwise 1.0.0
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/LICENSE +21 -0
- package/README.md +119 -0
- package/package.json +62 -0
- package/src/main/api-core.js +231 -0
- package/src/main/auth.js +29 -0
- package/src/main/backup.js +48 -0
- package/src/main/db-open.js +77 -0
- package/src/main/ipc.js +187 -0
- package/src/main/main.js +125 -0
- package/src/main/migrate.js +181 -0
- package/src/main/storage/local.js +55 -0
- package/src/main/update-check.js +99 -0
- package/src/main/webserver.js +294 -0
- package/src/preload.cjs +27 -0
- package/src/renderer/assets/icon.ico +0 -0
- package/src/renderer/assets/icon.png +0 -0
- package/src/renderer/css/app.css +2 -0
- package/src/renderer/fonts/roca.otf +0 -0
- package/src/renderer/fonts/roca.woff +0 -0
- package/src/renderer/fonts/roca.woff2 +0 -0
- package/src/renderer/index.html +1136 -0
- package/src/renderer/js/app.js +185 -0
- package/src/renderer/js/boot.js +4 -0
- package/src/renderer/js/charts.js +116 -0
- package/src/renderer/js/components.js +39 -0
- package/src/renderer/js/helpers.js +76 -0
- package/src/renderer/js/views/insights.js +259 -0
- package/src/renderer/js/views/month.js +687 -0
- package/src/renderer/js/views/settings.js +353 -0
- package/src/renderer/js/web-api.js +50 -0
- package/src/renderer/manifest.json +18 -0
- package/src/renderer/vendor/alpine-collapse.min.js +1 -0
- package/src/renderer/vendor/alpine.min.js +5 -0
- package/src/renderer/vendor/apexcharts.min.js +38 -0
- package/src/server/cli.mjs +164 -0
- package/src/shared/engine.js +728 -0
package/src/main/ipc.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { app, ipcMain, dialog, shell, BrowserWindow } from 'electron';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { openDb, resolveDbPath, loadConfig, saveConfig, migrateLegacyUserData } from './storage/local.js';
|
|
5
|
+
import { backupDb, backupsDir } from './backup.js';
|
|
6
|
+
import { importLegacy } from './migrate.js';
|
|
7
|
+
import { createCore } from './api-core.js';
|
|
8
|
+
import { createWebServer } from './webserver.js';
|
|
9
|
+
import { RELEASES_URL } from './update-check.js';
|
|
10
|
+
import '../shared/engine.js';
|
|
11
|
+
|
|
12
|
+
const E = globalThis.FinEngine;
|
|
13
|
+
|
|
14
|
+
// Live db context shared with the api core (and the web server, which the
|
|
15
|
+
// main process starts against the same ctx - one process owns db.json).
|
|
16
|
+
export const ctx = { db: null, dbPath: null };
|
|
17
|
+
export let core = null;
|
|
18
|
+
export let webServer = null;
|
|
19
|
+
|
|
20
|
+
const ok = (extra = {}) => ({ ok: true, ...extra });
|
|
21
|
+
|
|
22
|
+
// Reverse-proxy settings resolve config-over-env: a value saved from the
|
|
23
|
+
// Settings panel wins; otherwise fall back to the FINANCES_* env vars the CLI
|
|
24
|
+
// uses; otherwise the safe default (no trusted proxy, cookie auto-detected).
|
|
25
|
+
// ?? not || so an explicit saved 0 / false isn't overridden by the env value.
|
|
26
|
+
function resolveWebProxy (config) {
|
|
27
|
+
const w = (config && config.webServer) || {};
|
|
28
|
+
const envTP = process.env.FINANCES_TRUST_PROXY;
|
|
29
|
+
const envSC = process.env.FINANCES_SECURE_COOKIE === '1' || process.env.FINANCES_SECURE_COOKIE === 'true';
|
|
30
|
+
return {
|
|
31
|
+
trustProxy: w.trustProxy ?? (envTP !== undefined ? Math.max(0, Number(envTP) || 0) : 0),
|
|
32
|
+
secureCookie: w.secureCookie ?? envSC,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const fail = (error) => ({ ok: false, error: String(error && error.message || error) });
|
|
36
|
+
|
|
37
|
+
export async function registerIpc () {
|
|
38
|
+
migrateLegacyUserData(); // must run before the config is read
|
|
39
|
+
ctx.dbPath = resolveDbPath();
|
|
40
|
+
ctx.db = await openDb(ctx.dbPath);
|
|
41
|
+
ctx.version = app.getVersion();
|
|
42
|
+
core = createCore(ctx);
|
|
43
|
+
|
|
44
|
+
const handle = (channel, fn) => ipcMain.handle(channel, async (event, payload) => {
|
|
45
|
+
try {
|
|
46
|
+
return await fn(payload || {}, event);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return fail(err);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// core-backed handlers (same handlers serve the web clients)
|
|
53
|
+
handle('db:load', async () => ok(await core.loadDb()));
|
|
54
|
+
handle('db:rev', async () => ok(core.rev()));
|
|
55
|
+
handle('db:saveMonth', async (p) => ok(await core.saveMonth(p)));
|
|
56
|
+
handle('db:saveClosedMonth', async (p) => ok(await core.saveClosedMonth(p)));
|
|
57
|
+
handle('db:closeMonth', async (p) => ok(await core.closeMonth(p)));
|
|
58
|
+
handle('db:saveSettings', async (p) => ok(await core.saveSettings(p)));
|
|
59
|
+
handle('db:apply-tags', async (p) => ok(await core.applyTagsEverywhere(p)));
|
|
60
|
+
handle('backups:list', async () => ok(await core.listBackups()));
|
|
61
|
+
handle('backups:restore', async (p) => ok(await core.restoreBackup(p)));
|
|
62
|
+
handle('db:migrate-legacy', async (p) => ok(await core.migrateLegacy(p)));
|
|
63
|
+
handle('auth:has', async () => ok(core.authHas()));
|
|
64
|
+
handle('auth:verify', async (p) => ok(core.authVerify(p)));
|
|
65
|
+
handle('auth:set', async (p) => ok(await core.authSet(p)));
|
|
66
|
+
handle('update:check', async (p) => ok(await core.checkUpdate(p)));
|
|
67
|
+
|
|
68
|
+
// Opening the release page is the ONLY external URL the app will launch,
|
|
69
|
+
// so it's pinned to the project's own releases rather than taking a URL
|
|
70
|
+
// from the renderer - a compromised page can't turn this into a launcher.
|
|
71
|
+
handle('update:open', async () => {
|
|
72
|
+
await shell.openExternal(RELEASES_URL);
|
|
73
|
+
return ok();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ------------------------------------------------------------ web access
|
|
77
|
+
|
|
78
|
+
webServer = createWebServer(ctx, core);
|
|
79
|
+
const bootConfig = loadConfig();
|
|
80
|
+
if (bootConfig.webServer && bootConfig.webServer.enabled && core.authHas().hasPassword) {
|
|
81
|
+
try {
|
|
82
|
+
await webServer.start(bootConfig.webServer.port || 4180, undefined, resolveWebProxy(bootConfig));
|
|
83
|
+
console.log('[web] serving on port ' + (bootConfig.webServer.port || 4180));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error('[web] failed to start: ' + e.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
handle('web:status', async () => {
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
return ok({
|
|
92
|
+
enabled: !!(config.webServer && config.webServer.enabled),
|
|
93
|
+
port: (config.webServer && config.webServer.port) || 4180,
|
|
94
|
+
hasPassword: core.authHas().hasPassword,
|
|
95
|
+
...webServer.status(), // running/urls/sessions
|
|
96
|
+
// config intent wins for the proxy fields: while stopped the server
|
|
97
|
+
// still holds its boot default (0), which would misreport a saved
|
|
98
|
+
// setup. When running the two agree - start() derives from the same
|
|
99
|
+
// config - so overriding here is only ever a correction, never a lie.
|
|
100
|
+
...resolveWebProxy(config),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
handle('web:set', async ({ enabled, port, trustProxy, secureCookie }) => {
|
|
105
|
+
port = Math.max(1024, Math.min(65535, E.num(port) || 4180));
|
|
106
|
+
if (enabled && !core.authHas().hasPassword) {
|
|
107
|
+
throw new Error('Set an app password first - web access requires a login.');
|
|
108
|
+
}
|
|
109
|
+
const prev = loadConfig();
|
|
110
|
+
const webCfg = { ...(prev.webServer || {}), enabled: !!enabled, port };
|
|
111
|
+
// only persist proxy fields the caller actually sent, so unrelated
|
|
112
|
+
// toggles (on/off, port) don't wipe a saved reverse-proxy setup
|
|
113
|
+
if (trustProxy !== undefined) webCfg.trustProxy = Math.max(0, Math.min(10, Math.floor(Number(trustProxy) || 0)));
|
|
114
|
+
if (secureCookie !== undefined) webCfg.secureCookie = !!secureCookie;
|
|
115
|
+
saveConfig({ ...prev, webServer: webCfg });
|
|
116
|
+
const resolved = resolveWebProxy({ webServer: webCfg });
|
|
117
|
+
await webServer.stop();
|
|
118
|
+
if (enabled) await webServer.start(port, undefined, resolved);
|
|
119
|
+
// resolved last: while web access is off the server keeps its boot
|
|
120
|
+
// default (0), which would otherwise stomp the value just saved
|
|
121
|
+
return ok({ enabled: !!enabled, port, hasPassword: core.authHas().hasPassword, ...webServer.status(), ...resolved });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ------------------------------------------- dialog-based (desktop only)
|
|
125
|
+
|
|
126
|
+
handle('backups:open-folder', async () => {
|
|
127
|
+
const dir = backupsDir(ctx.dbPath);
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
await shell.openPath(dir);
|
|
130
|
+
return ok();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
handle('db:export', async (payload, event) => {
|
|
134
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
135
|
+
const res = await dialog.showSaveDialog(win, {
|
|
136
|
+
title: 'Export a copy of your data',
|
|
137
|
+
defaultPath: 'spend-wise-export.json',
|
|
138
|
+
filters: [{ name: 'JSON', extensions: ['json'] }],
|
|
139
|
+
});
|
|
140
|
+
if (res.canceled || !res.filePath) return ok({ exported: null });
|
|
141
|
+
fs.copyFileSync(ctx.dbPath, res.filePath);
|
|
142
|
+
return ok({ exported: res.filePath });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Move the database to a new location (e.g. a synced folder).
|
|
146
|
+
handle('db:change-location', async (payload, event) => {
|
|
147
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
148
|
+
const res = await dialog.showSaveDialog(win, {
|
|
149
|
+
title: 'Choose where to keep db.json',
|
|
150
|
+
defaultPath: path.join(path.dirname(ctx.dbPath), 'db.json'),
|
|
151
|
+
filters: [{ name: 'JSON', extensions: ['json'] }],
|
|
152
|
+
});
|
|
153
|
+
const snapshot = () => ({ data: E.clone(ctx.db.data), path: ctx.dbPath });
|
|
154
|
+
if (res.canceled || !res.filePath) return ok(snapshot());
|
|
155
|
+
const newPath = res.filePath;
|
|
156
|
+
if (path.resolve(newPath) !== path.resolve(ctx.dbPath)) {
|
|
157
|
+
if (fs.existsSync(newPath)) {
|
|
158
|
+
// adopting an existing file (multi-machine case): keep it, don't overwrite
|
|
159
|
+
backupDb(ctx.dbPath, 'pre-move');
|
|
160
|
+
} else {
|
|
161
|
+
fs.mkdirSync(path.dirname(newPath), { recursive: true });
|
|
162
|
+
fs.copyFileSync(ctx.dbPath, newPath);
|
|
163
|
+
}
|
|
164
|
+
saveConfig({ ...loadConfig(), dbPath: newPath });
|
|
165
|
+
ctx.dbPath = newPath;
|
|
166
|
+
ctx.db = await openDb(ctx.dbPath);
|
|
167
|
+
}
|
|
168
|
+
return ok(snapshot());
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Phase 1 of legacy import: pick the folder and report the groups found,
|
|
172
|
+
// so the user can adjust each group's kind before anything is written.
|
|
173
|
+
handle('db:migrate-scan', async (payload, event) => {
|
|
174
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
175
|
+
const res = await dialog.showOpenDialog(win, {
|
|
176
|
+
title: 'Select the legacy data folder (contains files like "january-26")',
|
|
177
|
+
properties: ['openDirectory'],
|
|
178
|
+
});
|
|
179
|
+
if (res.canceled || !res.filePaths.length) return ok({ scanned: false });
|
|
180
|
+
const { data } = importLegacy(res.filePaths[0]);
|
|
181
|
+
return ok({
|
|
182
|
+
scanned: true,
|
|
183
|
+
folder: res.filePaths[0],
|
|
184
|
+
groups: data.settings.groups.map((g) => ({ title: g.title, kind: g.kind })),
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
package/src/main/main.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { app, BrowserWindow, dialog } from 'electron';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { registerIpc } from './ipc.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Closing with edits in flight would drop them silently — nothing is written
|
|
11
|
+
* until Update. This has to live here rather than in the renderer: Electron
|
|
12
|
+
* does not prompt for beforeunload, it just cancels the close without telling
|
|
13
|
+
* anyone. So intercept, ask the page what's unsaved, and confirm natively.
|
|
14
|
+
*/
|
|
15
|
+
function guardClose (win) {
|
|
16
|
+
let allowClose = false;
|
|
17
|
+
// tests exit unattended, so the gate is off under FINANCES_E2E — unless
|
|
18
|
+
// FINANCES_CLOSE_ANSWER names the button to auto-click, which is how the
|
|
19
|
+
// gate itself gets tested without leaving a modal on screen
|
|
20
|
+
const autoAnswer = process.env.FINANCES_E2E && process.env.FINANCES_CLOSE_ANSWER;
|
|
21
|
+
|
|
22
|
+
win.on('close', (e) => {
|
|
23
|
+
if (allowClose || (process.env.FINANCES_E2E && !autoAnswer)) return;
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
win.webContents
|
|
26
|
+
.executeJavaScript('window.__unsavedSummary ? window.__unsavedSummary() : null')
|
|
27
|
+
.catch(() => null) // a broken page must never trap the user inside the app
|
|
28
|
+
.then(async (unsaved) => {
|
|
29
|
+
if (!unsaved) { allowClose = true; win.close(); return; }
|
|
30
|
+
const { response } = autoAnswer
|
|
31
|
+
? { response: autoAnswer === 'discard' ? 0 : 1 }
|
|
32
|
+
: await dialog.showMessageBox(win, {
|
|
33
|
+
type: 'warning',
|
|
34
|
+
buttons: ['Discard & quit', 'Keep editing'],
|
|
35
|
+
defaultId: 1,
|
|
36
|
+
cancelId: 1,
|
|
37
|
+
title: 'Unsaved changes',
|
|
38
|
+
message: `Discard unsaved changes to ${unsaved}?`,
|
|
39
|
+
detail: 'They have not been written to your database yet. Closing now loses them.',
|
|
40
|
+
});
|
|
41
|
+
if (response === 0) { allowClose = true; win.close(); }
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createWindow () {
|
|
47
|
+
const win = new BrowserWindow({
|
|
48
|
+
width: 1500,
|
|
49
|
+
height: 960,
|
|
50
|
+
minWidth: 900,
|
|
51
|
+
minHeight: 600,
|
|
52
|
+
icon: path.join(__dirname, '../renderer/assets/icon.ico'),
|
|
53
|
+
backgroundColor: '#fafafa',
|
|
54
|
+
webPreferences: {
|
|
55
|
+
preload: path.join(__dirname, '../preload.cjs'),
|
|
56
|
+
contextIsolation: true,
|
|
57
|
+
nodeIntegration: false,
|
|
58
|
+
sandbox: true,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
win.removeMenu();
|
|
62
|
+
guardClose(win);
|
|
63
|
+
win.loadFile(path.join(__dirname, '../renderer/index.html'));
|
|
64
|
+
if (process.env.FINANCES_DEVTOOLS) win.webContents.openDevTools({ mode: 'detach' });
|
|
65
|
+
if (process.env.FINANCES_LOG_CONSOLE) {
|
|
66
|
+
win.webContents.on('console-message', (e, level, message, line, sourceId) => {
|
|
67
|
+
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (process.env.FINANCES_SCREENSHOT) {
|
|
71
|
+
win.webContents.once('did-finish-load', () => {
|
|
72
|
+
setTimeout(async () => {
|
|
73
|
+
const image = await win.webContents.capturePage();
|
|
74
|
+
fs.writeFileSync(process.env.FINANCES_SCREENSHOT, image.toPNG());
|
|
75
|
+
console.log('[screenshot] written to ' + process.env.FINANCES_SCREENSHOT);
|
|
76
|
+
}, 2000);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (process.env.FINANCES_E2E) {
|
|
80
|
+
win.webContents.setBackgroundThrottling(false); // keep painting for capturePage
|
|
81
|
+
win.webContents.once('did-finish-load', () => {
|
|
82
|
+
setTimeout(async () => {
|
|
83
|
+
const script = fs.readFileSync(process.env.FINANCES_E2E, 'utf8');
|
|
84
|
+
let dbg = null;
|
|
85
|
+
if (process.env.FINANCES_PROFILE) {
|
|
86
|
+
dbg = win.webContents.debugger;
|
|
87
|
+
dbg.attach('1.3');
|
|
88
|
+
await dbg.sendCommand('Profiler.enable');
|
|
89
|
+
await dbg.sendCommand('Profiler.start');
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const result = await win.webContents.executeJavaScript(script);
|
|
93
|
+
console.log('[e2e] ' + JSON.stringify(result, null, 2));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.log('[e2e:error] ' + (err && err.message || err));
|
|
96
|
+
}
|
|
97
|
+
if (dbg) {
|
|
98
|
+
const { profile } = await dbg.sendCommand('Profiler.stop');
|
|
99
|
+
fs.writeFileSync(process.env.FINANCES_PROFILE, JSON.stringify(profile));
|
|
100
|
+
console.log('[profile] written to ' + process.env.FINANCES_PROFILE);
|
|
101
|
+
}
|
|
102
|
+
if (process.env.FINANCES_E2E_SCREENSHOT) {
|
|
103
|
+
const image = await win.webContents.capturePage();
|
|
104
|
+
fs.writeFileSync(process.env.FINANCES_E2E_SCREENSHOT, image.toPNG());
|
|
105
|
+
console.log('[screenshot] written to ' + process.env.FINANCES_E2E_SCREENSHOT);
|
|
106
|
+
}
|
|
107
|
+
app.quit();
|
|
108
|
+
}, 2500);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return win;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
app.whenReady().then(async () => {
|
|
115
|
+
await registerIpc();
|
|
116
|
+
createWindow();
|
|
117
|
+
|
|
118
|
+
app.on('activate', () => {
|
|
119
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.on('window-all-closed', () => {
|
|
124
|
+
if (process.platform !== 'darwin') app.quit();
|
|
125
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import '../shared/engine.js';
|
|
4
|
+
|
|
5
|
+
const E = globalThis.FinEngine;
|
|
6
|
+
|
|
7
|
+
const MONTH_NUM = {
|
|
8
|
+
january: '01', february: '02', march: '03', april: '04',
|
|
9
|
+
may: '05', june: '06', july: '07', august: '08',
|
|
10
|
+
september: '09', october: '10', november: '11', december: '12',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const KIND_MAP = {
|
|
14
|
+
income: 'income',
|
|
15
|
+
budgets: 'envelope',
|
|
16
|
+
expenses: 'expense',
|
|
17
|
+
miscBudgets: 'goal',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Import a legacy old-finances-app data directory into a fresh db shape.
|
|
22
|
+
*
|
|
23
|
+
* Legacy layout: one JSON file per month named `<monthname>-<yy>` containing
|
|
24
|
+
* `{ form: { data: [groups], savings } }`, plus a `savings_history` file
|
|
25
|
+
* keyed like "June '18". Field ids are 13-char uniqids reused across months
|
|
26
|
+
* for pinned fields - we map each legacy id to one uuid so cross-month
|
|
27
|
+
* identity (and budget links) survive.
|
|
28
|
+
*
|
|
29
|
+
* kindOverrides maps a legacy group TITLE to a kind, overriding the default
|
|
30
|
+
* type mapping everywhere that group appears.
|
|
31
|
+
*/
|
|
32
|
+
export function importLegacy (dataDir, kindOverrides = {}) {
|
|
33
|
+
const files = fs.readdirSync(dataDir);
|
|
34
|
+
const idMap = new Map();
|
|
35
|
+
const mapId = (oldId) => {
|
|
36
|
+
if (!oldId) return null;
|
|
37
|
+
if (!idMap.has(oldId)) idMap.set(oldId, E.uuid());
|
|
38
|
+
return idMap.get(oldId);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const groupDefs = [];
|
|
42
|
+
const defFor = (title, mappedKind) => {
|
|
43
|
+
let def = groupDefs.find((d) => d.title === title);
|
|
44
|
+
if (!def) {
|
|
45
|
+
const kind = kindOverrides[title] || mappedKind;
|
|
46
|
+
def = { id: E.uuid(), title, kind, order: groupDefs.length };
|
|
47
|
+
if (E.isEnvelopeKind(kind)) def.strictOverspend = false;
|
|
48
|
+
groupDefs.push(def);
|
|
49
|
+
}
|
|
50
|
+
return def;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const months = {};
|
|
54
|
+
let parsed = 0;
|
|
55
|
+
const warnings = [];
|
|
56
|
+
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const m = /^([a-z]+)-(\d{2})$/.exec(file);
|
|
59
|
+
if (!m || !MONTH_NUM[m[1]]) continue;
|
|
60
|
+
const key = `20${m[2]}-${MONTH_NUM[m[1]]}`;
|
|
61
|
+
|
|
62
|
+
let json;
|
|
63
|
+
try {
|
|
64
|
+
json = JSON.parse(fs.readFileSync(path.join(dataDir, file), 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
warnings.push(`${file}: unreadable JSON, skipped`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const form = json && json.form;
|
|
70
|
+
if (!form || !Array.isArray(form.data)) {
|
|
71
|
+
warnings.push(`${file}: no form data, skipped`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const month = {
|
|
76
|
+
key,
|
|
77
|
+
status: 'closed',
|
|
78
|
+
startingSavings: E.num(form.savings),
|
|
79
|
+
closingSavings: null,
|
|
80
|
+
groups: [],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const legacyGroup of form.data) {
|
|
84
|
+
const def = defFor(legacyGroup.title || 'Untitled', KIND_MAP[legacyGroup.type] || 'expense');
|
|
85
|
+
const kind = def.kind; // may differ from the legacy type via overrides
|
|
86
|
+
const group = { groupId: def.id, title: def.title, kind, fields: [] };
|
|
87
|
+
|
|
88
|
+
for (const lf of legacyGroup.fields || []) {
|
|
89
|
+
const field = {
|
|
90
|
+
id: mapId(lf.id) || E.uuid(),
|
|
91
|
+
label: lf.label || '',
|
|
92
|
+
value: E.num(lf.value),
|
|
93
|
+
pinned: E.truthy(lf.default),
|
|
94
|
+
accounted: E.truthy(lf.accounted),
|
|
95
|
+
tags: [],
|
|
96
|
+
};
|
|
97
|
+
if (kind === 'expense') field.budgetId = mapId(lf.budget);
|
|
98
|
+
if (E.isEnvelopeKind(kind)) {
|
|
99
|
+
field.avail = E.num(lf.avail);
|
|
100
|
+
field.spent = E.num(lf.spent);
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'goal') field.target = null;
|
|
103
|
+
group.fields.push(field);
|
|
104
|
+
}
|
|
105
|
+
month.groups.push(group);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Legacy 'budgets' stored spent as the DERIVED sum of linked expenses;
|
|
109
|
+
// our model stores only direct/manual spending and derives the linked
|
|
110
|
+
// part live - strip it so it isn't counted twice.
|
|
111
|
+
for (const group of month.groups) {
|
|
112
|
+
if (!E.isEnvelopeKind(group.kind)) continue;
|
|
113
|
+
for (const field of group.fields) {
|
|
114
|
+
field.spent = Math.max(0, E.num(field.spent) - E.linkedSpent(month, field.id));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
months[key] = month;
|
|
119
|
+
parsed += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!parsed) throw new Error('No legacy month files found in that folder.');
|
|
123
|
+
|
|
124
|
+
// most recent month stays open; every closed month gets a derived closing figure
|
|
125
|
+
const keys = Object.keys(months).sort();
|
|
126
|
+
months[keys[keys.length - 1]].status = 'open';
|
|
127
|
+
for (const key of keys) {
|
|
128
|
+
if (months[key].status === 'closed') {
|
|
129
|
+
months[key].closingSavings = E.savings(months[key]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// savings_history: { "June '18": "3800" } → { "2018-06": 3800 }
|
|
134
|
+
const savingsHistory = {};
|
|
135
|
+
try {
|
|
136
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dataDir, 'savings_history'), 'utf8'));
|
|
137
|
+
for (const [label, value] of Object.entries(raw)) {
|
|
138
|
+
const hm = /^([A-Za-z]+) '(\d{2})$/.exec(label.trim());
|
|
139
|
+
if (!hm) continue;
|
|
140
|
+
const mm = MONTH_NUM[hm[1].toLowerCase()];
|
|
141
|
+
if (!mm) continue;
|
|
142
|
+
savingsHistory[`20${hm[2]}-${mm}`] = E.num(value);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
warnings.push('savings_history missing or unreadable - rebuilt from month files');
|
|
146
|
+
}
|
|
147
|
+
// month files are authoritative where both exist
|
|
148
|
+
for (const key of keys) savingsHistory[key] = E.num(months[key].startingSavings);
|
|
149
|
+
|
|
150
|
+
// Only groups still present in the latest month become the go-forward
|
|
151
|
+
// settings; groups the user renamed/retired years ago live on inside the
|
|
152
|
+
// month snapshots (months are self-describing) without cluttering new months.
|
|
153
|
+
const latest = months[keys[keys.length - 1]];
|
|
154
|
+
const activeGroups = latest.groups.map((g, i) => {
|
|
155
|
+
const def = groupDefs.find((d) => d.id === g.groupId);
|
|
156
|
+
return { ...def, order: i };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
data: {
|
|
161
|
+
meta: { schemaVersion: 1, rev: 0 },
|
|
162
|
+
settings: {
|
|
163
|
+
currency: 'USD',
|
|
164
|
+
initialSavings: E.num(months[keys[0]].startingSavings),
|
|
165
|
+
groups: activeGroups,
|
|
166
|
+
},
|
|
167
|
+
months,
|
|
168
|
+
savingsHistory,
|
|
169
|
+
},
|
|
170
|
+
summary: {
|
|
171
|
+
months: parsed,
|
|
172
|
+
firstMonth: keys[0],
|
|
173
|
+
lastMonth: keys[keys.length - 1],
|
|
174
|
+
groups: activeGroups.map((g) => `${g.title} (${g.kind})`),
|
|
175
|
+
retiredGroups: groupDefs.filter((d) => !activeGroups.some((a) => a.id === d.id))
|
|
176
|
+
.map((g) => `${g.title} (${g.kind})`),
|
|
177
|
+
historyEntries: Object.keys(savingsHistory).length,
|
|
178
|
+
warnings,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { app } from 'electron';
|
|
4
|
+
export { openDb, currentMonthKey } from '../db-open.js';
|
|
5
|
+
|
|
6
|
+
const configPath = () => path.join(app.getPath('userData'), 'config.json');
|
|
7
|
+
|
|
8
|
+
// The app was called "Finances" before 1.1; Electron derives userData from the
|
|
9
|
+
// app name, so the rename would strand the old folder's database, settings and
|
|
10
|
+
// backups. Adopt them once, on first run of the renamed app.
|
|
11
|
+
const LEGACY_APP_NAME = 'Finances';
|
|
12
|
+
|
|
13
|
+
export function migrateLegacyUserData () {
|
|
14
|
+
const current = app.getPath('userData');
|
|
15
|
+
const legacy = path.join(path.dirname(current), LEGACY_APP_NAME);
|
|
16
|
+
if (path.resolve(legacy) === path.resolve(current) || !fs.existsSync(legacy)) return null;
|
|
17
|
+
// only ever adopt into a folder that has no data of its own
|
|
18
|
+
if (fs.existsSync(path.join(current, 'db.json')) || fs.existsSync(path.join(current, 'config.json'))) return null;
|
|
19
|
+
|
|
20
|
+
const copied = [];
|
|
21
|
+
fs.mkdirSync(current, { recursive: true });
|
|
22
|
+
for (const name of ['config.json', 'db.json']) {
|
|
23
|
+
const from = path.join(legacy, name);
|
|
24
|
+
if (fs.existsSync(from)) { fs.copyFileSync(from, path.join(current, name)); copied.push(name); }
|
|
25
|
+
}
|
|
26
|
+
const legacyBackups = path.join(legacy, 'backups');
|
|
27
|
+
if (fs.existsSync(legacyBackups)) {
|
|
28
|
+
// the user's history lives here - Settings → Backups must still find it
|
|
29
|
+
fs.cpSync(legacyBackups, path.join(current, 'backups'), { recursive: true });
|
|
30
|
+
copied.push(`backups/ (${fs.readdirSync(legacyBackups).length} files)`);
|
|
31
|
+
}
|
|
32
|
+
if (!copied.length) return null;
|
|
33
|
+
// copy, never move: the old folder stays as a safety net
|
|
34
|
+
console.log(`[migrate] adopted data from "${LEGACY_APP_NAME}": ${copied.join(', ')}`);
|
|
35
|
+
return { from: legacy, to: current, copied };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadConfig () {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveConfig (config) {
|
|
47
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
|
48
|
+
fs.writeFileSync(configPath(), JSON.stringify(config, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveDbPath () {
|
|
52
|
+
if (process.env.FINANCES_DB_PATH) return process.env.FINANCES_DB_PATH; // tests
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
return config.dbPath || path.join(app.getPath('userData'), 'db.json');
|
|
55
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Update check against the GitHub Releases API.
|
|
3
|
+
*
|
|
4
|
+
* Privacy: this is the ONLY outbound request the app ever makes, and it is a
|
|
5
|
+
* plain unauthenticated GET of a public URL - no query string, no body, no
|
|
6
|
+
* identifier, nothing about the user or their finances. GitHub sees an IP and
|
|
7
|
+
* a timestamp, the same as opening the releases page in a browser. It is off
|
|
8
|
+
* with one toggle (Settings → updateCheck: false) and silently skipped when
|
|
9
|
+
* offline, so the app still runs with the network unplugged.
|
|
10
|
+
*
|
|
11
|
+
* It runs in the main/server process, never the renderer: one check per
|
|
12
|
+
* install rather than one per open browser tab, and the renderer's CSP does
|
|
13
|
+
* not have to allow an external origin.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const REPO = 'blindmikey/spendwise';
|
|
18
|
+
const ENDPOINT = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
19
|
+
const RELEASES_URL = `https://github.com/${REPO}/releases/latest`;
|
|
20
|
+
|
|
21
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // a long-running server checks once a day
|
|
22
|
+
const TIMEOUT_MS = 5000; // never let a hung request stall the UI
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* "v1.2.3" / "1.2.3" / "1.2.3-beta.1" → { parts:[1,2,3], pre:'beta.1'|null }.
|
|
26
|
+
* Returns null for anything unparseable, so a garbage tag can never be read
|
|
27
|
+
* as an update.
|
|
28
|
+
*/
|
|
29
|
+
export function parseVersion (v) {
|
|
30
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim());
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is `latest` a newer release than `current`? Unparseable → false (never nag). */
|
|
36
|
+
export function isNewer (latest, current) {
|
|
37
|
+
const a = parseVersion(latest);
|
|
38
|
+
const b = parseVersion(current);
|
|
39
|
+
if (!a || !b) return false;
|
|
40
|
+
for (let i = 0; i < 3; i++) {
|
|
41
|
+
if (a.parts[i] > b.parts[i]) return true;
|
|
42
|
+
if (a.parts[i] < b.parts[i]) return false;
|
|
43
|
+
}
|
|
44
|
+
// same x.y.z: a prerelease is OLDER than its final, and we never offer to
|
|
45
|
+
// "update" someone from a final onto a prerelease
|
|
46
|
+
if (a.pre && !b.pre) return false;
|
|
47
|
+
if (!a.pre && b.pre) return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `fetchImpl` / `now` are injectable so tests never touch the network.
|
|
53
|
+
*/
|
|
54
|
+
export function createUpdateChecker ({ currentVersion, fetchImpl, now } = {}) {
|
|
55
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
56
|
+
const clock = now || (() => Date.now());
|
|
57
|
+
let cache = null; // { at, result }
|
|
58
|
+
|
|
59
|
+
async function fetchLatest () {
|
|
60
|
+
// AbortSignal.timeout needs node 17.3+; we support 18+
|
|
61
|
+
const res = await doFetch(ENDPOINT, {
|
|
62
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'spendwise' },
|
|
63
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw new Error(`GitHub returned ${res.status}`);
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
const tag = body && (body.tag_name || body.name);
|
|
68
|
+
if (!parseVersion(tag)) throw new Error('No usable release tag');
|
|
69
|
+
return { tag: String(tag), url: (body && body.html_url) || RELEASES_URL };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
/** `force` bypasses the cache (the Settings "Check now" button). */
|
|
74
|
+
async check ({ force = false } = {}) {
|
|
75
|
+
if (!force && cache && clock() - cache.at < TTL_MS) return cache.result;
|
|
76
|
+
|
|
77
|
+
const base = { current: currentVersion || '', checkedAt: clock() };
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
const { tag, url } = await fetchLatest();
|
|
81
|
+
result = {
|
|
82
|
+
...base,
|
|
83
|
+
latest: tag,
|
|
84
|
+
url,
|
|
85
|
+
available: isNewer(tag, currentVersion),
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// offline, rate-limited, no releases yet, DNS blocked: all the
|
|
89
|
+
// same non-event. Never surfaced as an error the user must act
|
|
90
|
+
// on - the app does not need GitHub to work.
|
|
91
|
+
result = { ...base, latest: null, url: RELEASES_URL, available: false, unreachable: true };
|
|
92
|
+
}
|
|
93
|
+
cache = { at: clock(), result };
|
|
94
|
+
return result;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { RELEASES_URL };
|