Skip to main content

Devlog Feb 22: MoLOS-Markdown & Quick Notes

· 14 min read
Eduardez
MoLOS Lead Developer

This week we completed the MoLOS-Markdown module with the implementation of hierarchical document management and added the Quick Notes feature with a Google Keep-inspired design. We also made significant improvements to the UI responsiveness and styling.

Hierarchical Document Management API

We implemented a comprehensive API for managing hierarchical markdown documents with full CRUD operations.

MarkdownRepository

The core of the module is the MarkdownRepository which handles all database operations:

export class MarkdownRepository {
async create(data: CreatePageDTO): Promise<MarkdownPage> {
const [page] = await this.db
.insert(markdownPages)
.values({
id: generateId(),
path: this.generatePath(data.path, data.parentId),
title: data.title,
content: data.content,
tags: data.tags || [],
parentId: data.parentId,
userId: data.userId,
createdAt: Date.now(),
updatedAt: Date.now()
})
.returning();

// Create initial version
await this.createVersion(page.id, data.content, 1);

return page;
}

async findById(id: string): Promise<MarkdownPage | null> {
const result = await this.db
.select()
.from(markdownPages)
.where(eq(markdownPages.id, id))
.limit(1);

return result[0] || null;
}

async findByPath(path: string): Promise<MarkdownPage | null> {
const result = await this.db
.select()
.from(markdownPages)
.where(eq(markdownPages.path, path))
.limit(1);

return result[0] || null;
}

async findByParent(parentId: string | null): Promise<MarkdownPage[]> {
return await this.db
.select()
.from(markdownPages)
.where(
parentId
? eq(markdownPages.parentId, parentId)
: isNull(markdownPages.parentId)
)
.orderBy(asc(markdownPages.title));
}

async findByUser(userId: string): Promise<MarkdownPage[]> {
return await this.db
.select()
.from(markdownPages)
.where(eq(markdownPages.userId, userId))
.orderBy(asc(markdownPages.path));
}

async update(id: string, data: UpdatePageDTO): Promise<MarkdownPage> {
const page = await this.findById(id);
if (!page) {
throw new Error('Page not found');
}

// Create new version if content changed
if (data.content && data.content !== page.content) {
const versionCount = await this.getVersionCount(id);
await this.createVersion(id, data.content, versionCount + 1);
}

const [updated] = await this.db
.update(markdownPages)
.set({
...data,
path: data.path ? this.generatePath(data.path, data.parentId) : undefined,
updatedAt: Date.now()
})
.where(eq(markdownPages.id, id))
.returning();

return updated;
}

async delete(id: string): Promise<void> {
// Delete all child pages recursively
const children = await this.findByParent(id);
for (const child of children) {
await this.delete(child.id);
}

// Delete versions
await this.db
.delete(markdownVersions)
.where(eq(markdownVersions.pageId, id));

// Delete page
await this.db.delete(markdownPages).where(eq(markdownPages.id, id));
}

private generatePath(path: string, parentId: string | null): string {
if (!parentId) {
return `/${path}`;
}

const parent = await this.findById(parentId);
if (!parent) {
throw new Error('Parent page not found');
}

return `${parent.path}/${path}`;
}

private async createVersion(pageId: string, content: string, version: number): Promise<MarkdownVersion> {
const [versionRecord] = await this.db
.insert(markdownVersions)
.values({
id: generateId(),
pageId,
version,
content,
createdAt: Date.now()
})
.returning();

return versionRecord;
}

async getVersionCount(pageId: string): Promise<number> {
const result = await this.db
.select({ count: count() })
.from(markdownVersions)
.where(eq(markdownVersions.pageId, pageId));

return result[0].count;
}

async getVersions(pageId: string): Promise<MarkdownVersion[]> {
return await this.db
.select()
.from(markdownVersions)
.where(eq(markdownVersions.pageId, pageId))
.orderBy(desc(markdownVersions.version));
}

async restoreVersion(pageId: string, version: number): Promise<MarkdownPage> {
const versionRecord = await this.db
.select()
.from(markdownVersions)
.where(
and(
eq(markdownVersions.pageId, pageId),
eq(markdownVersions.version, version)
)
)
.limit(1);

if (!versionRecord[0]) {
throw new Error('Version not found');
}

return await this.update(pageId, {
content: versionRecord[0].content
});
}

async search(query: string, userId: string): Promise<MarkdownPage[]> {
const results = await this.db
.select()
.from(markdownPages)
.where(
and(
eq(markdownPages.userId, userId),
or(
like(markdownPages.title, `%${query}%`),
like(markdownPages.content, `%${query}%`)
)
)
)
.orderBy(desc(markdownPages.updatedAt));

return results;
}

async getRecent(userId: string, limit = 10): Promise<MarkdownPage[]> {
return await this.db
.select()
.from(markdownPages)
.where(eq(markdownPages.userId, userId))
.orderBy(desc(markdownPages.updatedAt))
.limit(limit);
}
}

