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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
data/pdfs/
*.log
.DS_Store

653
css/style.css Normal file
View File

@@ -0,0 +1,653 @@
:root {
--paper: #f3ead3;
--paper-dark: #e8dcb8;
--ink: #1a1714;
--muted: #4a443c;
--rule: #1a1714;
--sidebar: 16.5rem;
--focus: #1a1714;
}
html[data-theme="dark"] {
--paper: #1c1916;
--paper-dark: #141210;
--ink: #f3ead3;
--muted: #c9bda8;
--rule: #d4c6aa;
--focus: #f3ead3;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
html {
font-size: 18px;
}
body {
background: var(--paper);
color: var(--ink);
font-family: "Courier New", Courier, ui-monospace, monospace;
line-height: 1.55;
}
.app {
display: grid;
grid-template-columns: var(--sidebar) 1fr;
height: 100vh;
width: 100vw;
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--rule);
background: var(--paper-dark);
}
.sidebar-head {
padding: 1rem 1rem 0.6rem;
border-bottom: 1px solid var(--rule);
}
.sidebar-head h2 {
margin: 0 0 0.65rem;
font-family: Georgia, "Times New Roman", Times, serif;
font-weight: 400;
font-size: 1.25rem;
}
.vault-picker {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.55rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.vault-picker select {
text-transform: none;
letter-spacing: 0;
font-size: 0.9rem;
width: 100%;
}
.vault-actions {
display: flex;
gap: 0.35rem;
margin-bottom: 0.4rem;
}
.vault-actions button,
#remove-vault {
flex: 1;
font-size: 0.72rem;
}
#remove-vault {
width: 100%;
}
.folder-heading {
padding: 0.7rem 1rem 0;
}
.eyebrow {
margin: 0 0 0.25rem;
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.folder-list {
flex: 1;
overflow: auto;
padding: 0.5rem 0;
}
.folder-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: stretch;
}
.folder-row button[data-folder-action] {
border: 0;
background: transparent;
padding: 0 0.45rem;
text-transform: none;
letter-spacing: 0;
font-size: 1rem;
}
.folder-item {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
text-transform: none;
letter-spacing: 0;
font-size: 0.95rem;
cursor: pointer;
}
.folder-item .count {
margin-left: auto;
color: var(--muted);
font-size: 0.85rem;
}
.folder-item.active {
background: var(--ink);
color: var(--paper);
}
.folder-item.active .count {
color: var(--paper);
}
.folder-item:hover:not(.active) {
text-decoration: underline;
}
.folder-item.nested {
padding-left: 1.6rem;
}
.sidebar-actions {
display: flex;
gap: 0.4rem;
padding: 0.75rem;
border-top: 1px solid var(--rule);
}
.sidebar-actions button {
flex: 1;
}
.file-path {
margin: 0;
padding: 0 0.75rem 0.75rem;
font-size: 0.75rem;
color: var(--muted);
word-break: break-all;
}
.workspace {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100vh;
}
.masthead {
flex: 0 0 auto;
padding: 0.9rem 1.15rem 0.8rem;
border-bottom: 1px solid var(--rule);
}
.masthead-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1.85rem;
font-weight: 400;
}
.subtitle {
margin: 0.15rem 0 0;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.8rem;
}
.masthead-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
.datestamp {
margin: 0;
font-size: 0.9rem;
white-space: nowrap;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
align-items: flex-end;
margin-top: 0.75rem;
}
.search {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1 1 14rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="search"],
input[type="number"],
input[type="text"],
input[type="url"],
select,
textarea,
button {
font: inherit;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--rule);
border-radius: 0;
padding: 0.4rem 0.55rem;
}
input[type="search"] {
text-transform: none;
letter-spacing: 0;
font-size: 1rem;
}
.actions {
display: flex;
gap: 0.45rem;
}
button {
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8rem;
}
button:hover {
text-decoration: underline;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.toast {
margin: 0.55rem 0 0;
font-size: 0.9rem;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
flex: 0 0 auto;
border-bottom: 1px solid var(--rule);
}
.stat {
padding: 0.65rem 0.95rem;
border-right: 1px solid var(--rule);
}
.stat:last-child {
border-right: 0;
}
.stat .value {
display: block;
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1.45rem;
}
.stat .label {
display: block;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.filters {
display: grid;
grid-template-columns: 1fr 1fr 2fr;
flex: 0 0 auto;
border-bottom: 1px solid var(--rule);
}
.filters fieldset {
margin: 0;
border: 0;
border-right: 1px solid var(--rule);
padding: 0.55rem 0.9rem 0.7rem;
}
.filters fieldset:last-child {
border-right: 0;
}
.filters legend {
padding: 0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.filters label {
display: inline-block;
margin: 0.2rem 0.85rem 0 0;
font-size: 0.95rem;
}
.filters input {
accent-color: var(--ink);
}
.catalog {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 1.1rem 1.15rem 1.6rem;
}
.section {
margin-bottom: 1.5rem;
}
.section h2 {
margin: 0 0 0.7rem;
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1.25rem;
font-weight: 400;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.3rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
gap: 0.9rem;
}
.card {
border: 1px solid var(--rule);
padding: 1rem 1.05rem 1rem;
background: var(--paper);
display: flex;
flex-direction: column;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.55rem;
margin-bottom: 0.7rem;
}
.card h3 {
margin: 0;
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1.2rem;
font-weight: 400;
}
.meta {
margin: 0.2rem 0 0;
font-size: 0.95rem;
color: var(--muted);
}
.stamps {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.3rem;
}
.stamp {
display: inline-block;
border: 1px solid var(--rule);
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
.stamp.filled {
background: var(--ink);
color: var(--paper);
}
.progress-row {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0.4rem 0 0.35rem;
}
.progress-row button {
padding: 0.2rem 0.55rem;
min-width: 2rem;
}
.progress-row input[type="number"] {
width: 4.4rem;
text-align: right;
}
.progress-row .of {
font-size: 0.95rem;
color: var(--muted);
}
.bar {
height: 0.7rem;
border: 1px solid var(--rule);
margin: 0.15rem 0 0.65rem;
}
.bar > span {
display: block;
height: 100%;
background: var(--ink);
}
.card-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
margin-top: auto;
}
.field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field.wide {
grid-column: 1 / -1;
}
.field select,
.field textarea,
.field input {
text-transform: none;
letter-spacing: 0;
font-size: 0.95rem;
}
.field textarea {
min-height: 3.4rem;
resize: vertical;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--rule);
font-size: 0.9rem;
}
.card-foot a {
color: var(--ink);
}
.card-foot a:hover {
text-decoration: underline;
}
.card-foot-actions {
display: flex;
gap: 0.4rem;
}
.drop-overlay {
position: fixed;
inset: 0.75rem;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--rule);
background: color-mix(in srgb, var(--paper) 92%, transparent);
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1.4rem;
color: var(--ink);
pointer-events: none;
}
.drop-overlay[hidden] {
display: none;
}
.tags {
margin: 0.35rem 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.muted,
.empty {
color: var(--muted);
font-size: 1rem;
}
.error {
border: 1px solid var(--rule);
padding: 1rem;
}
.empty-vault {
max-width: 36rem;
}
.empty-vault p {
margin: 0 0 0.75rem;
}
.empty-vault ol {
margin: 0 0 1rem;
padding-left: 1.2rem;
}
.sheet {
width: min(34rem, calc(100vw - 2rem));
border: 1px solid var(--rule);
background: var(--paper);
color: var(--ink);
padding: 0;
}
.sheet::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.sheet form {
padding: 1.1rem 1.15rem 1.15rem;
}
.sheet h2 {
margin: 0 0 0.9rem;
font-family: Georgia, "Times New Roman", Times, serif;
font-weight: 400;
font-size: 1.3rem;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.4rem;
}
.dialog-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
.dialog-fields .field.wide {
grid-column: 1 / -1;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: flex-end;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
max-height: 11rem;
border-right: 0;
border-bottom: 1px solid var(--rule);
}
.workspace {
height: auto;
min-height: 0;
}
}

43
data/library.json Normal file
View File

@@ -0,0 +1,43 @@
{
"folders": [
{
"id": "books-msyr7amm",
"name": "Books",
"parentId": ""
},
{
"id": "articles-msysxgse",
"name": "Articles",
"parentId": ""
}
],
"books": [
{
"id": "quick-start-kubernetes-msyslnvy",
"title": "Quick Start Kubernetes",
"author": "Nigel Poulton",
"format": "pdf",
"status": "reading",
"totalPages": 102,
"currentPage": 31,
"folderId": "books-msyr7amm",
"tags": [],
"notes": "",
"pdfPath": "D:\\Personal Projects\\ResourceLoader\\data\\pdfs\\quickstartkubernetes-2.pdf",
"sourceName": "quickstartkubernetes.pdf"
}
],
"resources": [
{
"id": "you-won-t-make-it-it-makes-you-msysxceg",
"title": "You Wont Make It. It Makes You.",
"kind": "article",
"url": "https://medium.com/westenberg/you-wont-make-it-it-makes-you-5580fb0ae28c?sharedUserId=nworb",
"source": "",
"status": "backlog",
"folderId": "articles-msysxgse",
"tags": [],
"notes": ""
}
]
}

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),
});

