Testing
This commit is contained in:
292
electron/main.js
Normal file
292
electron/main.js
Normal 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
90
electron/pdf-import.js
Normal 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
16
electron/preload.js
Normal 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),
|
||||
});
|
||||
Reference in New Issue
Block a user