Path Utilities

We created utility functions for managing document paths:

export function validatePath(path: string): boolean {
if (!path || path.trim() === '') {
return false;
}

if (path.startsWith('/') || path.endsWith('/')) {
return false;
}

const invalidChars = /[<>:"|?*\x00-\x1F]/;
if (invalidChars.test(path)) {
return false;
}

const segments = path.split('/');
for (const segment of segments) {
if (segment.trim() === '') {
return false;
}

if (segment === '.' || segment === '..') {
return false;
}
}

return true;
}

export function normalizePath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/\/$/, '');
}

export function getParentPath(path: string): string {
const normalized = normalizePath(path);
const lastSlash = normalized.lastIndexOf('/');

if (lastSlash === -1) {
return '';
}

return normalized.substring(0, lastSlash);
}

export function getFilename(path: string): string {
const normalized = normalizePath(path);
const lastSlash = normalized.lastIndexOf('/');

if (lastSlash === -1) {
return normalized;
}

return normalized.substring(lastSlash + 1);
}

export function joinPath(...segments: string[]): string {
const normalized = segments
.map(segment => normalizePath(segment))
.filter(segment => segment !== '')
.join('/');

return normalized;
}

API Endpoints

We implemented RESTful API endpoints for all operations:

// GET /api/markdown
export async function GET({ url, locals }) {
const search = url.searchParams.get('search');
const recent = url.searchParams.get('recent');

if (search) {
const pages = await markdownRepository.search(search, locals.user.id);
return json({ pages });
}

if (recent) {
const pages = await markdownRepository.getRecent(
locals.user.id,
parseInt(recent)
);
return json({ pages });
}

const pages = await markdownRepository.findByUser(locals.user.id);
return json({ pages });
}

// POST /api/markdown
export async function POST({ request, locals }) {
const data = await request.json();

if (!validatePath(data.path)) {
return json(
{ error: 'Invalid path' },
{ status: 400 }
);
}

const page = await markdownRepository.create({
...data,
userId: locals.user.id
});

return json({ page }, { status: 201 });
}

// GET /api/markdown/[id]
export async function GET({ params }) {
const page = await markdownRepository.findById(params.id);
if (!page) {
throw error(404, 'Page not found');
}

return json({ page });
}

// PUT /api/markdown/[id]
export async function PUT({ params, request }) {
const data = await request.json();

if (data.path && !validatePath(data.path)) {
return json(
{ error: 'Invalid path' },
{ status: 400 }
);
}

const page = await markdownRepository.update(params.id, data);
return json({ page });
}

// DELETE /api/markdown/[id]
export async function DELETE({ params }) {
await markdownRepository.delete(params.id);
return json({ success: true });
}

// GET /api/markdown/[id]/versions
export async function GET({ params }) {
const versions = await markdownRepository.getVersions(params.id);
return json({ versions });
}

// POST /api/markdown/[id]/restore/[version]
export async function POST({ params }) {
const page = await markdownRepository.restoreVersion(
params.id,
parseInt(params.version)
);
return json({ page });
}

Quick Notes Feature

We implemented a Quick Notes feature inspired by Google Keep, with flat notes, checklists, colors, pinning, and archiving.

Database Schema

export const quickNotes = sqliteTable('markdown_quick_notes', {
id: text('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
color: text('color').notNull().default('#ffffff'),
labels: text('labels', { mode: 'json' }).$type<string[]>(),
checklists: text('checklists', { mode: 'json' }).$type<ChecklistItem[]>(),
isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
userId: text('user_id').notNull(),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull()
});

export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}

QuickNotesRepository