106
index.html Normal file
View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:" />
<title>Personal Library · Resource Index</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="sidebar-head">
<p class="eyebrow">Vault</p>
<h2 id="vault-name">No vault</h2>
<label class="vault-picker">
<span>Switch vault</span>
<select id="vault-select"></select>
</label>
<div class="vault-actions">
<button type="button" id="open-vault">Open vault</button>
<button type="button" id="new-vault">New vault</button>
</div>
<button type="button" id="remove-vault">Remove from this PC</button>
</div>
<p class="eyebrow folder-heading">Folders</p>
<nav class="folder-list" id="folder-list"></nav>
<div class="sidebar-actions">
<button type="button" id="new-folder">New folder</button>
</div>
<p class="file-path" id="file-path"></p>
</aside>
<div class="workspace">
<header class="masthead">
<div class="masthead-row">
<div>
<h1>Personal Library</h1>
<p class="subtitle">Resource Index</p>
</div>
<div class="masthead-meta">
<p class="datestamp" id="datestamp"></p>
<button type="button" id="theme-toggle">Dark mode</button>
</div>
</div>
<div class="toolbar">
<label class="search">
<span>Search</span>
<input type="search" id="search" placeholder="title, author, source, tag" autocomplete="off" />
</label>
<div class="actions">
<button type="button" id="import-pdf">Import PDF</button>
<button type="button" id="add-book">Add book</button>
<button type="button" id="add-resource">Add resource</button>
</div>
</div>
<p class="toast" id="toast" hidden></p>
</header>
<section class="stats" id="stats" aria-label="Collection summary"></section>
<section class="filters" aria-label="Filters">
<fieldset>
<legend>Format</legend>
<label><input type="checkbox" name="format" value="physical" /> Physical</label>
<label><input type="checkbox" name="format" value="pdf" /> PDF</label>
</fieldset>
<fieldset>
<legend>Kind</legend>
<label><input type="checkbox" name="kind" value="article" /> Article</label>
<label><input type="checkbox" name="kind" value="video" /> Video</label>
</fieldset>
<fieldset>
<legend>Status</legend>
<label><input type="checkbox" name="status" value="reading" /> Reading</label>
<label><input type="checkbox" name="status" value="in_progress" /> In progress</label>
<label><input type="checkbox" name="status" value="shelf" /> Shelf</label>
<label><input type="checkbox" name="status" value="paused" /> Paused</label>
<label><input type="checkbox" name="status" value="backlog" /> Backlog</label>
<label><input type="checkbox" name="status" value="finished" /> Finished</label>
<label><input type="checkbox" name="status" value="done" /> Done</label>
</fieldset>
</section>
<main id="catalog" class="catalog">
<p class="muted">Loading catalog…</p>
</main>
</div>
</div>
<div id="drop-overlay" class="drop-overlay" hidden>Drop PDFs to import</div>
<dialog id="dialog" class="sheet">
<form id="dialog-form" method="dialog">
<h2 id="dialog-title">Add</h2>
<div id="dialog-fields" class="dialog-fields"></div>
<div class="dialog-actions">
<button type="submit" value="save">Save</button>
<button type="button" id="dialog-cancel">Cancel</button>
</div>
</form>
</dialog>
<script src="js/app.js"></script>
</body>
</html>

