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
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() |
|
})
|
|
|