export class QuickNotesRepository {
async create(data: CreateNoteDTO): Promise<QuickNote> {
const [note] = await this.db
.insert(quickNotes)
.values({
id: generateId(),
title: data.title,
content: data.content,
color: data.color || '#ffffff',
labels: data.labels || [],
checklists: data.checklists || [],
isPinned: false,
isArchived: false,
userId: data.userId,
createdAt: Date.now(),
updatedAt: Date.now()
})
.returning();

return note;
}

async findById(id: string): Promise<QuickNote | null> {
const result = await this.db
.select()
.from(quickNotes)
.where(eq(quickNotes.id, id))
.limit(1);

return result[0] || null;
}

async findByUser(
userId: string,
filter: 'all' | 'pinned' | 'archived' = 'all',
search = ''
): Promise<QuickNote[]> {
let conditions = [eq(quickNotes.userId, userId)];

if (filter === 'pinned') {
conditions.push(eq(quickNotes.isPinned, true));
} else if (filter === 'archived') {
conditions.push(eq(quickNotes.isArchived, true));
} else {
conditions.push(eq(quickNotes.isArchived, false));
}

if (search) {
conditions.push(
or(
like(quickNotes.title, `%${search}%`),
like(quickNotes.content, `%${search}%`)
)!
);
}

return await this.db
.select()
.from(quickNotes)
.where(and(...conditions))
.orderBy(desc(quickNotes.isPinned), desc(quickNotes.updatedAt));
}

async update(id: string, data: UpdateNoteDTO): Promise<QuickNote> {
const [note] = await this.db
.update(quickNotes)
.set({
...data,
updatedAt: Date.now()
})
.where(eq(quickNotes.id, id))
.returning();

return note;
}

async togglePin(id: string): Promise<QuickNote> {
const note = await this.findById(id);
if (!note) {
throw new Error('Note not found');
}

return await this.update(id, { isPinned: !note.isPinned });
}

async toggleArchive(id: string): Promise<QuickNote> {
const note = await this.findById(id);
if (!note) {
throw new Error('Note not found');
}

return await this.update(id, { isArchived: !note.isArchived });
}

async addChecklistItem(noteId: string, text: string): Promise<QuickNote> {
const note = await this.findById(noteId);
if (!note) {
throw new Error('Note not found');
}

const newItem: ChecklistItem = {
id: generateId(),
text,
checked: false
};

return await this.update(noteId, {
checklists: [...note.checklists, newItem]
});
}

async toggleChecklistItem(noteId: string, itemId: string): Promise<QuickNote> {
const note = await this.findById(noteId);
if (!note) {
throw new Error('Note not found');
}

const checklists = note.checklists.map(item =>
item.id === itemId
? { ...item, checked: !item.checked }
: item
);

return await this.update(noteId, { checklists });
}

async deleteChecklistItem(noteId: string, itemId: string): Promise<QuickNote> {
const note = await this.findById(noteId);
if (!note) {
throw new Error('Note not found');
}

const checklists = note.checklists.filter(item => item.id !== itemId);

return await this.update(noteId, { checklists });
}
}

Color Picker Component

<script>
export let selectedColor = '#ffffff';

const colors = [
'#ffffff', '#f28b82', '#fbbc04', '#fff475',
'#ccff90', '#a7ffeb', '#cbf0f8', '#aecbfa',
'#d7aefb', '#fdcfe8', '#e6c9a8', '#e8eaed'
];

function selectColor(color: string) {
selectedColor = color;
}
</script>

<div class="color-picker">
{#each colors as color}
<div
class="color-swatch {selectedColor === color ? 'selected' : ''}"
style="background-color: {color}"
on:click={() => selectColor(color)}
role="button"
aria-label="Select color {color}"
/>
{/each}
</div>

<style>
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}

.color-swatch {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}

.color-swatch:hover {
transform: scale(1.1);
}

.color-swatch.selected {
border-color: #000;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.2);
}
</style>

Quick Note Card Component

<script>
export let note;

async function togglePin() {
const response = await fetch(`/api/quick-notes/${note.id}/pin`, {
method: 'POST'
});
note = await response.json();
}

async function toggleArchive() {
const response = await fetch(`/api/quick-notes/${note.id}/archive`, {
method: 'POST'
});
note = await response.json();
}

