Beauty of orgmode syntax
This article will be about org syntax, not emacs org mode as a PKM system.
Both orgmode and markdown syntax allow us to format plain text in a computer AND human understandable way. For example, in markdown, adding # at the beggining creates a header, ## creates subheaders. writing - [ ] creates a open task, - [x] represents a complete task. It is human readable, but software can render it nicely as html. For example, this blogpost and lifelab is formatted in markdown!
Markdown
One of the biggest pain points I had with markdown is the lack of structure and the need to constantly parse the text to process data. I hate parsing data because I am secretly awful at regex and feel embarassed when I need to write parsers. Since the markdown syntax is permissive, Users can do anything with it, and this makes it hard to make a system that can make sense of any markdown. There are too many ways to do things in markdown!
LifeLab was a overcorrection of this pet peeve. I have strict separation of data types (text blocks, data blocks, code blocks, image blocks, tasks) in the UI and database. While markdown syntax allows you to represent tasks, I wanted them to be separate blocks for the database querying.
If you want to aggregate tasks from a lot of notes and show them in another view or sidebar, and then complete them from the UI, you need to do a search and replace action to find the task in the original note and replace - [ ] with - [x], but what if you have recurring tasks with same name? In lifelab, each block has a UUID so this is solved. When we transclude task blocks and complete one, we mark the right one in O(1) time
.
Less flexibility is good for software, but pretty painful for wetware
One thing I noticed is the immense friction caused by this, especially the difficulty of converting a particular block to another. If I want to make a task, I have to either click 'add' and then 'task' buttons on the top right, or start a markdown task with /task to tranform it into a task block in the UI. If I have some existing text, I can't turn it into a task at will. Vice versa, I can't use completed tasks as documentation of a certain project.
Orgmode
In org, there is a feature that I really like which combines tasks, text blocks and data blocks in 1 very elegant format. It uses astericks to create headlines with infinite nesting (org is an outliner), and something called drawers to append any key/value pairs or tags into the headline that emacs knows how to hide.
* this is a headline!
this is text relating to the headline above
** This is a subheadline!
* TODO this is a TASK!
* DONE this is a complete task!
* This is a headline which contains data! It is hidden by default in emacs!
:PROPERTIES:
:KEY: value
:END:
What can we do about it?
I went ahead and made a demo web app using htmx to simulate org mode and I quite enjoyed it. Every headline + it's text content is a entry in sqlite. So each entry can be either a headline with text, a TASK with text, a DONE task with text or any of the above with key-value metadata. For the Org demo, I had to implement arbitrary nesting (which is an absolute nightmare to do with sqlite)
It was so much denser than my current block based approach! The same amount of content was expressed in less lines of pure text.
I wonder if having every block have a headline would help? It would be similar to orgmode headlines and would be able to represent task. By adding - [ ] to the title, we turn it a task, keeping the existing text! But that forces the user to give every block a title. For metadata, in emacs the property drawers are hand editable. In lifelab, you need to click into metadata to show a table view. Further, maybe the title can also contain all tags, which would be parsed and stored. For data blocks, the title could have autocomplete and match with available schemas, making the title both informative and functional.
I include a read only html demo just to demonstrate the orgmode syntax.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>org-textual Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #333;
}
header h1 {
color: #7dd3fc;
font-size: 1.5em;
margin-bottom: 5px;
}
header p {
color: #888;
font-size: 0.85em;
}
.editor-container {
background: #0f0f1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
}
.status-bar {
background: #1e1e32;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8em;
border-bottom: 1px solid #333;
}
.mode-indicator {
padding: 2px 8px;
border-radius: 3px;
font-weight: bold;
}
.mode-normal {
background: #22d3ee;
color: #000;
}
.mode-insert {
background: #4ade80;
color: #000;
}
.stats {
color: #888;
}
.stats span {
margin-left: 15px;
}
.stats .todo-count { color: #f97316; }
.stats .done-count { color: #4ade80; }
.editor {
padding: 15px;
min-height: 400px;
outline: none;
}
.node {
margin: 2px 0;
border-radius: 4px;
transition: background 0.1s;
}
.node.selected {
background: #2a2a4a;
}
.heading-line {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
gap: 8px;
}
.stars {
color: #6366f1;
font-weight: bold;
min-width: 30px;
}
.fold-icon {
color: #666;
width: 16px;
text-align: center;
cursor: pointer;
}
.fold-icon:hover {
color: #aaa;
}
.todo-state {
padding: 1px 6px;
border-radius: 3px;
font-size: 0.75em;
font-weight: bold;
min-width: 85px;
text-align: center;
}
.todo-TODO {
background: #f97316;
color: #000;
}
.todo-IN-PROGRESS {
background: #eab308;
color: #000;
}
.todo-DONE {
background: #4ade80;
color: #000;
}
.heading-text {
flex: 1;
user-select: none;
}
.heading-text.editing,
.body-text.editing {
background: #1a1a2e;
border: 1px solid #4ade80;
padding: 2px 6px;
border-radius: 3px;
outline: none;
user-select: text;
}
.stats-cookie {
color: #a78bfa;
font-size: 0.85em;
}
.children {
margin-left: 20px;
border-left: 1px dashed #333;
padding-left: 10px;
}
.children.folded {
display: none;
}
.properties-drawer, .logbook-drawer {
margin: 4px 0 4px 38px;
padding: 8px 12px;
background: #16162a;
border-radius: 4px;
font-size: 0.85em;
border-left: 3px solid #6366f1;
}
.logbook-drawer {
border-left-color: #8b5cf6;
}
.drawer-header {
color: #666;
margin-bottom: 5px;
}
.property-line {
display: flex;
gap: 10px;
padding: 2px 0;
}
.property-key {
color: #f472b6;
}
.property-value {
color: #a78bfa;
}
.log-entry {
padding: 2px 0;
color: #888;
}
.log-entry .timestamp {
color: #6366f1;
}
.log-entry .state-change {
color: #4ade80;
}
.body-text {
margin: 4px 0 4px 38px;
padding: 4px 8px;
color: #aaa;
white-space: pre-wrap;
}
.shortcuts {
background: #1e1e32;
padding: 10px 12px;
font-size: 0.75em;
color: #888;
border-top: 1px solid #333;
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.shortcuts kbd {
background: #333;
padding: 2px 6px;
border-radius: 3px;
color: #7dd3fc;
margin-right: 4px;
}
.toolbar {
padding: 10px 12px;
background: #16162a;
border-bottom: 1px solid #333;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.toolbar button {
background: #2a2a4a;
border: 1px solid #444;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.85em;
transition: all 0.15s;
}
.toolbar button:hover {
background: #3a3a5a;
border-color: #666;
}
.toolbar button:active {
transform: scale(0.98);
}
a {
color: #7dd3fc;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Org mode demo</h1>
</header>
<div class="editor-container">
<div class="status-bar">
<span class="mode-indicator" id="mode"></span>
<div class="stats">
<span>Items: <strong id="total-count">0</strong></span>
<span class="todo-count">TODO: <strong id="todo-count">0</strong></span>
<span class="done-count">DONE: <strong id="done-count">0</strong></span>
</div>
</div>
<div class="toolbar">
<button onclick="addNewItem()">+ Add Item</button>
<button onclick="cycleSelectedTodo()">Cycle TODO (t)</button>
<button onclick="toggleSelectedFold()">Fold/Unfold (Tab)</button>
<button onclick="toggleSelectedProperties()">Properties (Ctrl+D)</button>
<button onclick="clockIn()">Clock In (c)</button>
<button onclick="clockOut()">Clock Out (C)</button>
<button onclick="loadSampleData()">Load Sample Data</button>
</div>
<div class="editor" id="editor" tabindex="0">
<!-- Nodes rendered here -->
</div>
<div class="shortcuts">
<span><kbd>Click</kbd> Select</span>
<span><kbd>Click text</kbd> Edit title</span>
<span><kbd>Double-click</kbd> Edit body</span>
<span><kbd>t</kbd> Cycle TODO</span>
<span><kbd>b</kbd> Edit body</span>
<span><kbd>l</kbd> Toggle logbook</span>
<span><kbd>Esc</kbd> Exit edit</span>
<span><kbd>Delete</kbd> Remove</span>
</div>
</div>
</div>
<script>
// Data model
let nodes = [];
let selectedId = null;
let mode = 'NORMAL';
let clockedInId = null;
let clockStartTime = null;
// Generate unique ID
function genId() {
return 'node-' + Math.random().toString(36).substr(2, 9);
}
// Create a new node
function createNode(heading, level = 1, todo = null, parentId = null) {
return {
id: genId(),
heading,
level,
todo,
parentId,
folded: false,
showProperties: false,
showLogbook: false,
properties: {},
logbook: [],
body: '',
children: []
};
}
// Format timestamp
function formatTimestamp(date = new Date()) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const pad = n => n.toString().padStart(2, '0');
return `[${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${days[date.getDay()]} ${pad(date.getHours())}:${pad(date.getMinutes())}]`;
}
// Cycle TODO state
function cycleTodo(node) {
const states = [null, 'TODO', 'IN-PROGRESS', 'DONE'];
const currentIdx = states.indexOf(node.todo);
const oldState = node.todo;
node.todo = states[(currentIdx + 1) % states.length];
// Add log entry for state change
if (oldState !== node.todo) {
node.logbook.unshift({
type: 'state',
timestamp: formatTimestamp(),
from: oldState || 'none',
to: node.todo || 'none'
});
}
}
// Calculate stats cookie
function calcStats(node) {
if (node.children.length === 0) return '';
let done = 0, total = 0;
node.children.forEach(child => {
if (child.todo) {
total++;
if (child.todo === 'DONE') done++;
}
});
if (total === 0) return '';
return `[${done}/${total}]`;
}
// Render a single node
function renderNode(node, depth = 0) {
const stars = '*'.repeat(node.level);
const stats = calcStats(node);
const isSelected = node.id === selectedId;
const hasTodoChildren = node.children.some(c => c.todo);
let html = `
<div class="node ${isSelected ? 'selected' : ''}" data-id="${node.id}">
<div class="heading-line" onclick="selectNode('${node.id}')" ondblclick="editBody('${node.id}')">
<span class="fold-icon" onclick="event.stopPropagation(); toggleFold('${node.id}')">${node.children.length > 0 ? (node.folded ? '▸' : '▾') : ' '}</span>
<span class="stars">${stars}</span>
${node.todo ? `<span class="todo-state todo-${node.todo}" onclick="event.stopPropagation(); cycleTodoById('${node.id}')">${node.todo}</span>` : ''}
<span class="heading-text" id="text-${node.id}" onclick="event.stopPropagation(); editNode('${node.id}')" ondblclick="event.stopPropagation(); editBody('${node.id}')">${escapeHtml(node.heading)}</span>
${stats ? `<span class="stats-cookie">${stats}</span>` : ''}
</div>
`;
// Properties drawer
if (node.showProperties) {
html += `
<div class="properties-drawer">
<div class="drawer-header">:PROPERTIES:</div>
${Object.entries(node.properties).map(([k, v]) => `
<div class="property-line">
<span class="property-key">:${k}:</span>
<span class="property-value">${escapeHtml(v)}</span>
</div>
`).join('')}
${Object.keys(node.properties).length === 0 ? '<div style="color:#666">(empty - use toolbar to add)</div>' : ''}
<div class="drawer-header">:END:</div>
</div>
`;
}
// Logbook drawer
if (node.showLogbook && node.logbook.length > 0) {
html += `
<div class="logbook-drawer">
<div class="drawer-header" style="cursor:pointer" onclick="toggleLogbook('${node.id}')">:LOGBOOK: (click to hide)</div>
${node.logbook.map(entry => {
if (entry.type === 'state') {
return `<div class="log-entry">- State "<span class="state-change">${entry.to}</span>" from "${entry.from}" <span class="timestamp">${entry.timestamp}</span></div>`;
} else if (entry.type === 'note') {
return `<div class="log-entry">- Note taken on <span class="timestamp">${entry.timestamp}</span><br> ${escapeHtml(entry.content)}</div>`;
} else if (entry.type === 'clock') {
return `<div class="log-entry">CLOCK: <span class="timestamp">${entry.start}</span>--<span class="timestamp">${entry.end || '...'}</span>${entry.duration ? ` => ${entry.duration}` : ''}</div>`;
}
return '';
}).join('')}
<div class="drawer-header">:END:</div>
</div>
`;
}
// Body text (always render if body exists or is empty string)
if (node.body !== undefined) {
html += `<div class="body-text" id="body-${node.id}">${node.body ? escapeHtml(node.body) : '<span style="color:#555;font-style:italic">Enter body text...</span>'}</div>`;
}
// Children
if (node.children.length > 0) {
html += `<div class="children ${node.folded ? 'folded' : ''}">`;
node.children.forEach(child => {
html += renderNode(child, depth + 1);
});
html += '</div>';
}
html += '</div>';
return html;
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Render all nodes
function render() {
const editor = document.getElementById('editor');
if (nodes.length === 0) {
editor.innerHTML = '<div style="color:#666;padding:20px;text-align:center">No items yet. Click "Add Item" or "Load Sample Data" to get started.</div>';
} else {
editor.innerHTML = nodes.map(n => renderNode(n)).join('');
}
updateStats();
}
// Update stats display
function updateStats() {
let total = 0, todoCount = 0, doneCount = 0;
function countNode(node) {
total++;
if (node.todo === 'TODO' || node.todo === 'IN-PROGRESS') todoCount++;
if (node.todo === 'DONE') doneCount++;
node.children.forEach(countNode);
}
nodes.forEach(countNode);
document.getElementById('total-count').textContent = total;
document.getElementById('todo-count').textContent = todoCount;
document.getElementById('done-count').textContent = doneCount;
}
// Find node by ID
function findNode(id, nodeList = nodes) {
for (const node of nodeList) {
if (node.id === id) return node;
const found = findNode(id, node.children);
if (found) return found;
}
return null;
}
// Select a node
function selectNode(id) {
selectedId = id;
render();
}
// Edit a node (click-to-edit)
function editNode(id) {
selectedId = id;
mode = 'INSERT';
document.getElementById('mode').textContent = 'INSERT';
document.getElementById('mode').className = 'mode-indicator mode-insert';
const textEl = document.getElementById('text-' + id);
if (textEl) {
textEl.contentEditable = true;
textEl.classList.add('editing');
textEl.focus();
// Save on blur
textEl.onblur = () => {
if (mode !== 'INSERT') return; // Already exited via Escape
const node = findNode(id);
if (node) {
node.heading = textEl.textContent;
}
mode = 'NORMAL';
document.getElementById('mode').textContent = '';
document.getElementById('mode').className = 'mode-indicator';
render();
};
}
}
// Edit body text
function editBody(id) {
const node = findNode(id);
if (!node) return;
selectedId = id;
// Ensure body element exists
if (!node.body) {
node.body = '';
render();
}
mode = 'INSERT';
document.getElementById('mode').textContent = 'INSERT';
document.getElementById('mode').className = 'mode-indicator mode-insert';
const bodyEl = document.getElementById('body-' + id);
if (bodyEl) {
bodyEl.contentEditable = true;
bodyEl.classList.add('editing');
bodyEl.focus();
// Save on blur
bodyEl.onblur = () => {
if (mode !== 'INSERT') return;
node.body = bodyEl.textContent;
mode = 'NORMAL';
document.getElementById('mode').textContent = '';
document.getElementById('mode').className = 'mode-indicator';
render();
};
}
}
// Toggle fold
function toggleFold(id) {
const node = findNode(id);
if (node && node.children.length > 0) {
node.folded = !node.folded;
render();
}
}
// Add new item (quick) - creates item and enters edit mode
function addNewItem() {
const node = createNode('New item', 1, 'TODO');
node.properties.ID = node.id;
nodes.push(node);
selectedId = node.id;
render();
// Enter edit mode immediately
setTimeout(() => {
editNode(node.id);
// Select all text so typing replaces it
const textEl = document.getElementById('text-' + node.id);
if (textEl) {
const range = document.createRange();
range.selectNodeContents(textEl);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}, 10);
}
// Cycle TODO by ID
function cycleTodoById(id) {
const node = findNode(id);
if (node) {
cycleTodo(node);
render();
}
}
// Cycle selected TODO
function cycleSelectedTodo() {
if (selectedId) {
cycleTodoById(selectedId);
}
}
// Toggle selected fold
function toggleSelectedFold() {
if (selectedId) {
toggleFold(selectedId);
}
}
// Toggle logbook visibility
function toggleLogbook(id) {
const node = findNode(id);
if (node) {
node.showLogbook = !node.showLogbook;
render();
}
}
// Toggle properties drawer
function toggleSelectedProperties() {
if (selectedId) {
const node = findNode(selectedId);
if (node) {
node.showProperties = !node.showProperties;
render();
}
}
}
// Add log entry
function addLogEntry() {
if (selectedId) {
const node = findNode(selectedId);
if (node) {
const note = prompt('Enter note:');
if (note) {
node.logbook.unshift({
type: 'note',
timestamp: formatTimestamp(),
content: note
});
node.showLogbook = true;
render();
}
}
}
}
// Clock in
function clockIn() {
if (selectedId && !clockedInId) {
const node = findNode(selectedId);
if (node) {
clockedInId = selectedId;
clockStartTime = new Date();
node.logbook.unshift({
type: 'clock',
start: formatTimestamp(clockStartTime),
end: null,
duration: null
});
node.showLogbook = true;
render();
}
}
}
// Clock out
function clockOut() {
if (clockedInId) {
const node = findNode(clockedInId);
if (node && node.logbook.length > 0) {
const clockEntry = node.logbook.find(e => e.type === 'clock' && !e.end);
if (clockEntry) {
const endTime = new Date();
clockEntry.end = formatTimestamp(endTime);
const mins = Math.round((endTime - clockStartTime) / 60000);
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
clockEntry.duration = `${hours}:${remainMins.toString().padStart(2, '0')}`;
}
}
clockedInId = null;
clockStartTime = null;
render();
}
}
// Delete selected
function deleteSelected() {
if (!selectedId) return;
function removeFromList(list) {
const idx = list.findIndex(n => n.id === selectedId);
if (idx !== -1) {
list.splice(idx, 1);
return true;
}
for (const node of list) {
if (removeFromList(node.children)) return true;
}
return false;
}
removeFromList(nodes);
selectedId = null;
render();
}
// Load sample data
function loadSampleData() {
nodes = [];
const project = createNode('Project Planning', 1, 'IN-PROGRESS');
project.properties = { ID: project.id, CATEGORY: 'work', PRIORITY: 'A' };
project.body = 'Main project planning document.\nTracking all tasks and progress.';
project.logbook = [
{ type: 'state', timestamp: '[2024-01-10 Wed 09:00]', from: 'TODO', to: 'IN-PROGRESS' }
];
project.showProperties = true;
const research = createNode('Research phase', 2, 'DONE');
research.properties = { ID: research.id, EFFORT: '2h' };
research.logbook = [
{ type: 'state', timestamp: '[2024-01-08 Mon 14:30]', from: 'IN-PROGRESS', to: 'DONE' },
{ type: 'clock', start: '[2024-01-08 Mon 10:00]', end: '[2024-01-08 Mon 12:15]', duration: '2:15' }
];
research.body = 'Completed the initial research.\nGathered requirements from stakeholders.';
project.children.push(research);
const impl = createNode('Implementation', 2, 'IN-PROGRESS');
impl.properties = { ID: impl.id };
impl.showLogbook = true;
impl.logbook = [
{ type: 'state', timestamp: '[2024-01-09 Tue 10:00]', from: 'TODO', to: 'IN-PROGRESS' },
{ type: 'note', timestamp: '[2024-01-09 Tue 11:30]', content: 'Started working on core features' }
];
const backend = createNode('Backend API', 3, 'DONE');
backend.body = 'REST API endpoints completed.';
impl.children.push(backend);
const frontend = createNode('Frontend UI', 3, 'TODO');
frontend.body = 'React components for the dashboard.';
impl.children.push(frontend);
const testing = createNode('Testing', 3, 'TODO');
impl.children.push(testing);
project.children.push(impl);
const docs = createNode('Documentation', 2, 'TODO');
docs.properties = { ID: docs.id, ASSIGNEE: 'team' };
project.children.push(docs);
nodes.push(project);
// Second top-level item
const ideas = createNode('Ideas & Notes', 1, null);
ideas.body = 'Random ideas and notes for future reference.';
ideas.properties = { ID: ideas.id, CATEGORY: 'notes' };
const idea1 = createNode('Add dark mode support', 2, 'TODO');
ideas.children.push(idea1);
const idea2 = createNode('Performance optimization', 2, null);
idea2.body = 'Look into caching strategies.';
ideas.children.push(idea2);
nodes.push(ideas);
selectedId = project.id;
render();
}
// Keyboard handling
document.addEventListener('keydown', (e) => {
const editor = document.getElementById('editor');
// Ignore if typing in input
if (e.target.tagName === 'INPUT') return;
if (mode === 'NORMAL') {
switch(e.key) {
case 't':
cycleSelectedTodo();
e.preventDefault();
break;
case 'Tab':
toggleSelectedFold();
e.preventDefault();
break;
case 'i':
if (selectedId) {
editNode(selectedId);
}
e.preventDefault();
break;
case 'c':
clockIn();
e.preventDefault();
break;
case 'C':
clockOut();
e.preventDefault();
break;
case 'b':
if (selectedId) {
editBody(selectedId);
}
e.preventDefault();
break;
case 'l':
if (selectedId) {
const node = findNode(selectedId);
if (node) {
node.showLogbook = !node.showLogbook;
render();
}
}
e.preventDefault();
break;
case 'Delete':
case 'Backspace':
if (confirm('Delete selected item?')) {
deleteSelected();
}
e.preventDefault();
break;
}
} else if (mode === 'INSERT') {
if (e.key === 'Escape') {
mode = 'NORMAL';
document.getElementById('mode').textContent = '';
document.getElementById('mode').className = 'mode-indicator';
// Save edited text
if (selectedId) {
const textEl = document.getElementById('text-' + selectedId);
if (textEl) {
const node = findNode(selectedId);
if (node) {
node.heading = textEl.textContent;
}
textEl.contentEditable = false;
textEl.classList.remove('editing');
}
}
render();
e.preventDefault();
}
}
});
// Initialize
loadSampleData();
</script>
</body>
</html>
I started implementing a emacs-free org mode editor written in textual but there is a clash between extensibility (in emacs you can do anything) and performance. Maybe I should give neovim orgmode a try.