Track your finances easily: categorize transaction data quickly via "smart" inverted index, then define your own charts and reports.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

652 lines
21 KiB

// Modules to control application life and create native browser window
const {app, ipcMain, dialog, BrowserWindow, Menu, MenuItem } = require('electron')
const level = require('level')
const path = require('path')
const open = require('open')
const fs = require('mz/fs')
const {VM} = require('vm2')
const zlib = require('zlib')
const uuid = require('uuid/v4')
const { translations } = require('./strings')
const settings = require('./settings')
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow
let db
const locale = app.getLocale();
const simpleLocale = locale.split('-')[0];
const keyPrefixFileMetadata = '00:';
const keyFileMetadata = x => `${keyPrefixFileMetadata}${x}`;
const firstDatabaseId = 2;
let strings;
if(translations[locale]) {
strings = translations[locale];
} else if(translations[simpleLocale]) {
strings = translations[simpleLocale];
} else {
strings = translations['en']
}
// This is needed to avoid Electron logging a message every time one of these promises is rejected :(
function wrapError(doTheThing) {
// electron IPC cant pass null because it serializes everything as JSON and the serializer strips null :(
const errorToErrorResult = (error) => {
let stack = error != null ? error.stack : null;
let errorString = stack ? stack : (typeof error.toString == 'function' ? error.toString() : String(error));
return {
error: error == null ? '__electron-ipc-wrapped-null__' : error,
errorString: errorString
}
}
try {
return doTheThing().then(
result => {
return {result: result == null ? '__electron-ipc-wrapped-null__' : result}
},
errorToErrorResult
);
} catch (error) {
return Promise.resolve(errorToErrorResult(error))
}
}
const getAppSupportFolder = () => {
let folder;
if(process.platform == 'darwin') {
folder = path.resolve(process.env.HOME, 'Library/Application Support/LibreBookKeeper')
} else if(process.platform == 'win32') {
folder = path.resolve(process.env.APPDATA, 'LibreBookKeeper')
} else {
folder = path.resolve(process.env.HOME, '.local/share/LibreBookKeeper')
}
return folder;
};
const logFile = `${getAppSupportFolder()}/LibreBookKeeper.log`;
let logStream = null;
let logStreamOpen = false;
let myLog = (message) => {
console.log(message);
};
fs.mkdir(getAppSupportFolder(), {recursive: true, mode: 0744}).then(
(_) => false,
(err) => err.code == 'EEXIST' ? false : true
).then(mightHaveFailed => {
if(!mightHaveFailed) {
logStream = fs.createWriteStream(logFile, {flags:'a'});
logStreamOpen = true;
myLog = (message) => {
logStream.write(`\n${new Date().toISOString()} ${message}`);
console.log(message);
};
myLog("log file opened");
}
});
let currentDBPrefix = '';
let currentOpenFilePath = '';
let currentDatabaseId = '';
const setDatabaseOpened = (filePath, databaseId) => {
currentOpenFilePath = filePath;
currentDatabaseId = databaseId;
// prevent currentDBPrefix from ever getting to be more than two digits.
databaseId = databaseId % (36*36);
currentDBPrefix = `${databaseId.toString(36)}:`;
if(currentDBPrefix.length == 2) {
currentDBPrefix = `0${currentDBPrefix}`;
}
myLog(`setDatabaseOpened: filePath=${filePath} databaseId=${databaseId} dbPrefix=${currentDBPrefix}`);
};
ipcMain.handle('db-get', (event, key) => wrapError(() => db.get(`${currentDBPrefix}${key}`)));
ipcMain.handle('db-put', (event, key, value) => wrapError(() => db.put(`${currentDBPrefix}${key}`, value)));
ipcMain.handle('db-del', (event, key) => wrapError(() => db.del(`${currentDBPrefix}${key}`)));
ipcMain.handle('db-batch', (event, batch) => wrapError(() => {
const batchWithPrefixedKeys = batch.map(x => {
x.key = `${currentDBPrefix}${x.key}`;
return x;
});
return db.batch(batchWithPrefixedKeys);
}));
const dbRange = (options) => new Promise((resolve, reject) => {
const results = [];
if(!settings.isProduction) {
myLog(`dbRange ${JSON.stringify(options)}`);
}
db.createReadStream(options)
.on('data', function (data) {
// Make sure to remove the currentDBPrefix from the key here because the app doesnt need (or want) to know it exists
data.key = data.key.replace(RegExp(`^${currentDBPrefix}`), '');
// const json = JSON.stringify(data.value);
// console.log([data.key, ' ==> ' , json.substring(0, json.length > 30 ? 30 : json.length)].join(''));
results.push(data);
})
.on('error', function (err) {
reject(err);
})
.on('end', function () {
// const foo = results.map(kv => {
// const json = JSON.stringify(kv.value);
// return [kv.key, ' ==> ' , json.substring(0, json.length > 30 ? 30 : json.length)].join('');
// }).join('\n');
resolve(results);
});
});
ipcMain.handle('db-range', (event, options) => {
if(options.gt != null) {
options.gt = `${currentDBPrefix}${options.gt}`;
}
if(options.lt != null) {
options.lt = `${currentDBPrefix}${options.lt}`;
}
if(options.gte != null) {
options.gte = `${currentDBPrefix}${options.gte}`;
}
if(options.lte != null) {
options.lte = `${currentDBPrefix}${options.lte}`;
}
return wrapError(() => dbRange(options));
});
// ipcMain.handle('db-put-raw', (event, key, value) => wrapError(() => db.put(`${key}`, value)));
// ipcMain.handle('db-range-all', (event, options) => {
// return wrapError(() => dbRange(options));
// });
ipcMain.handle('vm-exec', (event, javascriptString) => {
return new Promise((resolve, reject) => {
try {
const result = new VM({
timeout: 1000,
sandbox: {}
}).run(javascriptString);
resolve(result);
} catch (err) {
reject(err)
}
});
});
ipcMain.handle('open-database', () => {
const leveldbFolder = `${getAppSupportFolder()}/leveldb`;
return new Promise((resolve, reject) => {
fs.access(leveldbFolder, fs.constants.F_OK | fs.constants.W_OK).then(
(_) => {
return null;
},
err => {
if(err.code !== 'ENOENT') {
const fsAccess = `fs.access${err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`}`;
return strings.errorMessage_XReturnedY(fsAccess, `"${err.message}"`);
}
return null;
}
).then((accessError) => {
if(accessError) {
return accessError;
}
return fs.mkdir(leveldbFolder, {recursive: true, mode: 0744}).then(
(_) => {
return null;
},
(err) => {
if(err.code != 'EEXIST') {
const fsMkdir = `fs.mkdir${err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`}`;
return strings.errorMessage_XReturnedY(fsMkdir, `"${err.message}"`);
}
}
);
}).then((error) => {
if(error) {
reject(error);
} else {
level(leveldbFolder, {valueEncoding: 'json'}, (err, newDb) => {
if(err) {
const pathIfNotThereAlready = err.message.includes(leveldbFolder) ? '' : `("${leveldbFolder}")`;
reject(strings.errorMessage_XReturnedY(`leveldb${pathIfNotThereAlready}`, `"${err.message}"`));
} else {
myLog(`LibreBookKeeper leveldb instance opened at ${leveldbFolder}`);
db = newDb;
dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => {
Promise.all(fileMetadataKvs.map(
x => fs.access(x.value.path, fs.constants.F_OK | fs.constants.W_OK)
.then((_) => x.value, (_) => null)
)).then(results => {
const notNullFileNames = results.filter(x => x != null);
const uniqueFileNames = Object.values(
notNullFileNames.reduce((accum, x) => {
if(!accum[x.path] || accum[x.path].lastModified < x.lastModified) {
accum[x.path] = x;
}
return accum;
}, {})
);
uniqueFileNames.sort((a, b) => a.lastModified < b.lastModified ? 1 : (a.lastModified > b.lastModified ? -1 : 0));
resolve({previouslyOpenedFiles: uniqueFileNames.slice(0, 3).map(x => x.path)});
});
});
}
});
}
});
});
});
ipcMain.on('close-database-and-quit', () => {
myLog('close-database-and-quit');
if(logStreamOpen) {
logStream.end();
logStreamOpen = false;
}
if(db) {
db.close()
db = null;
}
app.quit();
setTimeout(() => {
process.exit(1);
}, 2000);
});
const myGzip = {
compress: (buffer) => new Promise((resolve, reject) => {
zlib.gzip(buffer, (err, result) => {
if(err) {
reject(err);
} else {
resolve(result);
}
});
}),
decompress: (buffer) => new Promise((resolve, reject) => {
zlib.gunzip(buffer, (err, result) => {
if(err) {
reject(err);
} else {
resolve(result);
}
});
})
};
const readFile = (filePath, resolve, reject) => {
currentDBPrefix = '';
currentOpenFilePath = '';
currentDatabaseId = '';
fs.readFile(filePath).then(
fileContentsGzip => {
myGzip.decompress(fileContentsGzip).then(
(fileContents) => {
let file;
try {
file = JSON.parse(fileContents);
// if(file.db && file.db[0].key) {
// file.db = file.db.map(kv => [ kv.key, kv.value ]);
// }
// console.log(JSON.stringify(file, null, " ").substring(0,1000));
//
} catch (err) {
const jsonParse = `JSON.parse("${filePath}")`;
reject(strings.errorMessage_XReturnedY(jsonParse, `"${err.message}".`));
return;
}
dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => {
const metadataKv = fileMetadataKvs.filter(x => x.value.origin == file.origin)[0];
let databaseId;
let syncNeeded;
let writeMetadataPromise;
if(!metadataKv) {
databaseId = fileMetadataKvs.reduce(
(accum, x) => accum < x.value.databaseId ? x.value.databaseId : accum,
firstDatabaseId
)+1;
myLog(`${file.origin} (${filePath}) was not found in keyPrefixFileMetadata. `);
myLog(`Syncing NEW database (id=${databaseId}) with file.`);
syncNeeded = true;
writeMetadataPromise = db.put(keyFileMetadata(file.origin), {
origin: file.origin,
version: file.version,
lastModified: new Date().getTime(),
path: filePath,
databaseId: databaseId
});
} else {
databaseId = metadataKv.value.databaseId || file.databaseId;
syncNeeded = metadataKv.value.version != file.version;
if (metadataKv.value.path != filePath || file.path != filePath || databaseId != metadataKv.value.databaseId) {
file.path = filePath;
writeMetadataPromise = db.put(keyFileMetadata(file.origin), {
origin: file.origin,
version: file.version,
lastModified: file.lastModified,
path: filePath,
databaseId: databaseId
});
} else {
writeMetadataPromise = Promise.resolve();
}
if(syncNeeded) {
myLog(`${file.origin} (${filePath}) has changed since the last time I remember opening it.`);
myLog(`Syncing existing database (id=${databaseId}) with file.`);
} else {
myLog(`${file.origin} (${filePath}) has not changed since the last time I remember opening it.`);
}
}
writeMetadataPromise.then(
() => {
setDatabaseOpened(filePath, databaseId);
if(syncNeeded) {
new Promise((resolve, reject) => {
const batch = [];
db.createKeyStream({
gt: currentDBPrefix,
lt: currentDBPrefix + '\xff'
})
.on('data', function (key) {
batch.push({type: 'del', key: key});
})
.on('error', function (err) {
reject(err);
})
.on('end', function () {
myLog(`deleting all keys for databaseId=${databaseId} (dbPrefix=${currentDBPrefix})`);
db.batch(batch).then( resolve, reject );
});
}).then(
() => {
//console.log(JSON.stringify(file.db, null, " "))
const rewriteFileBatch = file.db.map(tuple => ({
type: 'put',
key: `${currentDBPrefix}${tuple[0]}`,
value: tuple[1]
}));
myLog(`copying keys from ${file.origin} (${filePath}) into databaseId=${databaseId} (dbPrefix=${currentDBPrefix}) `);
db.batch(rewriteFileBatch).then(
() => {
myLog(`copying keys completed.`)
resolve({recreateSearchIndex: true});
},
reject
);
},
reject
);
} else {
resolve({recreateSearchIndex: false});
}
},
reject
);
});
},
(err) => {
const zlibUnzip = `gzip.decompress("${filePath}")`;
reject(strings.errorMessage_XReturnedY(zlibUnzip, `"${err.message}".`));
}
);
},
err => {
const readFile = `fs.readFile${err.message.includes(filePath) ? '' : `("${filePath}")`}`;
reject(strings.errorMessage_XReturnedY(readFile, `"${err.message}"`));
}
);
};
const writeFile = (filePath, file, resolve, reject) => {
dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => {
let databaseId = fileMetadataKvs.filter(x => x.value.origin == file.origin).map(x => x.value.databaseId)[0];
if(databaseId == null) {
databaseId = fileMetadataKvs.reduce(
(accum, x) => accum < x.value.databaseId ? x.value.databaseId : accum,
firstDatabaseId
)+1;
}
file.version = uuid();
file.databaseId = databaseId;
setDatabaseOpened(filePath, databaseId);
db.put(keyFileMetadata(file.origin), {
origin: file.origin,
version: file.version,
lastModified: new Date().getTime(),
path: filePath,
databaseId: databaseId
}).then(
() => {
myGzip.compress(JSON.stringify(file)).then(
fileContentsGzip => {
fs.writeFile(filePath, fileContentsGzip, { mode: 0744 }).then(
(_) => {
resolve({recreateSearchIndex: false});
},
err => {
const writeFile = `fs.writeFile${err.message.includes(filePath) ? '' : `("${filePath}")`}`;
reject(strings.errorMessage_XReturnedY(writeFile, `"${err.message}"`));
}
);
},
err => {
reject(strings.errorMessage_XReturnedY('gzip.compress', `"${err.message}".`));
}
);
},
reject
);
});
};
ipcMain.handle('request-file', (event, mode, filePath) => {
if(!filePath) {
if(mode == 'open') {
filePath = dialog.showOpenDialogSync({
title: strings.title_OpenAnLBKFile,
properties: ['openFile']
});
}
if(mode == 'new') {
filePath = dialog.showSaveDialogSync({
title: strings.title_WhereToSaveYourFile,
defaultPath: strings.defaultFileName
});
}
}
if(Array.isArray(filePath)) {
filePath = filePath[0];
}
return new Promise((resolve, reject) => {
if(filePath != null) {
fs.access(filePath, fs.constants.F_OK | fs.constants.W_OK).then(
(_) => {
return null;
},
err => {
if(mode == 'open' || err.code !== 'ENOENT') {
const fsAccess = `fs.access${err.message.includes(filePath) ? '' : `("${filePath}")`}`;
return strings.errorMessage_XReturnedY(fsAccess, `"${err.message}"`);
}
return null;
}
).then(error => {
if(error) {
reject(error);
} else {
if(mode == 'open') {
readFile(filePath, resolve, reject);
} else {
const newFileMetadata = {
origin: uuid(),
db: [],
};
writeFile(filePath, newFileMetadata, resolve, reject);
}
}
});
} else {
reject('cancelled_symbol');
}
});
});
ipcMain.handle('auto-save', (event) => {
return wrapError(
() => dbRange({gt: keyPrefixFileMetadata, lt: keyPrefixFileMetadata + '\xff'}).then(fileMetadataKvs => {
if((typeof currentDatabaseId) != 'number') {
throw new Error(`Got auto-save when currentDatabaseId was not set.`);
}
const matchingKvs = fileMetadataKvs.filter(x => x.value.databaseId === currentDatabaseId);
if(matchingKvs.length != 1) {
throw new Error(`Expected exactly one file metadata record for this database. Found: ${JSON.stringify(matching)}.`);
}
let currentFile = matchingKvs[0].value;
return dbRange({
gt: `${currentDBPrefix}${settings.keyPrefixStartOfSourceOfTruth}`,
lt: `${currentDBPrefix}${settings.keyPrefixEndOfSourceOfTruth}`
}).then(sourceOfTruthDatabaseKeyValues => {
myLog(`auto-saving ${sourceOfTruthDatabaseKeyValues.length} keys from (databaseId=${currentFile.databaseId}) into ${currentFile.origin} (${currentFile.path})`)
// dbRange() already strips the currentDBPrefix so no need to do it here
currentFile.db = sourceOfTruthDatabaseKeyValues.map(kv => [ kv.key, kv.value ]);
currentFile.version = uuid();
return new Promise((resolve, reject) => writeFile(currentFile.path, currentFile, resolve, reject))
.then(() => myLog(`writing ${currentFile.origin} (${currentFile.path}) completed!`));
});
})
);
});
function createWindow () {
// Create the browser window.
mainWindow = new BrowserWindow({
icon: 'electron-packager-stuff/icon/icon_256.png',
width: settings.isProduction ? 800 : 1200,
height: 600,
webPreferences: {
devTools: !settings.isProduction,
// sandbox: true, // breaks preload
enableRemoteModule: false,
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.maximize();
// and load the index.html of the app.
mainWindow.loadFile('client/index.html')
if(!settings.isProduction) {
mainWindow.webContents.openDevTools()
}
mainWindow.webContents.on('new-window', function(event, url){
event.preventDefault();
open(url);
});
// Remove the help menu and the developer tools from the application menu
let defaultMenu = Menu.getApplicationMenu()
let newMenu = new Menu();
defaultMenu.items
.filter(x => x.role != 'help')
.forEach(x => {
if(x.role == 'viewmenu' && settings.isProduction) {
let newSubmenu = new Menu();
const removeFromViewMenu = [
'toggledevtools',
'reload',
'forcereload'
];
x.submenu.items.filter(y => !removeFromViewMenu.includes(y.role)).forEach(y => newSubmenu.append(y));
//x.submenu.items.forEach(x => console.log(x.role));
x.submenu = newSubmenu;
newMenu.append(
new MenuItem({
type: x.type,
label: x.label,
submenu: newSubmenu
})
);
} else {
newMenu.append(x);
}
});
Menu.setApplicationMenu(newMenu);
// Emitted when the window is closed.
mainWindow.on('closed', function () {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow)
// Quit when all windows are closed.
app.on('window-all-closed', function () {
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow()
})