feat(electron): main process, vault, search index, links, import, capture
Vault on plain .md files with FTS5 index, link rewriting, canvas files, history/trash/backup, link previews, image fetch, tasks and unlinked mentions, Notion/Obsidian importer, quick-capture window and shortcut. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b5f8bfa8b
commit
313c41f867
16 files changed
+2706
-15
No files matched your search
@@ -0,0 +1,781 @@
|
||||
'use strict';
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell, protocol, net, Menu, session, nativeTheme, globalShortcut, screen } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { Vault, titleOf, setLimits } = require('./vault.cjs');
|
||||
const { backup, due: backupDue } = require('./backup.cjs');
|
||||
const links = require('./links.cjs');
|
||||
const tasks = require('./tasks.cjs');
|
||||
const importer = require('./importer.cjs');
|
||||
const canvas = require('./canvas.cjs');
|
||||
const linkpreview = require('./linkpreview.cjs');
|
||||
const imagefetch = require('./imagefetch.cjs');
|
||||
// What search indexes for a page: a canvas's text, not its JSON.
|
||||
const indexed = (id, body) => (canvas.isCanvasId(id) ? canvas.canvasText(body) : body);
|
||||
const { openDb } = require('./db.cjs');
|
||||
const { isEmpty, seedVault } = require('./seed.cjs');
|
||||
const settings = require('./settings.cjs');
|
||||
|
||||
const DEV = process.argv.includes('--dev');
|
||||
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
|
||||
const BUILD = path.join(__dirname, '..', 'build');
|
||||
const MAC = process.platform === 'darwin';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } },
|
||||
// Images inside the notes folder: notes-media://vault/<page id>?src=<path as written in the page>
|
||||
{ scheme: 'notes-media', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true } }
|
||||
]);
|
||||
|
||||
// The app used to be called "Notes"; its settings (notes folder, favorites, window size) lived under that name.
|
||||
(function moveOldSettings() {
|
||||
const now = path.join(app.getPath('userData'), 'settings.json');
|
||||
const old = path.join(app.getPath('appData'), 'Notes', 'settings.json');
|
||||
try {
|
||||
if (!fs.existsSync(now) && fs.existsSync(old)) {
|
||||
fs.mkdirSync(path.dirname(now), { recursive: true });
|
||||
fs.copyFileSync(old, now);
|
||||
}
|
||||
} catch (err) { console.error('Could not bring over old settings', err); }
|
||||
})();
|
||||
if (process.platform === 'win32') app.setAppUserModelId('app.ink.desktop');
|
||||
|
||||
if (!app.requestSingleInstanceLock()) app.quit();
|
||||
|
||||
let win = null;
|
||||
let vault = null;
|
||||
let db = null;
|
||||
let stopWatch = null;
|
||||
let allowClose = false;
|
||||
let quitting = false;
|
||||
|
||||
/* ---------- vault ---------- */
|
||||
|
||||
const strip = (pages) => pages.map(({ body, ...p }) => p);
|
||||
|
||||
async function snapshot(pages) {
|
||||
if (!vault) return null;
|
||||
return { path: vault.root, name: path.basename(vault.root), pages: strip(pages || (await vault.list())) };
|
||||
}
|
||||
|
||||
async function openVault(dir) {
|
||||
if (stopWatch) stopWatch();
|
||||
if (db) db.close();
|
||||
await fsp.mkdir(path.join(dir, '.notes'), { recursive: true });
|
||||
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
|
||||
db = openDb(path.join(dir, '.notes', 'index.sqlite'));
|
||||
const pages = await vault.list();
|
||||
db.rebuild(pages);
|
||||
const trashDays = prefs().trashDays;
|
||||
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
|
||||
stopWatch = vault.watch(async (ids) => {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const r = await vault.read(id);
|
||||
if (r.exists) db.upsert({ id, title: r.title, tags: r.meta.tags, props: r.meta, body: indexed(id, r.body) });
|
||||
else db.remove(id);
|
||||
} catch { /* folder events and the like */ }
|
||||
}
|
||||
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', ids);
|
||||
});
|
||||
settings.set({ vault: vault.root });
|
||||
return snapshot(pages);
|
||||
}
|
||||
|
||||
async function reindexTree(id) {
|
||||
const all = await vault.list();
|
||||
for (const p of all) if (p.exists && (p.id === id || p.id.startsWith(id + '/'))) db.upsert(p);
|
||||
}
|
||||
|
||||
// After page `from` became `to` (rename or move), point every [[link]] to it, or to a page under it, at the new place.
|
||||
async function relink(before, from, to) {
|
||||
const moved = (id) => (id === from ? to : id.startsWith(from + '/') ? to + id.slice(from.length) : null);
|
||||
const after = await vault.list();
|
||||
const touched = [];
|
||||
for (const p of after) {
|
||||
if (!p.exists) continue;
|
||||
if (canvas.isCanvasId(p.id)) {
|
||||
// (p.body is the canvas's text; the file itself is JSON)
|
||||
const next = canvas.rewriteCanvas((await vault.read(p.id)).body, before, after, moved);
|
||||
if (next === null) continue;
|
||||
await vault.write(p.id, {}, next);
|
||||
db.upsert({ id: p.id, title: p.title, tags: [], body: canvas.canvasText(next) });
|
||||
touched.push(p.id);
|
||||
continue;
|
||||
}
|
||||
const rewritten = links.rewriteLinks(p.body, before, after, moved);
|
||||
const patch = links.rewriteProps(p.props, before, after, moved); // [[links]] in a record's property values
|
||||
if (rewritten === null && !patch) continue;
|
||||
const { meta, body: raw } = await vault.read(p.id);
|
||||
const body = rewritten === null ? raw : rewritten;
|
||||
await vault.write(p.id, patch ? { ...meta, ...patch } : meta, body);
|
||||
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: { ...(p.props || {}), ...(patch || {}) }, body });
|
||||
touched.push(p.id);
|
||||
}
|
||||
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
|
||||
}
|
||||
|
||||
// A heading of page `id` was renamed: point other pages' [[Page#from]] links and ![[Page#from]] embeds at `to`.
|
||||
// (The page itself is left to the editor, which has its unsaved text.) Returns { pages: [ids changed], count: links changed }.
|
||||
async function renameHeading(id, from, to) {
|
||||
const pages = await need().list();
|
||||
const touched = [];
|
||||
let count = 0;
|
||||
for (const p of pages) {
|
||||
if (!p.exists || p.id === id) continue;
|
||||
const isCanvas = canvas.isCanvasId(p.id);
|
||||
const r = isCanvas
|
||||
? canvas.renameHeadingInCanvas((await vault.read(p.id)).body, pages, id, String(from), String(to))
|
||||
: links.renameHeadingRefs(p.body, pages, id, String(from), String(to));
|
||||
if (!r) continue;
|
||||
const { meta } = await vault.read(p.id);
|
||||
await vault.write(p.id, meta, r.body);
|
||||
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: p.props, body: indexed(p.id, r.body) });
|
||||
touched.push(p.id);
|
||||
count += r.count;
|
||||
}
|
||||
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
|
||||
return { pages: touched, count };
|
||||
}
|
||||
|
||||
async function relocate(id, op) {
|
||||
const before = await need().list();
|
||||
const next = await op();
|
||||
if (next !== id) {
|
||||
db.remove(id);
|
||||
await reindexTree(next);
|
||||
await relink(before, id, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp'] }];
|
||||
|
||||
function need() {
|
||||
if (!vault) throw new Error('No notes folder is open.');
|
||||
return vault;
|
||||
}
|
||||
|
||||
/* ---------- IPC ---------- */
|
||||
|
||||
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args));
|
||||
|
||||
handle('vault:get', () => snapshot());
|
||||
handle('vault:list', () => snapshot());
|
||||
handle('vault:pick', async (kind) => {
|
||||
let dir;
|
||||
if (kind === 'default') {
|
||||
dir = path.join(app.getPath('home'), 'Notes');
|
||||
} else {
|
||||
const r = await dialog.showOpenDialog(win, {
|
||||
title: 'Choose a folder for your notes',
|
||||
buttonLabel: 'Use this folder',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
});
|
||||
if (r.canceled || !r.filePaths[0]) return null;
|
||||
dir = r.filePaths[0];
|
||||
}
|
||||
if (await isEmpty(dir)) await seedVault(dir);
|
||||
return openVault(dir);
|
||||
});
|
||||
handle('vault:read', (id) => need().read(id));
|
||||
handle('vault:keepVersion', (id) => need().keepCurrentVersion(id));
|
||||
handle('vault:listHistory', (id) => need().listHistory(id));
|
||||
handle('vault:readHistory', (id, ts) => need().readHistory(id, ts));
|
||||
handle('vault:listTemplates', () => need().listTemplates());
|
||||
handle('vault:getTemplate', (name) => need().getTemplate(name));
|
||||
handle('vault:openTemplates', async () => shell.openPath(await need().ensureTemplatesDir())); // '' on success, else an error text
|
||||
handle('vault:restoreHistory', async (id, ts) => {
|
||||
const mtime = await need().restoreHistory(id, ts);
|
||||
const r = await vault.read(id);
|
||||
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body: indexed(id, r.body) });
|
||||
return mtime;
|
||||
});
|
||||
handle('vault:write', async (id, meta, body) => {
|
||||
const mtime = await need().write(id, meta || {}, body || '');
|
||||
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
|
||||
return mtime;
|
||||
});
|
||||
handle('vault:create', async (parent, title, meta, body = '') => {
|
||||
const id = await need().create(parent || null, title || 'Untitled', meta || {}, body);
|
||||
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
|
||||
return id;
|
||||
});
|
||||
// Many records at once (a CSV import): [{ title, meta }] -> their ids, in order.
|
||||
handle('vault:createMany', async (parent, items) => {
|
||||
const v = need();
|
||||
const ids = [];
|
||||
for (const it of Array.isArray(items) ? items.slice(0, 20000) : []) {
|
||||
const id = await v.create(parent || null, String((it && it.title) || 'Untitled'), (it && it.meta) || {}, '');
|
||||
db.upsert({ id, title: titleOf(id), tags: [], props: it && it.meta, body: '' });
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
handle('vault:createCanvas', async (parent, title) => {
|
||||
const id = await need().createCanvas(parent || null, title || 'Untitled canvas');
|
||||
db.upsert({ id, title: titleOf(id), tags: [], body: '' });
|
||||
return id;
|
||||
});
|
||||
handle('vault:duplicate', async (id) => {
|
||||
const next = await need().duplicate(id);
|
||||
await reindexTree(next);
|
||||
return next;
|
||||
});
|
||||
handle('vault:rename', (id, title) => relocate(id, () => vault.rename(id, title)));
|
||||
handle('vault:move', (id, parent) => relocate(id, () => vault.move(id, parent)));
|
||||
handle('vault:renameHeading', (id, from, to) => renameHeading(id, from, to));
|
||||
handle('vault:backlinks', async (id) => links.backlinks(id, await need().list()));
|
||||
// Every checklist item in the notes; ticking one edits the line in its file (refused if the line has changed).
|
||||
handle('vault:tasks', async () => tasks.findTasks(await need().list()));
|
||||
handle('vault:setTask', async (id, line, text, done) => {
|
||||
const v = need();
|
||||
const r = await v.read(id);
|
||||
const body = tasks.setTask(r.body, line, text, !!done);
|
||||
if (body === null) throw new Error('That task changed on disk. Reopen the list.');
|
||||
await v.write(id, r.meta, body);
|
||||
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
|
||||
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
|
||||
});
|
||||
// Pages that say this page's title without linking to it, and turning one such mention into a link.
|
||||
handle('vault:unlinked', async (id) => links.unlinked(id, await need().list()));
|
||||
handle('vault:linkMention', async (id, target) => {
|
||||
const v = need();
|
||||
const pages = await v.list();
|
||||
const r = await v.read(id);
|
||||
const body = links.linkFirstMention(r.body, target, pages);
|
||||
if (body === null) throw new Error('That mention is gone.');
|
||||
await v.write(id, r.meta, body);
|
||||
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
|
||||
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
|
||||
});
|
||||
// Tags live in front matter: rename (or, into an existing tag, merge) or remove (to === '') one in every page.
|
||||
handle('vault:renameTag', async (from, to) => {
|
||||
const v = need();
|
||||
const norm = (t) => String(t || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
|
||||
const a = norm(from), b = norm(to);
|
||||
if (!a) throw new Error('Which tag?');
|
||||
const touched = [];
|
||||
for (const p of await v.list()) {
|
||||
if (!p.exists || !(p.tags || []).some((t) => norm(t) === a)) continue;
|
||||
const r = await v.read(p.id);
|
||||
const next = [];
|
||||
for (const t of Array.isArray(r.meta.tags) ? r.meta.tags : []) {
|
||||
const x = norm(t) === a ? b : String(t);
|
||||
if (x && !next.some((y) => norm(y) === norm(x))) next.push(x);
|
||||
}
|
||||
const meta = { ...r.meta, tags: next };
|
||||
if (!next.length) delete meta.tags;
|
||||
await v.write(p.id, meta, r.body);
|
||||
db.upsert({ id: p.id, title: p.title, tags: next, props: meta, body: indexed(p.id, r.body) });
|
||||
touched.push(p.id);
|
||||
}
|
||||
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
|
||||
return touched.length;
|
||||
});
|
||||
// The whole notes folder as one .zip (the same archive the backups make).
|
||||
handle('export:vaultZip', async () => {
|
||||
const v = need();
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), `${path.basename(v.root)} ${stamp}.zip`), filters: [{ name: 'Zip archive', extensions: ['zip'] }] });
|
||||
if (r.canceled || !r.filePath) return null;
|
||||
const count = await require('./backup.cjs').zipFolder(v.root, r.filePath);
|
||||
return { path: r.filePath, count };
|
||||
});
|
||||
// Import an Obsidian vault or a Notion export (a folder or a .zip) into a new folder of the notes.
|
||||
handle('vault:import', async (kind) => {
|
||||
const v = need();
|
||||
const zip = kind === 'zip';
|
||||
const r = await dialog.showOpenDialog(win, zip
|
||||
? { title: 'Import a .zip export', properties: ['openFile'], filters: [{ name: 'Zip archive', extensions: ['zip'] }] }
|
||||
: { title: 'Import a folder of notes', properties: ['openDirectory'] });
|
||||
if (r.canceled || !r.filePaths[0]) return null;
|
||||
const res = zip ? await importer.importZip(v.root, r.filePaths[0]) : await importer.importFolder(v.root, r.filePaths[0]);
|
||||
db.rebuild(await v.list());
|
||||
return res;
|
||||
});
|
||||
// A web page's HTML for the clipper (public addresses only).
|
||||
handle('web:clip', async (url) => {
|
||||
const page = await linkpreview.fetchHtml(String(url || '').trim(), { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
|
||||
if (!page) throw new Error('Could not read that page.');
|
||||
return page;
|
||||
});
|
||||
handle('vault:graph', async () => links.graph(await need().list()));
|
||||
handle('vault:saveImage', async (id, name, bytes) => {
|
||||
if (!(bytes instanceof Uint8Array) || bytes.length > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
|
||||
return need().saveImage(id, name, Buffer.from(bytes));
|
||||
});
|
||||
// A picture from a web address, saved next to page `id`; returns its file name.
|
||||
handle('vault:saveImageFromUrl', async (id, url) => {
|
||||
const v = need();
|
||||
const img = await imagefetch.fetchImage(url, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
|
||||
return v.saveImage(id, img.name, img.bytes);
|
||||
});
|
||||
handle('vault:pickImage', async (id) => {
|
||||
const v = need();
|
||||
const r = await dialog.showOpenDialog(win, { title: 'Add an image', properties: ['openFile'], filters: IMAGE_TYPES });
|
||||
if (r.canceled || !r.filePaths[0]) return null;
|
||||
return v.saveImage(id, path.basename(r.filePaths[0]), await fsp.readFile(r.filePaths[0]));
|
||||
});
|
||||
// Any file, for a database record's Files property: copied next to the record, its name returned.
|
||||
handle('vault:pickFile', async (id) => {
|
||||
const v = need();
|
||||
const r = await dialog.showOpenDialog(win, { title: 'Attach files', properties: ['openFile', 'multiSelections'] });
|
||||
if (r.canceled || !r.filePaths.length) return [];
|
||||
const names = [];
|
||||
for (const f of r.filePaths) {
|
||||
if ((await fsp.stat(f)).size > 50 * 1024 * 1024) throw new Error(`${path.basename(f)} is too big (50 MB at most).`);
|
||||
names.push(await v.saveImage(id, path.basename(f), await fsp.readFile(f)));
|
||||
}
|
||||
return names;
|
||||
});
|
||||
// Open a file attached to a record with the system's app for it (only files inside the notes folder).
|
||||
handle('vault:openFile', async (id, src) => {
|
||||
const file = need().imagePath(id, src);
|
||||
if (!file) throw new Error('That file is not in the notes folder.');
|
||||
const err = await shell.openPath(file); // '' on success
|
||||
if (err) throw new Error(err);
|
||||
});
|
||||
handle('vault:remove', async (id) => {
|
||||
const entry = await need().remove(id);
|
||||
db.remove(id);
|
||||
return entry;
|
||||
});
|
||||
handle('vault:trash', () => need().listTrash());
|
||||
handle('vault:restore', async (entry) => {
|
||||
const id = await need().restore(entry);
|
||||
await reindexTree(id);
|
||||
return id;
|
||||
});
|
||||
handle('vault:purge', (entry) => need().purge(entry || null));
|
||||
handle('vault:search', (q) => (db ? db.search(q) : []));
|
||||
handle('vault:reveal', async (id) => {
|
||||
const v = need();
|
||||
if (!id) return shell.openPath(v.root);
|
||||
const file = v.abs(id);
|
||||
shell.showItemInFolder(fs.existsSync(file) ? file : v.dir(id));
|
||||
});
|
||||
handle('vault:filePath', (id) => need().abs(id));
|
||||
handle('export:save', async (name, content) => {
|
||||
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
|
||||
if (r.canceled || !r.filePath) return null;
|
||||
await fsp.writeFile(r.filePath, content, 'utf8');
|
||||
return r.filePath;
|
||||
});
|
||||
|
||||
handle('export:pdf', async (name, title, htmlContent) => {
|
||||
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
|
||||
if (r.canceled || !r.filePath) return null;
|
||||
|
||||
// We create a hidden offscreen window to render the pristine HTML document
|
||||
const pdfWin = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false } });
|
||||
await pdfWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(htmlContent));
|
||||
|
||||
const data = await pdfWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
margins: { marginType: 'default' }
|
||||
});
|
||||
|
||||
pdfWin.destroy();
|
||||
|
||||
await fsp.writeFile(r.filePath, data);
|
||||
return r.filePath;
|
||||
});
|
||||
|
||||
// The title and description of a web page, for link cards on a canvas ({ off: true } when the user turned that off).
|
||||
const previewCache = new Map(); // url -> { at, value }
|
||||
handle('web:preview', async (url) => {
|
||||
if (settings.get().linkPreviews === false) return { off: true };
|
||||
const key = String(url || '');
|
||||
const hit = previewCache.get(key);
|
||||
if (hit && Date.now() - hit.at < (hit.value ? 3600e3 : 300e3)) return hit.value;
|
||||
const value = await linkpreview.fetchPreview(key, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
|
||||
previewCache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
});
|
||||
|
||||
// A canvas as a picture or a PDF: `html` is a self-contained page (see src/lib/canvasExport.js), `width` × `height` its size.
|
||||
handle('export:canvas', async (name, kind, html, width, height) => {
|
||||
const ext = kind === 'pdf' ? 'pdf' : 'png';
|
||||
const r = await dialog.showSaveDialog(win, {
|
||||
defaultPath: path.join(app.getPath('documents'), name),
|
||||
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
|
||||
});
|
||||
if (r.canceled || !r.filePath) return null;
|
||||
const page = path.join(app.getPath('temp'), `ink-canvas-${process.pid}-${Date.now()}.html`);
|
||||
await fsp.writeFile(page, html, 'utf8');
|
||||
const w = Math.max(1, Math.ceil(width)), h = Math.max(1, Math.ceil(height));
|
||||
const out = new BrowserWindow({
|
||||
show: false, width: w, height: h, useContentSize: true, frame: false, enableLargerThanScreen: true,
|
||||
webPreferences: { sandbox: true, backgroundThrottling: false }
|
||||
});
|
||||
try {
|
||||
await out.loadFile(page);
|
||||
// fonts and pictures in place before it is taken
|
||||
await out.webContents.executeJavaScript(
|
||||
'document.fonts.ready.then(() => Promise.all([...document.images].map((i) => i.decode().catch(() => null))))'
|
||||
);
|
||||
const data = ext === 'pdf'
|
||||
? await out.webContents.printToPDF({ printBackground: true, preferCSSPageSize: true, margins: { marginType: 'none' } })
|
||||
: (await out.webContents.capturePage()).toPNG();
|
||||
await fsp.writeFile(r.filePath, data);
|
||||
return r.filePath;
|
||||
} finally {
|
||||
out.destroy();
|
||||
fsp.rm(page, { force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('export:print', () => {
|
||||
win.webContents.print({
|
||||
printBackground: true,
|
||||
margins: { marginType: 'default' }
|
||||
});
|
||||
});
|
||||
handle('settings:get', () => settings.get());
|
||||
handle('settings:set', (patch) => {
|
||||
const next = settings.set(patch || {});
|
||||
if (patch && patch.prefs) applyPrefs();
|
||||
return next;
|
||||
});
|
||||
|
||||
/* prefs (see src/lib/prefs.js): the ones only this side can act on */
|
||||
function prefs() {
|
||||
const p = settings.get().prefs;
|
||||
return {
|
||||
trashDays: 30, historyKeep: 100, historyGapMin: 5, alwaysOnTop: false, startMinimized: false,
|
||||
backupEvery: 'off', backupKeep: 5, backupFolder: '',
|
||||
captureShortcut: 'CommandOrControl+Alt+N', captureTo: 'daily',
|
||||
...(p && typeof p === 'object' ? p : {})
|
||||
};
|
||||
}
|
||||
function applyPrefs() {
|
||||
const p = prefs();
|
||||
setLimits(p);
|
||||
if (win && !win.isDestroyed()) win.setAlwaysOnTop(!!p.alwaysOnTop);
|
||||
applyCaptureShortcut(p.captureShortcut);
|
||||
}
|
||||
|
||||
/* quick capture: a global shortcut opens a small window; what is typed is added to today's note (or an Inbox page)
|
||||
by the main window, which knows the daily-note settings. Captures wait in a queue until it takes them. */
|
||||
let captureWin = null;
|
||||
let captureKey = '';
|
||||
const captures = [];
|
||||
|
||||
function applyCaptureShortcut(accel) {
|
||||
const want = String(accel || '').trim();
|
||||
if (want === captureKey) return;
|
||||
if (captureKey) { try { globalShortcut.unregister(captureKey); } catch { /* not registered */ } }
|
||||
captureKey = '';
|
||||
if (!want) return;
|
||||
try {
|
||||
if (globalShortcut.register(want, showCapture)) captureKey = want;
|
||||
else console.error('Quick capture shortcut not available:', want);
|
||||
} catch (err) { console.error('Quick capture shortcut not valid:', want, err.message); }
|
||||
}
|
||||
|
||||
function showCapture() {
|
||||
if (!vault) return;
|
||||
if (captureWin && !captureWin.isDestroyed()) { captureWin.show(); captureWin.focus(); return; }
|
||||
const at = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
|
||||
const w = 560, h = 190;
|
||||
captureWin = new BrowserWindow({
|
||||
width: w, height: h, x: Math.round(at.x + (at.width - w) / 2), y: Math.round(at.y + at.height * 0.22),
|
||||
frame: false, resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, alwaysOnTop: true,
|
||||
show: false, title: 'Quick capture', backgroundColor: '#161617',
|
||||
webPreferences: { preload: path.join(__dirname, 'capture-preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false }
|
||||
});
|
||||
captureWin.loadFile(path.join(__dirname, 'capture.html'));
|
||||
captureWin.once('ready-to-show', () => captureWin.show());
|
||||
captureWin.on('blur', () => { if (captureWin && !captureWin.isDestroyed() && !captureWin.webContents.isDevToolsOpened()) captureWin.close(); });
|
||||
captureWin.on('closed', () => { captureWin = null; });
|
||||
}
|
||||
|
||||
ipcMain.handle('capture:info', () => {
|
||||
const s = settings.get();
|
||||
const dark = s.theme === 'dark' || (s.theme !== 'light' && nativeTheme.shouldUseDarkColors);
|
||||
return { theme: dark ? 'dark' : 'light', to: prefs().captureTo === 'inbox' ? 'inbox' : 'daily', vault: vault ? path.basename(vault.root) : '' };
|
||||
});
|
||||
ipcMain.handle('capture:save', (_e, text, to) => {
|
||||
const t = String(text || '').trim().slice(0, 20000);
|
||||
if (!t) return false;
|
||||
captures.push({ text: t, to: to === 'inbox' ? 'inbox' : 'daily' });
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('capture:ping');
|
||||
} else if (MAC && app.isReady()) {
|
||||
allowClose = false;
|
||||
createWindow(); // the page takes the queue when it starts
|
||||
}
|
||||
if (captureWin && !captureWin.isDestroyed()) captureWin.close();
|
||||
return true;
|
||||
});
|
||||
ipcMain.on('capture:close', () => { if (captureWin && !captureWin.isDestroyed()) captureWin.close(); });
|
||||
handle('capture:take', () => captures.splice(0));
|
||||
handle('capture:open', () => showCapture());
|
||||
|
||||
/* backups */
|
||||
const backupDir = () => prefs().backupFolder || path.join(app.getPath('userData'), 'backups');
|
||||
async function runBackup() {
|
||||
if (!vault) throw new Error('Open a notes folder first.');
|
||||
const r = await backup(vault.root, backupDir(), prefs().backupKeep);
|
||||
settings.set({ lastBackup: Date.now() });
|
||||
return r;
|
||||
}
|
||||
async function backupIfDue() {
|
||||
const p = prefs();
|
||||
if (!vault || !backupDue(p.backupEvery, settings.get().lastBackup)) return;
|
||||
try { await runBackup(); } catch (err) { console.error('Could not back up', err); }
|
||||
}
|
||||
handle('backup:now', async () => {
|
||||
const r = await runBackup();
|
||||
return { file: r.file, count: r.count };
|
||||
});
|
||||
handle('backup:info', () => ({ folder: backupDir(), last: settings.get().lastBackup || 0 }));
|
||||
handle('backup:pickFolder', async () => {
|
||||
const r = await dialog.showOpenDialog(win, { title: 'Folder for backups', properties: ['openDirectory', 'createDirectory'] });
|
||||
return r.canceled || !r.filePaths[0] ? null : r.filePaths[0];
|
||||
});
|
||||
handle('backup:reveal', async () => {
|
||||
const dir = backupDir();
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
shell.openPath(dir);
|
||||
return true;
|
||||
});
|
||||
|
||||
/* custom dictionary */
|
||||
handle('spell:words', async () => (await session.defaultSession.listWordsInSpellCheckerDictionary()).sort((a, b) => a.localeCompare(b)));
|
||||
handle('spell:addWord', (word) => session.defaultSession.addWordToSpellCheckerDictionary(String(word).trim().slice(0, 80)));
|
||||
handle('spell:removeWord', (word) => session.defaultSession.removeWordFromSpellCheckerDictionary(String(word)));
|
||||
|
||||
/* spellcheck: on/off and languages (macOS always uses the system's own languages) */
|
||||
function applySpellcheck() {
|
||||
const s = settings.get();
|
||||
const ses = session.defaultSession;
|
||||
if (!MAC) {
|
||||
const langs = (Array.isArray(s.spellLangs) ? s.spellLangs : []).filter((l) => ses.availableSpellCheckerLanguages.includes(l));
|
||||
if (langs.length) ses.setSpellCheckerLanguages(langs);
|
||||
}
|
||||
// After the languages: setting them switches spellcheck back on.
|
||||
ses.setSpellCheckerEnabled(s.spellcheck !== false);
|
||||
}
|
||||
handle('spell:get', () => {
|
||||
const ses = session.defaultSession;
|
||||
return {
|
||||
enabled: ses.isSpellCheckerEnabled(),
|
||||
languages: MAC ? [] : ses.getSpellCheckerLanguages(),
|
||||
available: MAC ? [] : ses.availableSpellCheckerLanguages,
|
||||
system: MAC
|
||||
};
|
||||
});
|
||||
handle('spell:set', (enabled, langs) => {
|
||||
settings.set({ spellcheck: !!enabled, spellLangs: Array.isArray(langs) ? langs.slice(0, 3) : [] });
|
||||
applySpellcheck();
|
||||
return true;
|
||||
});
|
||||
handle('win:isMaximized', () => !!win && win.isMaximized());
|
||||
ipcMain.on('win:minimize', () => win && win.minimize());
|
||||
ipcMain.on('win:toggleMaximize', () => win && (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win && win.close());
|
||||
// What the custom right-click menu asks for.
|
||||
const EDIT_ACTIONS = new Set(['cut', 'copy', 'paste', 'selectAll']);
|
||||
ipcMain.on('edit:do', (e, action) => { if (EDIT_ACTIONS.has(action)) e.sender[action](); });
|
||||
ipcMain.on('edit:copyImageAt', (e, x, y) => e.sender.copyImageAt(Math.round(x), Math.round(y)));
|
||||
ipcMain.on('edit:replaceMisspelling', (e, word) => e.sender.replaceMisspelling(String(word)));
|
||||
ipcMain.on('edit:addToDictionary', (e, word) => e.sender.session.addWordToSpellCheckerDictionary(String(word)));
|
||||
ipcMain.on('app:flushed', () => {
|
||||
allowClose = true;
|
||||
if (quitting) app.quit();
|
||||
else if (win && !win.isDestroyed()) win.close();
|
||||
});
|
||||
|
||||
/* ---------- window ---------- */
|
||||
|
||||
function createWindow() {
|
||||
const s = settings.get();
|
||||
const b = s.bounds || {};
|
||||
win = new BrowserWindow({
|
||||
width: b.width || 1280,
|
||||
height: b.height || 840,
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
title: 'Ink',
|
||||
icon: MAC ? undefined : path.join(__dirname, '..', 'resources', 'icons', '512x512.png'),
|
||||
backgroundColor: /^#[0-9a-f]{6}$/i.test(s.ground) ? s.ground : (s.theme === 'system' ? !nativeTheme.shouldUseDarkColors : s.theme === 'light') ? '#e7e7e9' : '#0e0e0f',
|
||||
// macOS keeps its traffic lights; elsewhere the page draws its own controls (WindowControls.svelte).
|
||||
frame: MAC,
|
||||
titleBarStyle: MAC ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: { x: 22, y: 24 },
|
||||
autoHideMenuBar: !MAC,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: true
|
||||
}
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show();
|
||||
if (prefs().startMinimized) win.minimize();
|
||||
});
|
||||
win.on('maximize', () => win.webContents.send('win:maximized', true));
|
||||
win.on('unmaximize', () => win.webContents.send('win:maximized', false));
|
||||
|
||||
win.on('app-command', (e, cmd) => {
|
||||
if (cmd === 'browser-backward') {
|
||||
e.preventDefault();
|
||||
win.webContents.send('nav:go', 'back');
|
||||
} else if (cmd === 'browser-forward') {
|
||||
e.preventDefault();
|
||||
win.webContents.send('nav:go', 'forward');
|
||||
}
|
||||
});
|
||||
|
||||
// Let the page save pending edits before the window goes away.
|
||||
win.on('close', (e) => {
|
||||
settings.set({ bounds: win.getBounds() });
|
||||
if (allowClose) return;
|
||||
e.preventDefault();
|
||||
win.webContents.send('app:flush');
|
||||
setTimeout(() => {
|
||||
allowClose = true;
|
||||
if (quitting) app.quit();
|
||||
else if (!win.isDestroyed()) win.close();
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('will-navigate', (e, url) => {
|
||||
if (!url.startsWith(DEV ? DEV_URL : 'app://')) {
|
||||
e.preventDefault();
|
||||
if (/^https?:/i.test(url)) shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Spelling suggestions and copy/paste on right click.
|
||||
// Right-click: the page draws its own menu (TextMenu.svelte). Only this side knows the spelling suggestions,
|
||||
// so pass along what was clicked; the page's choice comes back through 'edit:*' below.
|
||||
win.webContents.on('context-menu', (_e, p) => {
|
||||
win.webContents.send('app:context-menu', {
|
||||
x: p.x, y: p.y,
|
||||
isEditable: p.isEditable,
|
||||
selectionText: p.selectionText || '',
|
||||
misspelledWord: p.misspelledWord || '',
|
||||
suggestions: (p.dictionarySuggestions || []).slice(0, 5),
|
||||
linkURL: p.linkURL || '',
|
||||
mediaType: p.mediaType || 'none',
|
||||
can: { cut: p.editFlags.canCut, copy: p.editFlags.canCopy, paste: p.editFlags.canPaste, selectAll: p.editFlags.canSelectAll }
|
||||
});
|
||||
});
|
||||
|
||||
if (DEV) {
|
||||
win.loadURL(DEV_URL);
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
win.loadURL('app://notes/');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Linux desktop integration ---------- */
|
||||
|
||||
// Docks and launchers (GNOME, KDE…) find a window's icon through an installed .desktop file whose name matches
|
||||
// the window's app id ("ink", from desktopName in package.json). An AppImage installs nothing by itself, so when
|
||||
// we run from one, put a launcher and the icons in the user's data folder, and keep the launcher pointing at
|
||||
// wherever the AppImage is now.
|
||||
function integrateAppImage() {
|
||||
const image = process.env.APPIMAGE;
|
||||
if (process.platform !== 'linux' || !image) return;
|
||||
const data = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share');
|
||||
const launcher = path.join(data, 'applications', 'ink.desktop');
|
||||
const icons = path.join(__dirname, '..', 'resources', 'icons');
|
||||
// A fingerprint of the icon, so a new icon gets copied even when nothing else in the launcher changed.
|
||||
let iconId = '';
|
||||
try { iconId = require('node:crypto').createHash('sha1').update(fs.readFileSync(path.join(icons, '512x512.png'))).digest('hex').slice(0, 12); } catch { /* no icon */ }
|
||||
const entry = [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Ink',
|
||||
'Comment=A calm, offline Markdown notes app',
|
||||
`Exec="${image.replace(/(["`$\\])/g, '\\$1')}" %U`,
|
||||
'Icon=ink',
|
||||
'Terminal=false',
|
||||
'Categories=Office;',
|
||||
'Keywords=notes;markdown;writing;kanban;journal;',
|
||||
'StartupWMClass=ink',
|
||||
`X-AppImage-Version=${app.getVersion()}`,
|
||||
`X-Ink-Icon=${iconId}`,
|
||||
''
|
||||
].join('\n');
|
||||
try {
|
||||
if (fs.existsSync(launcher) && fs.readFileSync(launcher, 'utf8') === entry) return;
|
||||
for (const f of fs.readdirSync(icons)) {
|
||||
const size = f.replace(/\.png$/, '');
|
||||
if (!/^\d+x\d+$/.test(size)) continue;
|
||||
const dir = path.join(data, 'icons', 'hicolor', size, 'apps');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.copyFileSync(path.join(icons, f), path.join(dir, 'ink.png'));
|
||||
}
|
||||
fs.mkdirSync(path.dirname(launcher), { recursive: true });
|
||||
fs.writeFileSync(launcher, entry);
|
||||
} catch (err) {
|
||||
console.error('Could not add the desktop launcher', err);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
integrateAppImage();
|
||||
applySpellcheck();
|
||||
protocol.handle('app', (req) => {
|
||||
const { pathname } = new URL(req.url);
|
||||
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
let file = path.normalize(path.join(BUILD, rel));
|
||||
if (file !== BUILD && !file.startsWith(BUILD + path.sep)) return new Response('Not found', { status: 404 });
|
||||
let isFile = false;
|
||||
try { isFile = fs.statSync(file).isFile(); } catch { /* missing */ }
|
||||
if (!isFile) {
|
||||
if (rel.startsWith('_app/')) return new Response('Not found', { status: 404 });
|
||||
file = path.join(BUILD, 'index.html'); // SPA fallback
|
||||
}
|
||||
return net.fetch(pathToFileURL(file).toString());
|
||||
});
|
||||
protocol.handle('notes-media', (req) => {
|
||||
const u = new URL(req.url);
|
||||
const id = decodeURIComponent(u.pathname.replace(/^\/+/, ''));
|
||||
const file = vault && id && vault.imagePath(id, u.searchParams.get('src') || '');
|
||||
if (!file || !fs.existsSync(file)) return new Response('Not found', { status: 404 });
|
||||
return net.fetch(pathToFileURL(file).toString());
|
||||
});
|
||||
|
||||
const s = settings.get();
|
||||
if (s.vault && fs.existsSync(s.vault)) {
|
||||
try { await openVault(s.vault); } catch (err) { console.error('Could not open notes folder', err); }
|
||||
}
|
||||
createWindow();
|
||||
applyPrefs();
|
||||
backupIfDue();
|
||||
setInterval(backupIfDue, 3600e3).unref();
|
||||
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { allowClose = false; createWindow(); } });
|
||||
});
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
});
|
||||
app.on('window-all-closed', () => { if (!MAC) app.quit(); });
|
||||
app.on('before-quit', () => { quitting = true; });
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
if (stopWatch) stopWatch();
|
||||
if (db) db.close();
|
||||
settings.flush();
|
||||
});
|
||||
Reference in new issue
Block a user