844
js/app.js Normal file
View File

@@ -0,0 +1,844 @@
const BOOK_STATUSES = ["reading", "shelf", "paused", "finished"];
const RESOURCE_STATUSES = ["backlog", "in_progress", "done"];
const UNFILED = "";
const THEME_KEY = "resourceLoader.theme";
const catalogEl = document.getElementById("catalog");
const statsEl = document.getElementById("stats");
const searchEl = document.getElementById("search");
const toastEl = document.getElementById("toast");
const dateEl = document.getElementById("datestamp");
const folderListEl = document.getElementById("folder-list");
const filePathEl = document.getElementById("file-path");
const vaultNameEl = document.getElementById("vault-name");
const vaultSelectEl = document.getElementById("vault-select");
const dialogEl = document.getElementById("dialog");
const dialogForm = document.getElementById("dialog-form");
const dialogTitle = document.getElementById("dialog-title");
const dialogFields = document.getElementById("dialog-fields");
let catalog = { folders: [], books: [], resources: [] };
let settings = { vaults: [], activeVaultId: "" };
let vault = null;
let vaultReady = false;
let activeFolder = "all";
let dialogMode = null;
dateEl.textContent = formatDate(new Date());
initTheme();
init();
async function init() {
if (!window.libraryAPI) {
catalogEl.innerHTML = `
<div class="error">
<p><strong>Desktop app required.</strong></p>
<p>Run <code>npm start</code> from this folder, or build an .exe with <code>npm run build</code>.</p>
</div>`;
return;
}
bind();
await applyLoadResult(await window.libraryAPI.load());
}
function preferredTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "dark" || saved === "light") return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const button = document.getElementById("theme-toggle");
if (button) button.textContent = theme === "dark" ? "Light mode" : "Dark mode";
}
function initTheme() {
applyTheme(preferredTheme());
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
}
function bind() {
searchEl.addEventListener("input", render);
document.querySelectorAll(".filters input").forEach((input) => {
input.addEventListener("change", render);
});
document.getElementById("add-book").addEventListener("click", () => openBookDialog());
document.getElementById("add-resource").addEventListener("click", () => openResourceDialog());
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("import-pdf").addEventListener("click", () => importPdfs());
document.getElementById("new-folder").addEventListener("click", () => openFolderDialog());
document.getElementById("open-vault").addEventListener("click", openExistingVault);
document.getElementById("new-vault").addEventListener("click", openNewVaultDialog);
document.getElementById("remove-vault").addEventListener("click", removeActiveVault);
vaultSelectEl.addEventListener("change", async (event) => {
await applyLoadResult(await window.libraryAPI.switchVault(event.target.value));
});
document.getElementById("dialog-cancel").addEventListener("click", () => dialogEl.close());
dialogForm.addEventListener("submit", onDialogSubmit);
folderListEl.addEventListener("click", onFolderClick);
catalogEl.addEventListener("click", onCatalogClick);
catalogEl.addEventListener("change", onCatalogChange);
catalogEl.addEventListener("focusout", onCatalogFocusOut);
bindDrop();
}
function existingPdfNames() {
return catalog.books.map((book) => book.sourceName).filter(Boolean);
}
function currentFolderId() {
return activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
}
async function applyLoadResult(result) {
settings = result.settings || { vaults: [], activeVaultId: "" };
vault = result.vault || null;
vaultReady = Boolean(result.ok);
catalog = result.ok && result.catalog ? result.catalog : { folders: [], books: [], resources: [] };
activeFolder = "all";
renderVaultChrome();
setWorkspaceEnabled(vaultReady);
if (!result.ok) {
catalogEl.innerHTML = vaultEmptyHtml(result);
statsEl.innerHTML = "";
folderListEl.innerHTML = "";
return;
}
render();
}
function renderVaultChrome() {
vaultNameEl.textContent = vault ? vault.name : "No vault";
filePathEl.textContent = vault ? vault.path : "Choose a Google Drive folder as a vault.";
vaultSelectEl.innerHTML = settings.vaults.length
? settings.vaults
.map(
(item) =>
`<option value="${escapeAttr(item.id)}" ${item.id === settings.activeVaultId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("")
: `<option value="">No vaults on this PC</option>`;
vaultSelectEl.disabled = settings.vaults.length === 0;
document.getElementById("remove-vault").disabled = !vault;
}
function setWorkspaceEnabled(enabled) {
["import-pdf", "add-book", "add-resource", "new-folder", "search"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.disabled = !enabled;
});
}
function vaultEmptyHtml(result) {
if (result.reason === "missing-folder") {
return `
<div class="error empty-vault">
<p><strong>Vault folder not found.</strong></p>
<p>${escapeHtml(result.path || "")}</p>
<p>If this lives on Google Drive, wait until Drive finishes syncing, then Open vault again. Dont edit the same vault on two PCs at once.</p>
</div>`;
}
if (result.reason === "unreadable") {
return `
<div class="error empty-vault">
<p><strong>Could not read library.json.</strong></p>
<p>${escapeHtml(result.error || "")}</p>
<p>Drive may still be syncing. Try again in a moment.</p>
</div>`;
}
return `
<div class="empty-vault">
<p><strong>No vault open on this computer.</strong></p>
<p>A vault is a folder (usually in Google Drive) with <code>library.json</code> and a <code>pdfs</code> folder. Use one vault for Work and another for Personal.</p>
<ol>
<li>Install Google Drive for desktop and wait for folders to sync.</li>
<li>New vault: create Work or Personal under <code>ResourceLoader</code> in Drive.</li>
<li>Open vault: point this app at that folder. Work laptop: add Work only.</li>
</ol>
<p>Dont edit the same vault on two PCs at once. Last save wins.</p>
</div>`;
}
async function openExistingVault() {
const result = await window.libraryAPI.openVault();
if (result.cancelled) return;
await applyLoadResult(result);
}
function openNewVaultDialog() {
dialogMode = "vault-create";
dialogTitle.textContent = "New vault";
dialogFields.innerHTML = `
<label class="field wide"><span>Name</span><input name="name" required placeholder="Work or Personal" /></label>
<p class="muted">Next you will choose the parent folder (for example your Google Drive ResourceLoader folder).</p>
`;
dialogEl.showModal();
}
async function removeActiveVault() {
if (!vault) return;
if (!confirm(`Remove “${vault.name}” from this PC? Files in Drive are not deleted.`)) return;
await applyLoadResult(await window.libraryAPI.removeVault(vault.id));
}
async function importPdfs() {
const result = await window.libraryAPI.importPdfs(existingPdfNames());
await applyImportResult(result);
}
async function applyImportResult(result) {
if (!result || result.cancelled) return;
if (result.ok === false) {
showToast(result.reason === "no-vault" ? "Open a vault first." : vaultMessage(result));
return;
}
const folderId = currentFolderId();
for (const pdf of result.imported || []) {
catalog.books.push({
id: makeId(pdf.title),
title: pdf.title,
author: pdf.author || "",
format: "pdf",
status: "shelf",
totalPages: Number(pdf.totalPages) || 0,
currentPage: 0,
folderId,
tags: Array.isArray(pdf.tags) ? pdf.tags : [],
notes: pdf.notes || "",
pdfPath: pdf.pdfPath || "",
sourceName: pdf.sourceName || "",
});
}
if (result.imported && result.imported.length) {
await persist();
render();
}
const parts = [];
if (result.imported?.length) parts.push(`Imported ${result.imported.length} PDF${result.imported.length === 1 ? "" : "s"}.`);
if (result.skipped?.length) parts.push(`Skipped ${result.skipped.length}.`);
if (result.errors?.length) parts.push(`Failed ${result.errors.length}.`);
if (parts.length) showToast(parts.join(" "));
}
function bindDrop() {
const overlay = document.getElementById("drop-overlay");
let dragDepth = 0;
window.addEventListener("dragenter", (event) => {
event.preventDefault();
dragDepth += 1;
overlay.hidden = false;
});
window.addEventListener("dragover", (event) => {
event.preventDefault();
overlay.hidden = false;
});
window.addEventListener("dragleave", (event) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) overlay.hidden = true;
});
window.addEventListener("drop", async (event) => {
event.preventDefault();
dragDepth = 0;
overlay.hidden = true;
const files = [...(event.dataTransfer?.files || [])];
const paths = files
.map((file) => {
try {
return window.libraryAPI.pathForFile(file);
} catch {
return "";
}
})
.filter((filePath) => filePath && filePath.toLowerCase().endsWith(".pdf"));
if (!paths.length) {
showToast("Drop one or more PDF files.");
return;
}
const result = await window.libraryAPI.importPdfPaths(paths, existingPdfNames());
await applyImportResult(result);
});
}
function vaultMessage(result) {
if (result.reason === "missing-folder") return "Vault folder not found. Wait for Drive to sync.";
if (result.reason === "unreadable") return "Could not read library.json.";
if (result.reason === "no-vault") return "Open a vault first.";
return result.error || "Vault is not ready.";
}
async function persist() {
if (!vaultReady) return;
const result = await window.libraryAPI.save(catalog);
if (!result.ok) {
showToast(vaultMessage(result));
return;
}
catalog = result.catalog;
}
function inFolder(item) {
if (activeFolder === "all") return true;
if (activeFolder === "unfiled") return !item.folderId;
return item.folderId === activeFolder;
}
function itemCount(folderId) {
const match = (item) =>
folderId === "unfiled" ? !item.folderId : item.folderId === folderId;
return catalog.books.filter(match).length + catalog.resources.filter(match).length;
}
function folderOptions(selected) {
const rows = [`<option value="${UNFILED}" ${!selected ? "selected" : ""}>Unfiled</option>`];
for (const folder of sortedFolders()) {
const prefix = folder.parentId ? "— " : "";
rows.push(
`<option value="${escapeAttr(folder.id)}" ${folder.id === selected ? "selected" : ""}>${prefix}${escapeHtml(folder.name)}</option>`
);
}
return rows.join("");
}
function sortedFolders() {
const roots = catalog.folders.filter((f) => !f.parentId).sort(byName);
const children = catalog.folders.filter((f) => f.parentId);
const ordered = [];
for (const root of roots) {
ordered.push(root);
ordered.push(...children.filter((c) => c.parentId === root.id).sort(byName));
}
const hanging = children.filter((c) => !ordered.includes(c)).sort(byName);
return [...ordered, ...hanging];
}
function byName(a, b) {
return a.name.localeCompare(b.name);
}
function getFilters() {
const checked = (name) =>
[...document.querySelectorAll(`input[name="${name}"]:checked`)].map((el) => el.value);
return {
query: searchEl.value.trim().toLowerCase(),
formats: checked("format"),
kinds: checked("kind"),
statuses: checked("status"),
};
}
function matches(item, filters) {
if (!inFolder(item)) return false;
if (filters.statuses.length && !filters.statuses.includes(item.status)) return false;
if (item.type === "book") {
if (filters.formats.length && !filters.formats.includes(item.format)) return false;
if (filters.kinds.length && !filters.formats.length) return false;
} else {
if (filters.kinds.length && !filters.kinds.includes(item.kind)) return false;
if (filters.formats.length && !filters.kinds.length) return false;
}
if (!filters.query) return true;
const hay = [
item.title,
item.author,
item.source,
item.format,
item.kind,
...(item.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(filters.query);
}
function tagged(list, type) {
return list.map((item) => ({ ...item, type }));
}
function render() {
renderFolders();
const books = tagged(catalog.books, "book");
const resources = tagged(catalog.resources, "resource");
const filters = getFilters();
const visibleBooks = books.filter((b) => matches(b, filters));
const visibleResources = resources.filter((r) => matches(r, filters));
renderStats(books.filter(inFolder), resources.filter(inFolder));
const nowReading = [
...visibleBooks.filter((b) => b.status === "reading"),
...visibleResources.filter((r) => r.status === "in_progress"),
];
const shelf = [
...visibleBooks.filter((b) => b.status === "shelf" || b.status === "paused"),
...visibleResources.filter((r) => r.status === "backlog"),
];
const finished = [
...visibleBooks.filter((b) => b.status === "finished"),
...visibleResources.filter((r) => r.status === "done"),
];
catalogEl.innerHTML = [
section("Now reading", nowReading),
section("On the shelf", shelf),
section("Finished", finished),
].join("");
}
function renderFolders() {
const allCount = catalog.books.length + catalog.resources.length;
const items = [
folderButton("all", "All items", allCount, false),
folderButton("unfiled", "Unfiled", itemCount("unfiled"), false),
...sortedFolders().map((folder) =>
folderButton(folder.id, folder.name, itemCount(folder.id), Boolean(folder.parentId), folder.id)
),
];
folderListEl.innerHTML = items.join("");
}
function folderButton(id, name, count, nested, folderId) {
const active = activeFolder === id ? " active" : "";
const nest = nested ? " nested" : "";
const manage =
folderId
? `<button type="button" data-folder-action="rename" data-id="${escapeAttr(folderId)}" title="Rename">✎</button>
<button type="button" data-folder-action="delete" data-id="${escapeAttr(folderId)}" title="Delete">×</button>`
: "";
return `
<div class="folder-row">
<button type="button" class="folder-item${active}${nest}" data-folder="${escapeAttr(id)}">
<span>${escapeHtml(name)}</span>
<span class="count">${count}</span>
</button>
${manage}
</div>`;
}
function renderStats(books, resources) {
const reading = books.filter((b) => b.status === "reading").length;
const shelf = books.filter((b) => b.status === "shelf" || b.status === "paused").length;
const pages = books.reduce((sum, b) => sum + (Number(b.currentPage) || 0), 0);
const backlog = resources.filter((r) => r.status === "backlog").length;
statsEl.innerHTML = [
stat(reading, "Now reading"),
stat(shelf, "On the shelf"),
stat(pages, "Pages logged"),
stat(backlog, "Resource backlog"),
].join("");
}
function stat(value, label) {
return `<div class="stat"><span class="value">${escapeHtml(String(value))}</span><span class="label">${escapeHtml(label)}</span></div>`;
}
function section(title, items) {
const body =
items.length === 0
? `<p class="empty">— none —</p>`
: `<div class="cards">${items.map(card).join("")}</div>`;
return `<section class="section"><h2>${escapeHtml(title)}</h2>${body}</section>`;
}
function card(item) {
return item.type === "book" ? bookCard(item) : resourceCard(item);
}
function bookCard(book) {
const total = Number(book.totalPages) || 0;
const page = Number(book.currentPage) || 0;
const pct = total ? Math.round((page / total) * 100) : 0;
return `
<article class="card" data-id="${escapeAttr(book.id)}" data-type="book">
<div class="card-head">
<div>
<h3>${escapeHtml(book.title)}</h3>
<p class="meta">${escapeHtml(book.author || "Unknown author")}</p>
${tagsHtml(book.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(book.format || "book")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(book.status))}</span>
</div>
</div>
<div class="progress-row">
<button type="button" data-action="page" data-delta="-1" aria-label="Previous page"></button>
<input type="number" min="0" max="${total}" value="${page}" data-field="currentPage" aria-label="Current page" />
<span class="of">/ ${total}</span>
<button type="button" data-action="page" data-delta="1" aria-label="Next page">+</button>
</div>
<div class="bar" aria-hidden="true"><span style="width:${pct}%"></span></div>
<div class="card-controls">
${statusField(book.status, BOOK_STATUSES)}
${folderField(book.folderId)}
${notesField(book.notes)}
</div>
<div class="card-foot">
<span>${pct}% of ${total} pp.</span>
<span class="card-foot-actions">
${book.pdfPath ? `<button type="button" data-action="open-pdf">Open PDF</button>` : ""}
<button type="button" data-action="delete">Delete</button>
</span>
</div>
</article>`;
}
function resourceCard(item) {
const url = item.url || "";
return `
<article class="card" data-id="${escapeAttr(item.id)}" data-type="resource">
<div class="card-head">
<div>
<h3>${escapeHtml(item.title)}</h3>
<p class="meta">${escapeHtml(item.source || "Unknown source")}</p>
${tagsHtml(item.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(item.kind || "resource")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(item.status))}</span>
</div>
</div>
<div class="card-controls">
${statusField(item.status, RESOURCE_STATUSES)}
${folderField(item.folderId)}
${notesField(item.notes)}
</div>
<div class="card-foot">
${
url
? `<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(item.kind || "link")}</a>`
: `<span class="muted">No URL</span>`
}
<button type="button" data-action="delete">Delete</button>
</div>
</article>`;
}
function statusField(status, options) {
const opts = options
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === status ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.join("");
return `<label class="field"><span>Status</span><select data-field="status">${opts}</select></label>`;
}
function folderField(folderId) {
return `<label class="field"><span>Folder</span><select data-field="folderId">${folderOptions(folderId || "")}</select></label>`;
}
function notesField(notes) {
return `<label class="field wide"><span>Notes</span><textarea data-field="notes" rows="2">${escapeHtml(notes || "")}</textarea></label>`;
}
function tagsHtml(tags) {
if (!tags || !tags.length) return "";
return `<p class="tags">${tags.map((t) => `#${escapeHtml(t)}`).join(" ")}</p>`;
}
function labelStatus(status) {
return String(status || "").replaceAll("_", " ");
}
function onFolderClick(event) {
const manage = event.target.closest("[data-folder-action]");
if (manage) {
const id = manage.dataset.id;
if (manage.dataset.folderAction === "rename") openFolderDialog(id);
if (manage.dataset.folderAction === "delete") deleteFolder(id);
return;
}
const button = event.target.closest("[data-folder]");
if (!button) return;
activeFolder = button.dataset.folder;
render();
}
async function deleteFolder(id) {
const folder = catalog.folders.find((f) => f.id === id);
if (!folder) return;
if (!confirm(`Delete folder “${folder.name}”? Items inside move to Unfiled.`)) return;
catalog.folders = catalog.folders.filter((f) => f.id !== id && f.parentId !== id);
for (const book of catalog.books) {
if (book.folderId === id) book.folderId = "";
}
for (const resource of catalog.resources) {
if (resource.folderId === id) resource.folderId = "";
}
if (activeFolder === id) activeFolder = "all";
await persist();
render();
showToast(`Deleted folder ${folder.name}.`);
}
async function onCatalogClick(event) {
const button = event.target.closest("button[data-action]");
if (!button) return;
const cardEl = button.closest(".card");
if (!cardEl) return;
const id = cardEl.dataset.id;
const kind = cardEl.dataset.type;
if (button.dataset.action === "delete") {
if (!confirm("Delete this item?")) return;
if (kind === "book") catalog.books = catalog.books.filter((b) => b.id !== id);
else catalog.resources = catalog.resources.filter((r) => r.id !== id);
await persist();
render();
showToast("Deleted.");
return;
}
if (button.dataset.action === "open-pdf") {
const book = catalog.books.find((b) => b.id === id);
if (!book?.pdfPath) return;
const opened = await window.libraryAPI.openPdf(book.pdfPath);
if (!opened.ok) showToast(opened.error || "Could not open PDF.");
return;
}
if (button.dataset.action === "page") {
const book = catalog.books.find((b) => b.id === id);
if (!book) return;
const next = clamp(
(Number(book.currentPage) || 0) + Number(button.dataset.delta),
0,
Number(book.totalPages) || 0
);
book.currentPage = next;
await persist();
render();
}
}
async function onCatalogChange(event) {
const field = event.target.dataset.field;
const cardEl = event.target.closest(".card");
if (!field || !cardEl || field === "notes") return;
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
if (!item) return;
if (field === "status") item.status = event.target.value;
if (field === "folderId") item.folderId = event.target.value;
if (field === "currentPage") {
const total = Number(item.totalPages) || 0;
item.currentPage = clamp(Number(event.target.value) || 0, 0, total);
}
await persist();
render();
}
async function onCatalogFocusOut(event) {
if (event.target.dataset.field !== "notes") return;
const cardEl = event.target.closest(".card");
if (!cardEl) return;
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
if (!item || item.notes === event.target.value) return;
item.notes = event.target.value;
await persist();
}
function findItem(id, type) {
return type === "book"
? catalog.books.find((b) => b.id === id)
: catalog.resources.find((r) => r.id === id);
}
function openBookDialog() {
dialogMode = "book";
dialogTitle.textContent = "Add book";
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Author</span><input name="author" /></label>
<label class="field"><span>Format</span>
<select name="format">
<option value="physical">Physical</option>
<option value="pdf">PDF</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(BOOK_STATUSES, "shelf")}</select>
</label>
<label class="field"><span>Total pages</span><input name="totalPages" type="number" min="0" value="0" /></label>
<label class="field"><span>Current page</span><input name="currentPage" type="number" min="0" value="0" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
dialogEl.showModal();
}
function openResourceDialog() {
dialogMode = "resource";
dialogTitle.textContent = "Add resource";
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Kind</span>
<select name="kind">
<option value="article">Article</option>
<option value="video">Video</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(RESOURCE_STATUSES, "backlog")}</select>
</label>
<label class="field wide"><span>URL</span><input name="url" type="url" placeholder="https://" /></label>
<label class="field"><span>Source</span><input name="source" placeholder="site or channel" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field wide"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
dialogEl.showModal();
}
function openFolderDialog(id) {
const existing = catalog.folders.find((f) => f.id === id);
dialogMode = existing ? "folder-edit" : "folder";
dialogTitle.textContent = existing ? "Rename folder" : "New folder";
const parentPrefill = !existing && activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<input type="hidden" name="id" value="${escapeAttr(existing ? existing.id : "")}" />
<label class="field wide"><span>Name</span><input name="name" required value="${escapeAttr(existing ? existing.name : "")}" /></label>
${
existing
? ""
: `<label class="field wide"><span>Inside</span>
<select name="parentId">
<option value="">Top level</option>
${catalog.folders
.filter((f) => !f.parentId)
.sort(byName)
.map(
(f) =>
`<option value="${escapeAttr(f.id)}" ${f.id === parentPrefill ? "selected" : ""}>${escapeHtml(f.name)}</option>`
)
.join("")}
</select>
</label>`
}
`;
dialogEl.showModal();
}
function options(values, selected) {
return values
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === selected ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.join("");
}
async function onDialogSubmit(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(dialogForm));
if (dialogMode === "book") {
catalog.books.push({
id: makeId(data.title),
title: data.title.trim(),
author: (data.author || "").trim(),
format: data.format === "pdf" ? "pdf" : "physical",
status: BOOK_STATUSES.includes(data.status) ? data.status : "shelf",
totalPages: Math.max(0, Number(data.totalPages) || 0),
currentPage: clamp(Number(data.currentPage) || 0, 0, Math.max(0, Number(data.totalPages) || 0)),
folderId: data.folderId || "",
tags: parseTags(data.tags),
notes: data.notes || "",
});
showToast("Book added.");
} else if (dialogMode === "resource") {
catalog.resources.push({
id: makeId(data.title),
title: data.title.trim(),
kind: data.kind === "video" ? "video" : "article",
url: (data.url || "").trim(),
source: (data.source || "").trim(),
status: RESOURCE_STATUSES.includes(data.status) ? data.status : "backlog",
folderId: data.folderId || "",
tags: parseTags(data.tags),
notes: data.notes || "",
});
showToast("Resource added.");
} else if (dialogMode === "folder") {
catalog.folders.push({
id: makeId(data.name),
name: data.name.trim(),
parentId: data.parentId || "",
});
showToast("Folder created.");
} else if (dialogMode === "folder-edit") {
const folder = catalog.folders.find((f) => f.id === data.id);
if (folder) folder.name = data.name.trim();
showToast("Folder renamed.");
} else if (dialogMode === "vault-create") {
dialogEl.close();
const result = await window.libraryAPI.createVault(data.name.trim());
if (result.cancelled) return;
await applyLoadResult(result);
showToast(result.ok ? "Vault created." : vaultMessage(result));
return;
}
dialogEl.close();
await persist();
render();
}
function parseTags(value) {
return String(value || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
function makeId(title) {
const slug = String(title || "item")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
return `${slug || "item"}-${Date.now().toString(36)}`;
}
function showToast(message) {
toastEl.hidden = false;
toastEl.textContent = message;
}
function formatDate(date) {
return date
.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
})
.toUpperCase();
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value);
}

5322
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "resource-loader",
"version": "1.0.0",
"description": "Personal library and resource catalog",
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder --win portable"
},
"author": "",
"license": "UNLICENSED",
"dependencies": {
"pdf-lib": "^1.17.1"
},
"devDependencies": {
"electron": "^33.2.1",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "local.resourceloader.app",
"productName": "Resource Loader",
"files": [
"electron/**",
"index.html",
"css/**",
"js/**",
"data/**"
],
"directories": {
"output": "dist"
},
"win": {
"target": [
{
"target": "portable",
"arch": [
"x64"
]
}
]
}
}
}