Merge remote-tracking branch 'origin/master' into garrytan/v0.6-mcp-server

Resolved conflicts:
- VERSION: keep 0.6.0
- CHANGELOG.md: keep both 0.6.0 and 0.5.1 entries in order
- src/core/migrate.ts: renumber migrations (master's v2 slugify first, then v3 unique_chunk_index, v4 access_tokens)
- test/sync.test.ts: keep our test name ('normalizes to lowercase')
This commit is contained in:
Garry Tan
2026-04-10 11:09:49 -10:00
8 changed files with 257 additions and 38 deletions

View File

@@ -33,6 +33,14 @@ All notable changes to GBrain will be documented in this file.
- Schema migration v2: unique index on `content_chunks(page_id, chunk_index)` for UPSERT support. - Schema migration v2: unique index on `content_chunks(page_id, chunk_index)` for UPSERT support.
- Schema migration v3: `access_tokens` and `mcp_request_log` tables for remote MCP auth. - Schema migration v3: `access_tokens` and `mcp_request_log` tables for remote MCP auth.
## [0.5.1] - 2026-04-10
### Fixed
- **Apple Notes and files with spaces just work.** Paths like `Apple Notes/2017-05-03 ohmygreen.md` now auto-slugify to clean slugs (`apple-notes/2017-05-03-ohmygreen`). Spaces become hyphens, parens and special characters are stripped, accented characters normalize to ASCII. All 5,861+ Apple Notes files import cleanly without manual renaming.
- **Existing brains auto-migrate.** On first run after upgrade, a one-time migration renames all existing slugs with spaces or special characters to their clean form. Links are rewritten automatically. No manual cleanup needed.
- **Import and sync produce identical slugs.** Both pipelines now use the same `slugifyPath()` function, eliminating the mismatch where sync preserved case but import lowercased.
## [0.5.0] - 2026-04-10 ## [0.5.0] - 2026-04-10
### Added ### Added

View File

@@ -1,5 +1,6 @@
import matter from 'gray-matter'; import matter from 'gray-matter';
import type { PageType } from './types.ts'; import type { PageType } from './types.ts';
import { slugifyPath } from './sync.ts';
export interface ParsedMarkdown { export interface ParsedMarkdown {
frontmatter: Record<string, unknown>; frontmatter: Record<string, unknown>;
@@ -148,17 +149,7 @@ function inferTitle(filePath?: string): string {
function inferSlug(filePath?: string): string { function inferSlug(filePath?: string): string {
if (!filePath) return 'untitled'; if (!filePath) return 'untitled';
return slugifyPath(filePath);
// Remove leading path components that are just the import root
// Keep the type directory + filename structure
let slug = filePath
.replace(/\.md$/i, '')
.replace(/\\/g, '/');
// Remove leading ./
if (slug.startsWith('./')) slug = slug.slice(2);
return slug.toLowerCase();
} }
function extractTags(frontmatter: Record<string, unknown>): string[] { function extractTags(frontmatter: Record<string, unknown>): string[] {

View File

@@ -1,4 +1,5 @@
import type { BrainEngine } from './engine.ts'; import type { BrainEngine } from './engine.ts';
import { slugifyPath } from './sync.ts';
/** /**
* Schema migrations — run automatically on initSchema(). * Schema migrations — run automatically on initSchema().
@@ -8,12 +9,16 @@ import type { BrainEngine } from './engine.ts';
* *
* Each migration runs in a transaction: if the SQL fails, the version stays * Each migration runs in a transaction: if the SQL fails, the version stays
* where it was and the next run retries cleanly. * where it was and the next run retries cleanly.
*
* Migrations can also include a handler function for application-level logic
* (e.g., data transformations that need TypeScript, not just SQL).
*/ */
interface Migration { interface Migration {
version: number; version: number;
name: string; name: string;
sql: string; sql: string;
handler?: (engine: BrainEngine) => Promise<void>;
} }
// Migrations are embedded here, not loaded from files. // Migrations are embedded here, not loaded from files.
@@ -22,6 +27,29 @@ const MIGRATIONS: Migration[] = [
// Version 1 is the baseline (schema.sql creates everything with IF NOT EXISTS). // Version 1 is the baseline (schema.sql creates everything with IF NOT EXISTS).
{ {
version: 2, version: 2,
name: 'slugify_existing_pages',
sql: '',
handler: async (engine) => {
const pages = await engine.listPages();
let renamed = 0;
for (const page of pages) {
const newSlug = slugifyPath(page.slug);
if (newSlug !== page.slug) {
try {
await engine.updateSlug(page.slug, newSlug);
await engine.rewriteLinks(page.slug, newSlug);
renamed++;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: could not rename "${page.slug}" → "${newSlug}": ${msg}`);
}
}
}
if (renamed > 0) console.log(` Renamed ${renamed} slugs`);
},
},
{
version: 3,
name: 'unique_chunk_index', name: 'unique_chunk_index',
sql: ` sql: `
-- Deduplicate any existing duplicate (page_id, chunk_index) rows before adding constraint -- Deduplicate any existing duplicate (page_id, chunk_index) rows before adding constraint
@@ -31,7 +59,7 @@ const MIGRATIONS: Migration[] = [
`, `,
}, },
{ {
version: 3, version: 4,
name: 'access_tokens_and_mcp_log', name: 'access_tokens_and_mcp_log',
sql: ` sql: `
CREATE TABLE IF NOT EXISTS access_tokens ( CREATE TABLE IF NOT EXISTS access_tokens (
@@ -67,17 +95,24 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
let applied = 0; let applied = 0;
for (const m of MIGRATIONS) { for (const m of MIGRATIONS) {
if (m.version > current) { if (m.version > current) {
// Each migration is transactional // SQL migration (transactional)
await engine.transaction(async (tx) => { if (m.sql) {
// Execute the migration SQL via raw connection await engine.transaction(async (tx) => {
// We need to access the underlying sql connection const eng = tx as any;
const eng = tx as any; const sql = eng.sql || eng._sql;
const sql = eng.sql || eng._sql; if (sql) {
if (sql) { await sql.unsafe(m.sql);
await sql.unsafe(m.sql); }
await sql`UPDATE config SET value = ${String(m.version)} WHERE key = 'version'`; });
} }
});
// Application-level handler (runs outside transaction for flexibility)
if (m.handler) {
await m.handler(engine);
}
// Update version after both SQL and handler succeed
await engine.setConfig('version', String(m.version));
console.log(` Migration ${m.version} applied: ${m.name}`); console.log(` Migration ${m.version} applied: ${m.name}`);
applied++; applied++;
} }

View File

@@ -97,21 +97,39 @@ export function isSyncable(path: string): boolean {
} }
/** /**
* Convert a repo-relative file path to a GBrain page slug. * Slugify a single path segment: lowercase, strip special chars, spaces → hyphens.
*/
export function slugifySegment(segment: string): string {
return segment
.normalize('NFD') // Decompose accented chars
.replace(/[\u0300-\u036f]/g, '') // Strip accent marks
.toLowerCase()
.replace(/[^a-z0-9.\s_-]/g, '') // Keep alphanumeric, dots, spaces, underscores, hyphens
.replace(/[\s]+/g, '-') // Spaces → hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Strip leading/trailing hyphens
}
/**
* Slugify a file path: strip .md, normalize separators, slugify each segment.
* *
* Examples: * Examples:
* people/pedro-franceschi.md → people/pedro-franceschi * Apple Notes/2017-05-03 ohmygreen.md → apple-notes/2017-05-03-ohmygreen
* daily/2026-04-05.md → daily/2026-04-05 * people/alice-smith.md → people/alice-smith
* notes.md → notes * notes/v1.0.0.md → notes/v1.0.0
*/
export function slugifyPath(filePath: string): string {
let path = filePath.replace(/\.md$/i, '');
path = path.replace(/\\/g, '/');
path = path.replace(/^\.?\//, '');
return path.split('/').map(slugifySegment).filter(Boolean).join('/');
}
/**
* Convert a repo-relative file path to a GBrain page slug.
*/ */
export function pathToSlug(filePath: string, repoPrefix?: string): string { export function pathToSlug(filePath: string, repoPrefix?: string): string {
// Strip .md extension let slug = slugifyPath(filePath);
let slug = filePath.replace(/\.md$/, '');
// Normalize separators
slug = slug.replace(/\\/g, '/');
// Strip leading slash
slug = slug.replace(/^\//, '');
// Add repo prefix for multi-repo setups
if (repoPrefix) slug = `${repoPrefix}/${slug}`; if (repoPrefix) slug = `${repoPrefix}/${slug}`;
return slug.toLowerCase(); return slug.toLowerCase();
} }

View File

@@ -697,14 +697,14 @@ describeE2E('E2E: Slug with Special Characters', () => {
afterAll(teardownDB); afterAll(teardownDB);
test('imports files with spaces in filename', async () => { test('imports files with spaces in filename', async () => {
const page = await callOp('get_page', { slug: 'apple-notes/2017-05-03 ohmygreen' }) as any; const page = await callOp('get_page', { slug: 'apple-notes/2017-05-03-ohmygreen' }) as any;
expect(page).not.toBeNull(); expect(page).not.toBeNull();
expect(page.title).toBe('OhMyGreen'); expect(page.title).toBe('OhMyGreen');
expect(page.type).toBe('company'); expect(page.type).toBe('company');
}); });
test('imports files with parens in filename', async () => { test('imports files with parens in filename', async () => {
const page = await callOp('get_page', { slug: 'apple-notes/notes (march 2024)' }) as any; const page = await callOp('get_page', { slug: 'apple-notes/notes-march-2024' }) as any;
expect(page).not.toBeNull(); expect(page).not.toBeNull();
expect(page.title).toBe('March 2024 Notes'); expect(page.title).toBe('March 2024 Notes');
}); });
@@ -713,7 +713,7 @@ describeE2E('E2E: Slug with Special Characters', () => {
const results = await callOp('search', { query: 'OhMyGreen' }) as any[]; const results = await callOp('search', { query: 'OhMyGreen' }) as any[];
expect(results.length).toBeGreaterThanOrEqual(1); expect(results.length).toBeGreaterThanOrEqual(1);
const slugs = results.map((r: any) => r.slug); const slugs = results.map((r: any) => r.slug);
expect(slugs).toContain('apple-notes/2017-05-03 ohmygreen'); expect(slugs).toContain('apple-notes/2017-05-03-ohmygreen');
}); });
test('re-import of special-char files is idempotent', async () => { test('re-import of special-char files is idempotent', async () => {

View File

@@ -331,4 +331,66 @@ describeE2E('E2E: Git-to-DB Sync Pipeline', () => {
noEmbed: true, noEmbed: true,
}); });
}); });
test('files with spaces in names get slugified slugs', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Add a file with spaces (Apple Notes style)
mkdirSync(join(repoPath, 'Apple Notes'), { recursive: true });
writeFileSync(join(repoPath, 'Apple Notes/2017-05-03 ohmygreen.md'), [
'---',
'title: Ohmygreen Notes',
'---',
'',
'Notes about ohmygreen lunch service.',
].join('\n'));
gitCommit(repoPath, 'add apple notes file with spaces');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('synced');
expect(result.added).toBe(1);
// Slug should be slugified (lowercase, spaces → hyphens)
const page = await engine.getPage('apple-notes/2017-05-03-ohmygreen');
expect(page).not.toBeNull();
expect(page!.title).toBe('Ohmygreen Notes');
// Original space-based slug should NOT exist
const rawSlug = await engine.getPage('Apple Notes/2017-05-03 ohmygreen');
expect(rawSlug).toBeNull();
});
test('incremental sync adds file with special characters', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Add a file with parens and special chars
writeFileSync(join(repoPath, 'Apple Notes/meeting notes (draft).md'), [
'---',
'title: Draft Meeting Notes',
'---',
'',
'Some draft notes from the meeting.',
].join('\n'));
gitCommit(repoPath, 'add file with parens');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('synced');
// Slug should have parens stripped, spaces → hyphens
const page = await engine.getPage('apple-notes/meeting-notes-draft');
expect(page).not.toBeNull();
expect(page!.title).toBe('Draft Meeting Notes');
});
}); });

View File

@@ -1,4 +1,5 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import { slugifySegment, slugifyPath } from '../src/core/sync.ts';
// Test the validateSlug behavior via the engine // Test the validateSlug behavior via the engine
// We can't import validateSlug directly (it's private), so we test through putPage mock behavior // We can't import validateSlug directly (it's private), so we test through putPage mock behavior
@@ -10,6 +11,102 @@ function validateSlug(slug: string): boolean {
return true; return true;
} }
describe('slugifySegment', () => {
test('converts spaces to hyphens', () => {
expect(slugifySegment('hello world')).toBe('hello-world');
});
test('strips special characters', () => {
expect(slugifySegment('notes (march 2024)')).toBe('notes-march-2024');
});
test('normalizes unicode accents', () => {
expect(slugifySegment('caf\u00e9')).toBe('cafe');
});
test('collapses multiple hyphens', () => {
expect(slugifySegment('a - b')).toBe('a-b');
});
test('strips leading and trailing hyphens', () => {
expect(slugifySegment(' hello ')).toBe('hello');
});
test('preserves dots', () => {
expect(slugifySegment('v1.0.0')).toBe('v1.0.0');
});
test('preserves underscores', () => {
expect(slugifySegment('my_file_name')).toBe('my_file_name');
});
test('lowercases', () => {
expect(slugifySegment('Apple Notes')).toBe('apple-notes');
});
test('returns empty for all-special-chars input', () => {
expect(slugifySegment('!!!')).toBe('');
});
test('handles curly quotes and ellipsis', () => {
expect(slugifySegment('she\u2026said \u201chello\u201d')).toBe('shesaid-hello');
});
});
describe('slugifyPath', () => {
test('slugifies each path segment independently', () => {
expect(slugifyPath('Apple Notes/file name.md')).toBe('apple-notes/file-name');
});
test('already-valid slugs unchanged', () => {
expect(slugifyPath('people/alice-smith.md')).toBe('people/alice-smith');
});
test('strips .md extension case-insensitively', () => {
expect(slugifyPath('notes/file.MD')).toBe('notes/file');
});
test('normalizes backslashes', () => {
expect(slugifyPath('notes\\file.md')).toBe('notes/file');
});
test('strips leading ./', () => {
expect(slugifyPath('./notes/file.md')).toBe('notes/file');
});
test('filters empty segments from all-special-chars dirs', () => {
expect(slugifyPath('!!!/file.md')).toBe('file');
});
test('preserves dots in filenames', () => {
expect(slugifyPath('notes/v1.0.0.md')).toBe('notes/v1.0.0');
});
test('handles consecutive slashes', () => {
expect(slugifyPath('a//b.md')).toBe('a/b');
});
// Bug report example transformations
test('Apple Notes example 1', () => {
expect(slugifyPath('Apple Notes/2017-05-03 ohmygreen.md')).toBe('apple-notes/2017-05-03-ohmygreen');
});
test('Apple Notes example 2', () => {
expect(slugifyPath('Apple Notes/2018-12-14 Garry Tan Photo.md')).toBe('apple-notes/2018-12-14-garry-tan-photo');
});
test('Apple Notes example 3 (parens and ellipsis)', () => {
const input = 'Apple Notes/2017-05-05 Today I had a touch base with Kavita for the meeting on Monday. (she\u2026.md';
const result = slugifyPath(input);
expect(result).toBe('apple-notes/2017-05-05-today-i-had-a-touch-base-with-kavita-for-the-meeting-on-monday.-she');
});
test('meetings transcript example', () => {
expect(slugifyPath('meetings/transcripts/2026-01-21 maria - california c4 collaboration discussion.md'))
.toBe('meetings/transcripts/2026-01-21-maria-california-c4-collaboration-discussion');
});
});
describe('validateSlug (widened for any filename chars)', () => { describe('validateSlug (widened for any filename chars)', () => {
test('accepts clean slug', () => { test('accepts clean slug', () => {
expect(validateSlug('people/sarah-chen')).toBe(true); expect(validateSlug('people/sarah-chen')).toBe(true);

View File

@@ -90,7 +90,7 @@ describe('isSyncable', () => {
}); });
describe('pathToSlug', () => { describe('pathToSlug', () => {
test('strips .md extension', () => { test('strips .md extension and lowercases', () => {
expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi'); expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi');
}); });
@@ -129,6 +129,14 @@ describe('pathToSlug', () => {
test('handles file with only extension', () => { test('handles file with only extension', () => {
expect(pathToSlug('.md')).toBe(''); expect(pathToSlug('.md')).toBe('');
}); });
test('slugifies spaces to hyphens', () => {
expect(pathToSlug('Apple Notes/2017-05-03 ohmygreen.md')).toBe('apple-notes/2017-05-03-ohmygreen');
});
test('strips special characters', () => {
expect(pathToSlug('notes/meeting (march 2024).md')).toBe('notes/meeting-march-2024');
});
}); });
describe('isSyncable edge cases', () => { describe('isSyncable edge cases', () => {