Compare commits

...

10 Commits

Author SHA1 Message Date
cmounce e0fce6ef03 Add Vite build plugin to generate version.txt 2026-02-03 22:10:59 -08:00
cmounce 4223c0c9f5 Fix type error with fileId not being set 2026-02-01 02:54:24 -08:00
cmounce cf6223b512 Fix name 2026-01-09 02:53:47 -08:00
cmounce 6bcb712b02 Update packages 2026-01-08 02:51:18 -08:00
cmounce 5c7a5cebff Add initial README file 2026-01-08 02:45:28 -08:00
cmounce 86b2f3329a Add better-sqlite3 to Node backend 2026-01-06 03:31:49 -08:00
cmounce 48a044cbd5 Add initial dev server 2025-12-09 03:30:39 -08:00
cmounce b695437415 Tear out old code and turn on compaction 2025-09-26 01:05:10 -07:00
cmounce c53f596d1d More debug stats, sizes of blobs 2025-09-25 23:54:15 -07:00
cmounce 4110eaf45a Log timing info for document load 2025-09-24 09:15:31 -07:00
17 changed files with 2957 additions and 507 deletions
+1
View File
@@ -1,2 +1,3 @@
dist/ dist/
node_modules/ node_modules/
server/data/
+25
View File
@@ -0,0 +1,25 @@
# Clementine
Clementine is a no-frills, self-hosted plain text editor.
It doesn't do fancy Markdown formatting or hyperlinks, though you can certainly still use it to write Markdown!
All it does is get out of your way and give you a place to type.
If that's all you wanted, Clementine might be for you.
Users beware: Clementine is currently at a very early stage of development!
I can't guarantee it won't eat your work, and it (just barely) meets my own needs.
## Planned features
Clementine is already architected to be offline-friendly: all data is in IndexedDB.
It's not a proper PWA yet, so you do need an internet connection on mobile in order to initially load the page.
But once loaded, no further connection is needed.
In order to support cross-device sync in the future, all data is represented using CRDTs (specifically, [Yjs](https://yjs.dev/)).
When you host Clementine, your server will track the various CRDT deltas in a SQLite database file, and your clients will periodically sync themselves with the contents of that database.
The intent is to allow "collaboration with yourself".
For example, you ought to be able to go back and forth between writing on your phone and revising on your laptop, without fear of the two devices accidentally overwriting each other's changes.
The sync system is architected so the server does not need to run Yjs itself; all the server needs to do is pass opaque blobs among the clients.
This restriction means the backend can potentially be very lightweight and have implementations in other languages besides JS.
It also allows the data to be encrypted client-side, using a key the server doesn't have.
(The amount of security this actually adds depends on your threat model. But if you sleep better with a padlock on your diary, this might give you some peace of mind.)
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<title>Story Editor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:;base64,iVBORw0KGgo=" /> <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<title>Story Editor</title> <title>Clementine</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+758 -375
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,5 +1,5 @@
{ {
"name": "story-editor", "name": "clementine",
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"license": "ISC", "license": "ISC",
@@ -7,26 +7,28 @@
"type": "commonjs", "type": "commonjs",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently 'vite' 'npm run dev:backend'",
"dev:backend": "cd server/node && npm run dev",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.21",
"@types/node": "24.3.0", "@types/node": "24.3.0",
"prettier": "^3.6.2", "concurrently": "^9.2.1",
"typescript": "^5.9.2", "prettier": "^3.7.4",
"vite": "^7.1.1", "typescript": "^5.9.3",
"vite-plugin-solid": "^2.10.2" "vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}, },
"dependencies": { "dependencies": {
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.4",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"solid-js": "^1.9.2", "solid-js": "^1.9.10",
"y-codemirror.next": "^0.3.5", "y-codemirror.next": "^0.3.5",
"yjs": "^13.6.27" "yjs": "^13.6.29"
} }
} }
+1927
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "clementine-backend",
"version": "0.1.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "index.ts",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"better-sqlite3": "^12.5.0",
"express": "^5.2.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+37
View File
@@ -0,0 +1,37 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let db: Database.Database | null = null;
export function getDatabase(): Database.Database {
if (!db) {
db = openDatabase();
}
return db;
}
function openDatabase() {
const dbPath = path.join(__dirname, '../../data/database.db');
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
console.log(`Database connected at ${dbPath}`);
return db;
}
function closeDatabase() {
if (db) {
db.close();
db = null;
console.log('Database connection closed');
}
}
process.on('SIGINT', closeDatabase);
process.on('SIGTERM', closeDatabase);
+25
View File
@@ -0,0 +1,25 @@
import express from 'express';
import { getDatabase } from './db';
const db = getDatabase();
const app = express();
const PORT = 3001;
app.use(express.json());
app.get('/api/hello', (req, res) => {
res.json({ message: 'Hello World!' });
});
app.get('/api/db', (req, res) => {
const result = db.prepare('SELECT 1 + 2 AS test').get();
res.json(result);
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));
+3 -1
View File
@@ -11,6 +11,7 @@ import Editor from './Editor';
import { HashRouter, Route, useNavigate } from '@solidjs/router'; import { HashRouter, Route, useNavigate } from '@solidjs/router';
import { defaultVault } from '../sync'; import { defaultVault } from '../sync';
import { getDocsMap } from '../vault'; import { getDocsMap } from '../vault';
import DebugView from './Debug';
interface NavbarProps { interface NavbarProps {
fileId: Accessor<string | null>; fileId: Accessor<string | null>;
@@ -33,7 +34,7 @@ function Layout(props: any) {
const navigate = useNavigate(); const navigate = useNavigate();
const [fileId, setFileId] = createSignal<string | null>(null); const [fileId, setFileId] = createSignal<string | null>(null);
const [numUpdates, setNumUpdates] = createSignal<number | null>(null); const [numUpdates, setNumUpdates] = createSignal<number | null>(null);
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
const title = () => { const title = () => {
const id = fileId(); const id = fileId();
@@ -67,6 +68,7 @@ function App() {
<HashRouter root={Layout}> <HashRouter root={Layout}>
<Route path="" component={Chooser} /> <Route path="" component={Chooser} />
<Route path="doc/:id" component={Editor} /> <Route path="doc/:id" component={Editor} />
<Route path="debug" component={DebugView} />
</HashRouter> </HashRouter>
); );
} }
+1 -1
View File
@@ -12,7 +12,7 @@ function Chooser() {
const { setFileId } = useNavbar(); const { setFileId } = useNavbar();
onMount(() => setFileId(null)); onMount(() => setFileId(null));
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
const computeDocsList = () => { const computeDocsList = () => {
const result: DocInfo[] = []; const result: DocInfo[] = [];
+19
View File
@@ -0,0 +1,19 @@
import { createSignal, For } from 'solid-js';
const [getDebugLogs, setDebugLogs] = createSignal<string[]>([]);
export function debugLog(message: string, ...args: any[]) {
console.log(message, ...args);
const components = [message, ...args.map((x) => JSON.stringify(x))];
const line = components.join(' ');
const allLines = [...getDebugLogs(), line];
if (allLines.length > 1000) {
allLines.splice(0, allLines.length - 1000);
}
setDebugLogs(allLines);
}
export default function DebugView() {
return <For each={getDebugLogs()}>{(logLine) => <div>{logLine}</div>}</For>;
}
+15 -9
View File
@@ -13,13 +13,16 @@ import {
onMount, onMount,
Show, Show,
} from 'solid-js'; } from 'solid-js';
import { LocalDocument } from '../sync';
import { useNavbar } from './App'; import { useNavbar } from './App';
import { useParams } from '@solidjs/router'; import { useParams } from '@solidjs/router';
import _ from 'lodash'; import _ from 'lodash';
import { closeDoc, getDocStats, openDoc } from '../persistence';
import { assert } from '../util';
function Editor() { function Editor() {
const { id: fileId } = useParams(); const { id: fileId } = useParams();
assert(fileId, 'Editor component loaded without a file');
const { setFileId, setNumUpdates } = useNavbar(); const { setFileId, setNumUpdates } = useNavbar();
onMount(() => { onMount(() => {
setFileId(fileId); setFileId(fileId);
@@ -27,25 +30,25 @@ function Editor() {
let editorDiv; let editorDiv;
let editorView: EditorView; let editorView: EditorView;
const [syncedDoc] = createResource(async () => LocalDocument.load(fileId)); const [syncedDoc] = createResource(async () => openDoc(fileId));
createEffect(() => { createEffect(() => {
const doc = syncedDoc(); const doc = syncedDoc();
if (!doc) { if (!doc) {
return; return;
} }
const ydoc = doc.doc;
const getNumRecords = () => getDocStats(doc).numRecords;
const refreshNumUpdates = _.debounce(() => { const refreshNumUpdates = _.debounce(() => {
setNumUpdates(doc.numUpdates); setNumUpdates(getNumRecords());
}, 100); }, 100);
setNumUpdates(doc.numUpdates); setNumUpdates(getNumRecords());
doc.doc.on('update', () => { doc.on('update', () => {
setTimeout(refreshNumUpdates, 1500); setTimeout(refreshNumUpdates, 1500);
}); });
const state = EditorState.create({ const state = EditorState.create({
doc: ydoc.getText().toString(), doc: doc.getText().toString(),
extensions: [ extensions: [
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.contentAttributes.of({ EditorView.contentAttributes.of({
@@ -56,7 +59,7 @@ function Editor() {
keymap.of([...defaultKeymap, ...historyKeymap]), keymap.of([...defaultKeymap, ...historyKeymap]),
scrollPastEnd(), scrollPastEnd(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
yCollab(ydoc.getText(), null), yCollab(doc.getText(), null),
], ],
}); });
editorView = new EditorView({ state, parent: editorDiv }); editorView = new EditorView({ state, parent: editorDiv });
@@ -65,7 +68,10 @@ function Editor() {
onCleanup(() => { onCleanup(() => {
setNumUpdates(null); setNumUpdates(null);
editorView?.destroy(); editorView?.destroy();
syncedDoc()?.finish(); const doc = syncedDoc();
if (doc) {
closeDoc(doc);
}
}); });
return ( return (
+37 -35
View File
@@ -2,70 +2,65 @@ import * as Y from 'yjs';
import { docsDb, LOCAL_DELTA_STORE } from './sync'; import { docsDb, LOCAL_DELTA_STORE } from './sync';
import { assert } from './util'; import { assert } from './util';
import _ from 'lodash'; import _ from 'lodash';
import { debugLog } from './components/Debug';
const COMPACTION_THRESHOLD = 200; const COMPACTION_THRESHOLD = 50;
export async function openDoc(id: string): Promise<Y.Doc> { export async function openDoc(id: string): Promise<Y.Doc> {
// Return existing doc if we still have it open // Return existing doc if we still have it open
const previousState = idToState.get(id); const previousState = idToState.get(id);
if (previousState) { if (previousState) {
previousState.refCount += 1; previousState.refCount += 1;
return previousState.ydoc; return previousState.savedDoc.ydoc;
} }
const state = await initialLoad(id); const state: State = {
refCount: 1,
savedDoc: new SavedDoc(id),
};
idToState.set(id, state); idToState.set(id, state);
docToState.set(state.ydoc, state); docToId.set(state.savedDoc.ydoc, id);
await state.savedDoc.connect();
debugLog(`Opened doc ${id}`);
return state.ydoc; return state.savedDoc.ydoc;
}
export function getDocStats(doc: Y.Doc) {
const id = docToId.get(doc);
assert(id, 'Document is not already open');
const state = idToState.get(id)!;
return state.savedDoc.getInfo();
} }
export function closeDoc(doc: Y.Doc) { export function closeDoc(doc: Y.Doc) {
const state = docToState.get(doc); const id = docToId.get(doc);
assert(state, 'Document is not already open'); assert(id, 'Document is not already open');
const state = idToState.get(id)!;
state.refCount -= 1; state.refCount -= 1;
if (state.refCount <= 0) { if (state.refCount <= 0) {
idToState.delete(state.id); idToState.delete(id);
docToState.delete(doc); docToId.delete(doc);
doc.destroy(); state.savedDoc.flush().then(() => doc.destroy());
} }
debugLog(`Closed doc ${id} (refcount ${state.refCount})`);
} }
const docToId = new Map<Y.Doc, string>();
const idToState = new Map<string, State>(); const idToState = new Map<string, State>();
const docToState = new Map<Y.Doc, State>();
type State = { type State = {
// TODO: Move more of this into class
id: string;
deltaBuffer: Uint8Array[];
numRecords: number;
refCount: number; refCount: number;
ydoc: Y.Doc; savedDoc: SavedDoc;
}; };
async function initialLoad(id: string): Promise<State> { class SavedDoc {
const ydoc = new Y.Doc(); readonly docId: string;
const records = await docsDb.getAll( readonly ydoc: Y.Doc;
LOCAL_DELTA_STORE,
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
);
Y.applyUpdate(ydoc, Y.mergeUpdates(records));
return {
id,
deltaBuffer: [],
numRecords: records.length,
refCount: 1,
ydoc,
};
}
class BufferedWriter {
private deltaBuffer: Uint8Array[]; private deltaBuffer: Uint8Array[];
private docId: string;
private numRecords: number; private numRecords: number;
private debouncedFlush: () => void; private debouncedFlush: () => void;
private ydoc: Y.Doc;
constructor(docId: string) { constructor(docId: string) {
this.deltaBuffer = []; this.deltaBuffer = [];
@@ -81,6 +76,10 @@ class BufferedWriter {
this.ydoc = new Y.Doc(); this.ydoc = new Y.Doc();
} }
public getInfo(): { numRecords: number } {
return { numRecords: this.numRecords };
}
public async connect(): Promise<Y.Doc> { public async connect(): Promise<Y.Doc> {
const records = await docsDb.getAll( const records = await docsDb.getAll(
LOCAL_DELTA_STORE, LOCAL_DELTA_STORE,
@@ -128,6 +127,9 @@ class BufferedWriter {
if (this.numRecords < COMPACTION_THRESHOLD) { if (this.numRecords < COMPACTION_THRESHOLD) {
return; return;
} }
debugLog(
`Running compaction on doc ${this.docId}, ${this.numRecords} records...`
);
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite'); const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
// Make sure we're not missing anything by pulling in all changes. // Make sure we're not missing anything by pulling in all changes.
+4 -59
View File
@@ -3,6 +3,7 @@ import * as Y from 'yjs';
import { openDB, type DBSchema } from 'idb'; import { openDB, type DBSchema } from 'idb';
import { assert, generateId } from './util'; import { assert, generateId } from './util';
import { getDocsMap, getVaultMap, type DocMap } from './vault'; import { getDocsMap, getVaultMap, type DocMap } from './vault';
import { openDoc } from './persistence';
const DB_NAME = 'synced-docs'; const DB_NAME = 'synced-docs';
export const LOCAL_DELTA_STORE = 'local-updates'; export const LOCAL_DELTA_STORE = 'local-updates';
@@ -34,61 +35,6 @@ export const docsDb = await openDB<DocsDatabase>(DB_NAME, 3, {
}, },
}); });
export class LocalDocument {
readonly id: string;
readonly doc: Y.Doc;
private updates: Uint8Array[];
private totalUpdates: number;
private constructor(id: string, ydoc: Y.Doc) {
this.id = id;
this.doc = ydoc;
this.updates = [];
this.totalUpdates = 0;
const flush = _.debounce(() => this.flush(), 1000);
this.doc.on('update', (update: Uint8Array) => {
this.updates.push(update);
flush();
});
}
get numUpdates(): number {
return this.totalUpdates;
}
public static async load(id: string): Promise<LocalDocument> {
// Build Y.Doc from records on disk
const updates: Uint8Array[] = await docsDb.getAll(
LOCAL_DELTA_STORE,
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
);
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, Y.mergeUpdates(updates));
// Build LocalDocument instance
const result = new LocalDocument(id, ydoc);
result.totalUpdates = updates.length;
return result;
}
public async flush(): Promise<void> {
const batch = this.updates;
this.updates = [];
if (batch.length > 0) {
const update = Y.mergeUpdates(batch);
const key: LocalDeltaKey = [this.id, new Date().valueOf()];
await docsDb.add(LOCAL_DELTA_STORE, update, key);
this.totalUpdates += 1;
}
}
public async finish(): Promise<void> {
await this.flush();
this.doc.destroy();
}
}
async function getOrCreateVaultDoc() { async function getOrCreateVaultDoc() {
const records = await docsDb.getAll(VAULT_STORE); const records = await docsDb.getAll(VAULT_STORE);
if (records.length > 1) { if (records.length > 1) {
@@ -104,8 +50,8 @@ async function getOrCreateVaultDoc() {
if (!id) { if (!id) {
throw new Error("Vault record didn't have id"); throw new Error("Vault record didn't have id");
} }
const vault = await LocalDocument.load(id); const vault = await openDoc(id);
const vaultMap = getVaultMap(vault.doc); const vaultMap = getVaultMap(vault);
if (!vaultMap.has('id')) { if (!vaultMap.has('id')) {
vaultMap.set('id', id); vaultMap.set('id', id);
} }
@@ -125,9 +71,8 @@ async function getOrCreateVaultDoc() {
} }
export const defaultVault = await getOrCreateVaultDoc(); export const defaultVault = await getOrCreateVaultDoc();
// console.log(defaultVault.doc.getMap().toJSON())
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
if (docsMap.size === 0) { if (docsMap.size === 0) {
console.log('Running vault migration...'); console.log('Running vault migration...');
for (const oldKey of ['foo', 'bar', 'baz']) { for (const oldKey of ['foo', 'bar', 'baz']) {
+70 -3
View File
@@ -1,7 +1,9 @@
import { defineConfig } from 'vite'; import { defineConfig, Plugin } from 'vite';
import solidPlugin from 'vite-plugin-solid'; import solidPlugin from 'vite-plugin-solid';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -13,12 +15,77 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
index: resolve(__dirname, 'index.html'), index: resolve(__dirname, 'index.html'),
app: resolve(__dirname, 'app.html'),
}, },
}, },
}, },
server: { server: {
port: 3000, port: 3000,
proxy: {
'/api': 'http://localhost:3001',
},
}, },
plugins: [solidPlugin()], plugins: [solidPlugin(), versionPlugin()],
}); });
function versionPlugin(): Plugin {
return {
name: 'generate-version',
apply: 'build',
async generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'version.txt',
source: await getVersionInfo(),
});
},
};
}
async function getVersionInfo(): Promise<string> {
const info: Record<string, string> = {
build: new Date().toISOString(),
};
const execAsync = promisify(exec);
const fromJujutsu = async () => {
const template = [
'change_id.short()',
'commit_id.short()',
'committer.timestamp().format("%Y-%m-%d")',
'description.first_line()',
].join(' ++ "\\t" ++ ');
const { stdout } = await execAsync(
`jj log -r '::@ & ~description("")' -n1 --no-graph -T '${template}'`
);
const [change, hash, date, msg] = stdout.trim().split('\t');
info.change = change;
info.hash = hash;
info.commit = `${msg} (${date})`;
};
const fromGit = async () => {
const { stdout } = await execAsync(
'git log --format="%h%x09%cs%x09%s" -n 1'
);
const [hash, date, msg] = stdout.trim().split('\t');
info.hash = hash;
info.commit = `${msg} (${date})`;
};
try {
await fromJujutsu();
} catch {
try {
await fromGit();
} catch {
// No VCS available
}
}
return (
Object.entries(info)
.map(([k, v]) => `${k}: ${v}`)
.join('\n') + '\n'
);
}