blog/pkm/Beauty of orgmode syntax
The beauty of org 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.

Org mode demo
<!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>&nbsp;&nbsp;${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>

Emacs-free orgmode?

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.