async function toggleChecklist(itemId: string) {
const response = await fetch(`/api/quick-notes/${note.id}/checklist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId, action: 'toggle' })
});
note = await response.json();
}
</script>

<div class="note-card" style="background-color: {note.color}">
<div class="note-header">
<h3>{note.title}</h3>
<div class="note-actions">
{#if note.isPinned}
<button on:click={togglePin} aria-label="Unpin note">
📌
</button>
{:else}
<button on:click={togglePin} aria-label="Pin note">
📍
</button>
{/if}
<button on:click={toggleArchive} aria-label="Archive note">
📦
</button>
</div>
</div>

<div class="note-content">
{@html renderMarkdown(note.content)}
</div>

{#if note.checklists && note.checklists.length > 0}
<div class="checklist">
{#each note.checklists as item}
<label class="checklist-item">
<input
type="checkbox"
checked={item.checked}
on:change={() => toggleChecklist(item.id)}
/>
<span class:text-decoration-line-through={item.checked}>
{item.text}
</span>
</label>
{/each}
</div>
{/if}

{#if note.labels && note.labels.length > 0}
<div class="labels">
{#each note.labels as label}
<span class="label">{label}</span>
{/each}
</div>
{/if}

<div class="note-footer">
<span class="date">
{new Date(note.updatedAt).toLocaleDateString()}
</span>
</div>
</div>

<style>
.note-card {
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}

.note-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.note-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}

.note-header h3 {
margin: 0;
font-size: 1.1rem;
}

.note-actions {
display: flex;
gap: 8px;
}

.note-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 4px;
border-radius: 4px;
}

.note-actions button:hover {
background-color: rgba(0, 0, 0, 0.05);
}

.checklist-item {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
cursor: pointer;
}

.labels {
display: flex;
gap: 8px;
margin-top: 8px;
}

.label {
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 12px;
font-size: 0.8rem;
}

.note-footer {
margin-top: 12px;
font-size: 0.8rem;
color: #666;
}
</style>

Quick Notes Page

<script>
import { onMount } from 'svelte';
import { quickNotesStore } from '$stores/quick-notes';

let notes = [];
let filter = 'all';
let search = '';
let showCreateDialog = false;
let selectedNote = null;

onMount(async () => {
await loadNotes();
});

async function loadNotes() {
notes = await quickNotesStore.fetchNotes(filter, search);
}

async function createNote(noteData) {
await quickNotesStore.createNote(noteData);
await loadNotes();
showCreateDialog = false;
}
</script>

<div class="quick-notes-container">
<div class="toolbar">
<input
type="text"
bind:value={search}
placeholder="Search notes..."
on:input={loadNotes}
/>

<div class="filter-buttons">
<button
class:active={filter === 'all'}
on:click={() => { filter = 'all'; loadNotes(); }}
>
All
</button>
<button
class:active={filter === 'pinned'}
on:click={() => { filter = 'pinned'; loadNotes(); }}
>
Pinned
</button>
<button
class:active={filter === 'archived'}
on:click={() => { filter = 'archived'; loadNotes(); }}
>
Archived
</button>
</div>

<button class="create-button" on:click={() => showCreateDialog = true}>
+ New Note
</button>
</div>

<div class="notes-grid">
{#each notes as note}
<QuickNoteCard {note} />
{/each}
</div>

{#if showCreateDialog}
<QuickNoteCreateDialog
on:create={createNote}
on:close={() => showCreateDialog = false}
/>
{/if}
</div>

<style>
.quick-notes-container {
padding: 24px;
}

.toolbar {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}

.toolbar input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}

.filter-buttons {
display: flex;
gap: 8px;
}

.filter-buttons button {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}

.filter-buttons button.active {
background: #007bff;
color: white;
border-color: #007bff;
}

.create-button {
padding: 8px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}

.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}

@media (max-width: 768px) {
.toolbar {
flex-direction: column;
}

.toolbar input,
.filter-buttons {
width: 100%;
}

.notes-grid {
grid-template-columns: 1fr;
}
}
</style>

UI Improvements

We made significant improvements to the UI across all components:

Responsive Sidebar

// Updated sidebar with improved responsiveness
<div class="sidebar {collapsed ? 'collapsed' : ''}">
<div class="sidebar-header">
<button on:click={toggleCollapse} aria-label="Toggle sidebar">
{collapsed ? '→' : '←'}
</button>
{!collapsed && <h2>MoLOS-Markdown</h2>}
</div>

{!collapsed && (
<>
<TreeView nodes={treeNodes} />
<div class="sidebar-actions">
<button on:click={createNewPage}>New Page</button>
<button on:click={importPages}>Import</button>
<button on:click={exportPages}>Export</button>
</div>
</>
)}
</div>

Enhanced Styling with Theme Support

/* Updated styles with theme variables */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--background-color: #ffffff;
--text-color: #333333;
--border-color: #dee2e6;
--hover-color: #f8f9fa;
--active-color: #e9ecef;
}

.dark {
--primary-color: #3b82f6;
--secondary-color: #9ca3af;
--background-color: #1f2937;
--text-color: #f3f4f6;
--border-color: #374151;
--hover-color: #374151;
--active-color: #4b5563;
}

.sidebar {
width: 300px;
height: 100vh;
background-color: var(--background-color);
border-right: 1px solid var(--border-color);
transition: width 0.3s ease;
}

.sidebar.collapsed {
width: 60px;
}

.sidebar-header {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}

@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transform: translateX(-100%);
}

.sidebar.open {
transform: translateX(0);
}
}

Search Results Component

<script>
export let results;
export let query;
export let onSelect;

function highlightText(text: string, query: string): string {
if (!query) return text;

const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
</script>

<div class="search-results">
<h3>Search Results for "{query}"</h3>
<p class="count">{results.length} results found</p>

<div class="results-list">
{#each results as result}
<div class="result-item" on:click={() => onSelect(result.id)}>
<h4>{@html highlightText(result.title, query)}</h4>
<div class="preview">
{@html highlightText(result.content.substring(0, 200), query)}
</div>
<div class="meta">
<span class="path">{result.path}</span>
<span class="date">
Updated: {new Date(result.updatedAt).toLocaleDateString()}
</span>
</div>
</div>
{/each}
</div>
</div>

<style>
.search-results {
padding: 24px;
}

.result-item {
padding: 16px;
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 16px;
cursor: pointer;
transition: all 0.2s;
}

.result-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-color: #007bff;
}

.result-item h4 {
margin: 0 0 8px 0;
color: #007bff;
}

.preview {
color: #666;
margin-bottom: 8px;
}

.meta {
display: flex;
gap: 16px;
font-size: 0.8rem;
color: #999;
}

mark {
background-color: #ffeb3b;
padding: 2px 4px;
border-radius: 2px;
}
</style>

Tree Node Component

<script>
export let node;
export let level = 0;
export let onExpand;
export let onCollapse;

let expanded = false;

function toggleExpand() {
expanded = !expanded;
if (expanded) {
onExpand(node.id);
} else {
onCollapse(node.id);
}
}

function handleClick(e: MouseEvent) {
e.stopPropagation();
window.location.href = `/markdown/${node.id}`;
}
</script>

<div class="tree-node" style="padding-left: {level * 20}px">
<div class="tree-node-content">
{#if node.children && node.children.length > 0}
<button
class="toggle-button"
on:click={toggleExpand}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? '▼' : '▶'}
</button>
{:else}
<span class="toggle-spacer"></span>
{/if}

<span class="node-label" on:click={handleClick}>
{node.title}
</span>
</div>

{#if expanded && node.children}
<div class="tree-node-children">
{#each node.children as child}
<TreeNode
node={child}
level={level + 1}
on:expand
on:collapse
/>
{/each}
</div>
{/if}
</div>

<style>
.tree-node {
border-bottom: 1px solid #f0f0f0;
}

.tree-node-content {
display: flex;
align-items: center;
padding: 8px 0;
cursor: pointer;
}

.toggle-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
}

.toggle-spacer {
width: 24px;
}

.node-label {
flex: 1;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}

.node-label:hover {
background-color: #f8f9fa;
}

.tree-node-children {
border-left: 1px solid #e0e0e0;
margin-left: 11px;
}
</style>

Key Commits

What's Next

With the completion of MoLOS-Markdown and Quick Notes, we're focusing on:

  • Collaboration: Real-time collaborative editing
  • Comments: Add comments to documents
  • Mobile App: Native mobile experience
  • Offline Support: Work offline with sync
  • Advanced Search: More powerful search capabilities
  • Export Formats: Export to PDF, DOCX, etc.
  • Performance: Optimize for large document sets
  • Testing: Expand test coverage