This commit is contained in:
Zaine Qayyum
2026-08-19 09:56:06 +01:00
commit 9e075ec941
10 changed files with 7414 additions and 0 deletions

292
electron/main.js Normal file
View File

@@ -0,0 +1,292 @@
const { app, BrowserWindow, ipcMain, shell, dialog } = require("electron");
const fs = require("fs");
const path = require("path");
const { importPdfFiles } = require("./pdf-import");
const EMPTY_CATALOG = {
folders: [],
books: [],
resources: [],
};
function settingsPath() {
return path.join(app.getPath("userData"), "settings.json");
}
function readSettings() {
try {
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
return {
vaults: Array.isArray(data.vaults) ? data.vaults : [],
activeVaultId: data.activeVaultId || "",
};
} catch {
return { vaults: [], activeVaultId: "" };
}
}
function writeSettings(settings) {
const payload = {
vaults: Array.isArray(settings.vaults) ? settings.vaults : [],
activeVaultId: settings.activeVaultId || "",
};
fs.mkdirSync(path.dirname(settingsPath()), { recursive: true });
fs.writeFileSync(settingsPath(), JSON.stringify(payload, null, 2));
return payload;
}
function activeVault(settings = readSettings()) {
return settings.vaults.find((vault) => vault.id === settings.activeVaultId) || null;
}
function makeVaultId(name) {
const slug = String(name || "vault")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
return `${slug || "vault"}-${Date.now().toString(36)}`;
}
function addOrSelectVault(folderPath, name) {
const settings = readSettings();
const resolved = path.resolve(folderPath);
let vault = settings.vaults.find((item) => path.resolve(item.path) === resolved);
if (!vault) {
vault = {
id: makeVaultId(name || path.basename(resolved)),
name: (name || path.basename(resolved)).trim() || path.basename(resolved),
path: resolved,
};
settings.vaults.push(vault);
}
settings.activeVaultId = vault.id;
return { settings: writeSettings(settings), vault };
}
function ensureVaultFiles(folderPath) {
if (!fs.existsSync(folderPath)) {
return { ok: false, reason: "missing-folder", path: folderPath };
}
const libraryFile = path.join(folderPath, "library.json");
fs.mkdirSync(path.join(folderPath, "pdfs"), { recursive: true });
if (!fs.existsSync(libraryFile)) {
fs.writeFileSync(libraryFile, JSON.stringify(EMPTY_CATALOG, null, 2));
}
return { ok: true, libraryPath: libraryFile, path: folderPath };
}
function parseCatalog(raw) {
const data = JSON.parse(raw);
return {
folders: Array.isArray(data.folders) ? data.folders : [],
books: Array.isArray(data.books) ? data.books : [],
resources: Array.isArray(data.resources) ? data.resources : [],
};
}
function loadLibrary() {
const settings = readSettings();
const vault = activeVault(settings);
if (!vault) {
return { ok: false, reason: "no-vault", settings };
}
const ensured = ensureVaultFiles(vault.path);
if (!ensured.ok) {
return { ...ensured, settings, vault };
}
try {
return {
ok: true,
settings,
vault,
catalog: parseCatalog(fs.readFileSync(ensured.libraryPath, "utf8")),
libraryPath: ensured.libraryPath,
};
} catch (err) {
return {
ok: false,
reason: "unreadable",
error: err.message,
settings,
vault,
path: vault.path,
};
}
}
function saveLibrary(data) {
const loaded = loadLibrary();
if (!loaded.ok) return loaded;
const payload = {
folders: Array.isArray(data.folders) ? data.folders : [],
books: Array.isArray(data.books) ? data.books : [],
resources: Array.isArray(data.resources) ? data.resources : [],
};
fs.writeFileSync(loaded.libraryPath, JSON.stringify(payload, null, 2));
return { ...loaded, catalog: payload };
}
function resolvePdfPath(vaultRoot, pdfPath) {
if (!pdfPath) return "";
if (path.isAbsolute(pdfPath) && fs.existsSync(pdfPath)) return pdfPath;
const joined = path.join(vaultRoot, pdfPath);
if (fs.existsSync(joined)) return joined;
return "";
}
function toPosixRelative(from, to) {
return path.relative(from, to).split(path.sep).join("/");
}
async function importIntoActiveVault(filePaths, existingNames) {
const loaded = loadLibrary();
if (!loaded.ok) {
return { imported: [], skipped: [], errors: [], cancelled: false, ...loaded };
}
const destDir = path.join(loaded.vault.path, "pdfs");
const result = await importPdfFiles(filePaths || [], destDir, existingNames || []);
result.imported = result.imported.map((item) => ({
...item,
pdfPath: toPosixRelative(loaded.vault.path, item.pdfPath),
}));
return result;
}
function focusedWindow() {
return BrowserWindow.getFocusedWindow();
}
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
backgroundColor: "#f3ead3",
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, "..", "index.html"));
win.maximize();
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
}
app.whenReady().then(() => {
ipcMain.handle("library:load", () => loadLibrary());
ipcMain.handle("library:save", (_event, data) => saveLibrary(data));
ipcMain.handle("settings:load", () => {
const settings = readSettings();
return { settings, vault: activeVault(settings) };
});
ipcMain.handle("vault:open", async () => {
const result = await dialog.showOpenDialog(focusedWindow(), {
title: "Open vault folder",
properties: ["openDirectory"],
});
if (result.canceled || !result.filePaths[0]) {
return { ok: false, cancelled: true, ...loadLibrary() };
}
const folderPath = result.filePaths[0];
if (!fs.existsSync(folderPath)) {
return {
ok: false,
reason: "missing-folder",
path: folderPath,
settings: readSettings(),
};
}
addOrSelectVault(folderPath, path.basename(folderPath));
return loadLibrary();
});
ipcMain.handle("vault:create", async (_event, name) => {
const folderName = String(name || "").trim();
if (!folderName) {
return { ok: false, reason: "no-name", settings: readSettings() };
}
const result = await dialog.showOpenDialog(focusedWindow(), {
title: "Choose parent folder for the new vault",
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) {
return { ok: false, cancelled: true, ...loadLibrary() };
}
const folderPath = path.join(result.filePaths[0], folderName);
fs.mkdirSync(folderPath, { recursive: true });
addOrSelectVault(folderPath, folderName);
return loadLibrary();
});
ipcMain.handle("vault:switch", (_event, id) => {
const settings = readSettings();
if (!settings.vaults.some((vault) => vault.id === id)) {
return loadLibrary();
}
writeSettings({ ...settings, activeVaultId: id });
return loadLibrary();
});
ipcMain.handle("vault:remove", (_event, id) => {
const settings = readSettings();
const vaults = settings.vaults.filter((vault) => vault.id !== id);
const activeVaultId =
settings.activeVaultId === id ? vaults[0]?.id || "" : settings.activeVaultId;
writeSettings({ vaults, activeVaultId });
return loadLibrary();
});
ipcMain.handle("pdf:import", async (_event, existingNames) => {
const loaded = loadLibrary();
if (!loaded.ok) {
return { imported: [], skipped: [], errors: [], cancelled: false, ...loaded };
}
const result = await dialog.showOpenDialog(focusedWindow(), {
title: "Import PDFs",
properties: ["openFile", "multiSelections"],
filters: [{ name: "PDF documents", extensions: ["pdf"] }],
});
if (result.canceled || !result.filePaths.length) {
return { imported: [], skipped: [], errors: [], cancelled: true };
}
return importIntoActiveVault(result.filePaths, existingNames);
});
ipcMain.handle("pdf:importPaths", async (_event, filePaths, existingNames) => {
return importIntoActiveVault(filePaths, existingNames);
});
ipcMain.handle("pdf:open", async (_event, pdfPath) => {
const vault = activeVault();
if (!vault) return { ok: false, error: "No vault open." };
const resolved = resolvePdfPath(vault.path, pdfPath);
if (!resolved) {
return {
ok: false,
error: "File not found. Wait for Google Drive to finish syncing, then try again.",
};
}
const error = await shell.openPath(resolved);
return { ok: !error, error: error || "" };
});
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

90
electron/pdf-import.js Normal file
View File

@@ -0,0 +1,90 @@
const fs = require("fs");
const path = require("path");
const { PDFDocument } = require("pdf-lib");
function clean(value) {
if (value == null) return "";
return String(value).replace(/\0/g, "").replace(/\s+/g, " ").trim();
}
function titleFromFilename(filePath) {
return path
.basename(filePath, path.extname(filePath))
.replace(/[_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseKeywords(value) {
if (!value) return [];
const list = Array.isArray(value) ? value : String(value).split(/[,;]+/);
return [...new Set(list.map(clean).filter(Boolean))];
}
function uniqueDest(dir, originalName) {
const ext = path.extname(originalName) || ".pdf";
const stem =
path
.basename(originalName, ext)
.replace(/[<>:"/\\|?*]+/g, "-")
.slice(0, 80) || "document";
let dest = path.join(dir, `${stem}${ext}`);
let n = 2;
while (fs.existsSync(dest)) {
dest = path.join(dir, `${stem}-${n}${ext}`);
n += 1;
}
return dest;
}
async function extractMetadata(filePath) {
const bytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(bytes, {
ignoreEncryption: true,
updateMetadata: false,
});
return {
title: clean(pdf.getTitle()) || titleFromFilename(filePath),
author: clean(pdf.getAuthor()),
totalPages: pdf.getPageCount(),
notes: clean(pdf.getSubject()),
tags: parseKeywords(pdf.getKeywords()),
};
}
async function importPdfFiles(filePaths, destDir, existingNames) {
fs.mkdirSync(destDir, { recursive: true });
const known = new Set((existingNames || []).map((name) => name.toLowerCase()));
const imported = [];
const skipped = [];
const errors = [];
for (const filePath of filePaths) {
const sourceName = path.basename(filePath);
if (!filePath.toLowerCase().endsWith(".pdf")) {
skipped.push({ file: sourceName, reason: "not a PDF" });
continue;
}
if (known.has(sourceName.toLowerCase())) {
skipped.push({ file: sourceName, reason: "already in catalog" });
continue;
}
try {
const meta = await extractMetadata(filePath);
const dest = uniqueDest(destDir, sourceName);
fs.copyFileSync(filePath, dest);
known.add(sourceName.toLowerCase());
imported.push({
...meta,
pdfPath: dest,
sourceName,
});
} catch (err) {
errors.push({ file: sourceName, message: err.message });
}
}
return { imported, skipped, errors };
}
module.exports = { importPdfFiles };

16
electron/preload.js Normal file
View File

@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("libraryAPI", {
load: () => ipcRenderer.invoke("library:load"),
save: (data) => ipcRenderer.invoke("library:save", data),
settings: () => ipcRenderer.invoke("settings:load"),
openVault: () => ipcRenderer.invoke("vault:open"),
createVault: (name) => ipcRenderer.invoke("vault:create", name),
switchVault: (id) => ipcRenderer.invoke("vault:switch", id),
removeVault: (id) => ipcRenderer.invoke("vault:remove", id),
importPdfs: (existingNames) => ipcRenderer.invoke("pdf:import", existingNames),
importPdfPaths: (filePaths, existingNames) =>
ipcRenderer.invoke("pdf:importPaths", filePaths, existingNames),
openPdf: (pdfPath) => ipcRenderer.invoke("pdf:open", pdfPath),
pathForFile: (file) => webUtils.getPathForFile(file),
});