Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
411 changes: 402 additions & 9 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"chrome-remote-interface": "^0.33.0"
"chrome-remote-interface": "^0.33.0",
"mammoth": "^1.12.0",
"pdf-parse": "^2.4.5"
},
"devDependencies": {
"@electron/rebuild": "^3.6.0",
"@eslint/js": "^9.39.4",
"@types/better-sqlite3": "^7.6.8",
"@types/chrome-remote-interface": "^0.34.0",
"@types/node": "^20.11.5",
"@types/pdf-parse": "^1.1.5",
"electron": "^29.0.0",
"electron-builder": "^24.9.1",
"eslint": "^9.39.4",
Expand Down
28 changes: 28 additions & 0 deletions src/main/db/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export interface ApplicationFull extends ApplicationSummary {
matches: { keyword_id: number; name: string; mention_count: number; blurb_id: number | null }[];
}

// TODO: add more stats and a potential nice graph using MatPlotLib's TS equivalent, gotta research on that what is
// though.
export interface AppStats {
total: number;
byStatus: Record<string, number>;
avgMatches: number;
}

export function createApplication(
db: Database.Database,
data: {
Expand Down Expand Up @@ -128,3 +136,23 @@ export function updateApplicationStatus(db: Database.Database, id: number, statu
export function updateApplicationNotes(db: Database.Database, id: number, notes: string): void {
db.prepare('UPDATE applications SET notes = ? WHERE id = ?').run(notes, id);
}

export function getStats(db: Database.Database): AppStats {
const total = (db.prepare('SELECT COUNT(*) as c FROM applications').get() as { c: number }).c;

const statusRows = db
.prepare('SELECT status, COUNT(*) as c FROM applications GROUP BY status')
.all() as { status: string; c: number }[];
const byStatus: Record<string, number> = {};
statusRows.forEach((r) => (byStatus[r.status] = r.c));

const avg = db
.prepare(
`SELECT AVG(cnt) as avg FROM (
SELECT application_id, COUNT(*) as cnt FROM application_keyword_matches GROUP BY application_id
)`
)
.get() as { avg: number | null };

return { total, byStatus, avgMatches: Math.round((avg.avg ?? 0) * 10) / 10 };
}
8 changes: 7 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { initDatabase, getDatabase } from './db/schema';
import { registerScraperIPC } from './ipc/scraper';
import { registerFileIPC } from './ipc/file';
import { registerDatabaseIPC } from './ipc/database';
import { registerImporterIPC } from './ipc/importer';
import { registerLibraryIPC } from './ipc/library';
import { seedKeywords } from './db/keywords';
import { seedTemplates } from './db/templates';

Expand Down Expand Up @@ -65,7 +67,11 @@ app.whenReady().then(() => {
registerDatabaseIPC();

createWindow();
if (mainWindow) registerScraperIPC(mainWindow);
if (mainWindow) {
registerScraperIPC(mainWindow);
registerImporterIPC(mainWindow);
registerLibraryIPC(mainWindow);
}

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function registerDatabaseIPC(): void {
ipcMain.handle('app:updateNotes', (_e, id: number, notes: string) =>
APP.updateApplicationNotes(db, id, notes)
);
ipcMain.handle('app:stats', () => APP.getStats(db));
ipcMain.handle('app:saveCoverLetter', (_e, id: number, html: string) =>
APP.saveCoverLetter(db, id, html)
);
Expand Down
46 changes: 46 additions & 0 deletions src/main/ipc/importer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ipcMain, dialog, BrowserWindow } from 'electron';
import * as fs from 'fs';
import mammoth from 'mammoth';

export function registerImporterIPC(win: BrowserWindow): void {
ipcMain.handle('import:docx', async () => {
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
title: 'Import DOCX',
filters: [{ name: 'Word Document', extensions: ['docx'] }],
properties: ['openFile'],
});
if (canceled || !filePaths[0]) return { success: false, cancelled: true };

try {
const result = await mammoth.convertToHtml({ path: filePaths[0] });
return { success: true, html: result.value };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
});

ipcMain.handle('import:pdf', async () => {
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
title: 'Import PDF',
filters: [{ name: 'PDF', extensions: ['pdf'] }],
properties: ['openFile'],
});
if (canceled || !filePaths[0]) return { success: false, cancelled: true };

try {

const pdfParseModule = await import('pdf-parse');
const pdfParse = (pdfParseModule as unknown as { default: (buf: Buffer) => Promise<{ text: string }> }).default
?? (pdfParseModule as unknown as (buf: Buffer) => Promise<{ text: string }>);
const buffer = fs.readFileSync(filePaths[0]);
const data = await pdfParse(buffer);
const html = data.text
.split(/\n{2,}/)
.map((p: string) => `<p>${p.trim().replace(/\n/g, ' ')}</p>`)
.join('\n');
return { success: true, html };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
});
}
62 changes: 62 additions & 0 deletions src/main/ipc/library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ipcMain, dialog, BrowserWindow, app as electronApp } from 'electron';
import * as fs from 'fs';
import { getDatabase } from '../db/schema';
import { createKeyword, addBlurb, getKeywordsDetails } from '../db/keywords';

interface V1Blurb { keywords: string[]; description: string }
type V1Format = Record<string, V1Blurb>;

interface V2Format {
keywords: { name: string; triggers: string[]; blurbs: { label: string; content_html: string; is_default: boolean }[] }[];
}

export function registerLibraryIPC(win: BrowserWindow): void {
const db = getDatabase();

ipcMain.handle('kw:importFile', async () => {
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
title: 'Import Blurb Library',
filters: [{ name: 'JSON', extensions: ['json'] }],
properties: ['openFile'],
});
if (canceled || !filePaths[0]) return { success: false, cancelled: true };

try {
const raw = JSON.parse(fs.readFileSync(filePaths[0], 'utf8'));
let imported = 0;

if (Array.isArray(raw.keywords)) {
const data = raw as V2Format;
for (const kw of data.keywords) {
const id = createKeyword(db, kw.name, kw.triggers);
for (const b of kw.blurbs) addBlurb(db, id, b.label, b.content_html, b.is_default);
imported++;
}
} else {
const data = raw as V1Format;
for (const [name, entry] of Object.entries(data)) {
const id = createKeyword(db, name, entry.keywords);
addBlurb(db, id, 'Imported', entry.description, true);
imported++;
}
}

return { success: true, imported };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
});

ipcMain.handle('kw:exportFile', async () => {
const { filePath, canceled } = await dialog.showSaveDialog(win, {
title: 'Export Blurb Library',
defaultPath: `blurb-library-${new Date().toISOString().slice(0, 10)}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (canceled || !filePath) return { success: false, cancelled: true };

const keywords = getKeywordsDetails(db);
fs.writeFileSync(filePath, JSON.stringify({ keywords }, null, 2), 'utf8');
return { success: true, filePath };
});
}
14 changes: 11 additions & 3 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('kw:updateBlurb', id, label, contentHtml),
deleteBlurb: (id: number) => ipcRenderer.invoke('kw:deleteBlurb', id),
setDefaultBlurb: (keywordId: number, blurbId: number) =>
ipcRenderer.invoke('kw:setDefaultBlurb', keywordId, blurbId)
ipcRenderer.invoke("kw:setDefaultBlurb", keywordId, blurbId),
importFile: () => ipcRenderer.invoke('kw:importFile'),
exportFile: () => ipcRenderer.invoke('kw:exportFile'),
},

templates: {
Expand All @@ -42,6 +44,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
updateNotes: (id: number, notes: string) =>
ipcRenderer.invoke('app:updateNotes', id, notes),
saveCoverLetter: (id: number, html: string) =>
ipcRenderer.invoke('app:saveCoverLetter', id, html)
}
ipcRenderer.invoke('app:saveCoverLetter', id, html),
stats: () => ipcRenderer.invoke('app:stats')
},

importFile: {
docx: () => ipcRenderer.invoke('import:docx'),
pdf: () => ipcRenderer.invoke('import:pdf'),
},
});
92 changes: 85 additions & 7 deletions src/renderer/editor.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
export const Editor = (() => {
let root: HTMLElement;
let textArea: HTMLTextAreaElement;
let previewFrame: HTMLIFrameElement;
let mode: 'visual' | 'text' | 'preview' = 'visual';
let undoStack: string[] = [];
let redoStack: string[] = [];
const MAX_STACK = 20; // TODO: migrate this to be its own proper config/settings thing
let pushTimer: ReturnType<typeof setTimeout> | null = null;

function init(elementId: string): void {
const KATEX_HEAD = `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.css"
integrity="sha384-vlBdW0r3AcZO/HboRPznQNowvexd3fY8qHOWkBi5q7KGgqJ+F48+DceybYmrVbmB"
crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.js"
integrity="sha384-AtrdNsnxl/75rvBneBVH7DtOvCxSVahR2zWqle1coBKd8DEmLoviqNeJSx64gNAs"
crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/contrib/auto-render.min.js"
integrity="sha384-bjyGPfbij8/NDKJhSGZNP/khQVgtHUE5exjm4Ydllo42FwIgYsdLO2lXGmRBf5Mz"
crossorigin="anonymous"
onload="renderMathInElement(document.body, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false }
]
});"></script>`;

function stripDocument(html: string): string {
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const inner = bodyMatch ? bodyMatch[1] : html;
return inner
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<\/?(html|head|body)[^>]*>/gi, '')
.trim();
}

function init(elementId: string, textAreaId: string, previewId: string): void {
root = document.getElementById(elementId) as HTMLElement;
root.contentEditable = 'true';
textArea = document.getElementById(textAreaId) as HTMLTextAreaElement;
previewFrame = document.getElementById(previewId) as HTMLIFrameElement;
root.contentEditable = "true";

resetWithMarker();
pushSnapshot(true);
Expand Down Expand Up @@ -86,7 +117,8 @@ export const Editor = (() => {
root.focus();
const marker = root.querySelector('.insertion-marker');
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
// See comment under setHTML
wrapper.innerHTML = stripDocument(html);
const newMarker =
'<span class="insertion-marker" contenteditable="false">insert here</span>';

Expand All @@ -107,16 +139,59 @@ export const Editor = (() => {

function getHTML(): string {
// strip the marker otherwise it appears on the actual final output
return root.innerHTML.replace(/<span class="insertion-marker"[^>]*>.*?<\/span>/g, '');
const source = mode === 'text' ? textArea.value : root.innerHTML;
return source.replace(/<span class="insertion-marker"[^>]*>.*?<\/span>/g, '');
}

function setHTML(html: string): void {
root.innerHTML = html;
// Pretty sure the reason why the UI was breaking was because it'd try to render the full base template which
// had HTML elements of its own, this should now strip it clean.
const clean = stripDocument(html)
root.innerHTML = clean;
textArea.value = clean;
undoStack = [];
redoStack = [];
pushSnapshot(true);
}

function insertAtExp(html: string): void {
const clean = stripDocument(html);
if (mode === 'text') {
const start = textArea.selectionStart;
const end = textArea.selectionEnd;
textArea.value = textArea.value.slice(0, start) + clean + textArea.value.slice(end);
} else {
insertBlurbAtMarker(clean);
}
}

function setMode(next: 'visual' | 'text' | 'preview'): void {
if (mode === 'visual' && next !== 'visual') syncFromVisual();
if (mode === 'text' && next !== 'text') syncFromText();

mode = next;
root.style.display = mode === 'visual' ? 'block' : 'none';
textArea.style.display = mode === 'text' ? 'block' : 'none';
previewFrame.style.display = mode === 'preview' ? 'block' : 'none';

if (mode === 'preview') {
previewFrame.srcdoc = `<html><head>${KATEX_HEAD}</head><body>${getHTML()}</body></html>`;
}
}

function syncFromVisual(): void {
textArea.value = getHTML();
}

function syncFromText(): void {
root.innerHTML = textArea.value;
pushSnapshot();
}

function getMode(): string {
return mode;
}

return {
init,
exec,
Expand All @@ -127,6 +202,9 @@ export const Editor = (() => {
getHTML,
setHTML,
undo,
redo
redo,
insertAtExp,
setMode,
getMode,
};
})();
Loading
Loading