mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
feat: slugify file paths with spaces and special characters (v0.5.1) (#29)
* feat: slugify file paths with spaces and special characters Apple Notes files (e.g., "2017-05-03 ohmygreen.md") now get clean, URL-safe slugs instead of raw filenames with spaces. Spaces become hyphens, special chars are stripped, accented chars normalize to ASCII. Both import (inferSlug) and sync (pathToSlug) pipelines now use the same slugifyPath() function, eliminating the case-preservation mismatch. * feat: one-time migration to slugify existing page slugs Extends Migration interface with optional TypeScript handler for application-level data transformations. Adds version 2 migration that renames all existing slugs to their slugified form, including link rewriting. Collision handling via try/catch + warning. * test: slugify unit tests, E2E tests, and updated expectations 22 new unit tests for slugifySegment and slugifyPath covering spaces, special chars, unicode, dots, empty segments, and all 4 bug report examples. Updated pathToSlug tests for new lowercase behavior. Updated E2E tests for slugified Apple Notes slugs. Added 2 new E2E tests for space-named file import and sync. * chore: bump version and changelog (v0.5.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix changelog example to show directory with spaces --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e9f3c9c24d
commit
27eb87f1f4
+2
-11
@@ -1,5 +1,6 @@
|
||||
import matter from 'gray-matter';
|
||||
import type { PageType } from './types.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
export interface ParsedMarkdown {
|
||||
frontmatter: Record<string, unknown>;
|
||||
@@ -148,17 +149,7 @@ function inferTitle(filePath?: string): string {
|
||||
|
||||
function inferSlug(filePath?: string): string {
|
||||
if (!filePath) return 'untitled';
|
||||
|
||||
// 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();
|
||||
return slugifyPath(filePath);
|
||||
}
|
||||
|
||||
function extractTags(frontmatter: Record<string, unknown>): string[] {
|
||||
|
||||
+46
-13
@@ -1,4 +1,5 @@
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
/**
|
||||
* Schema migrations — run automatically on initSchema().
|
||||
@@ -8,20 +9,45 @@ import type { BrainEngine } from './engine.ts';
|
||||
*
|
||||
* Each migration runs in a transaction: if the SQL fails, the version stays
|
||||
* 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 {
|
||||
version: number;
|
||||
name: string;
|
||||
sql: string;
|
||||
handler?: (engine: BrainEngine) => Promise<void>;
|
||||
}
|
||||
|
||||
// Migrations are embedded here, not loaded from files.
|
||||
// Add new migrations at the end. Never modify existing ones.
|
||||
const MIGRATIONS: Migration[] = [
|
||||
// Version 1 is the baseline (schema.sql creates everything with IF NOT EXISTS).
|
||||
// Future migrations go here:
|
||||
// { version: 2, name: 'add_aliases', sql: `ALTER TABLE pages ADD COLUMN IF NOT EXISTS aliases TEXT[];` },
|
||||
{
|
||||
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`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
@@ -35,17 +61,24 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
let applied = 0;
|
||||
for (const m of MIGRATIONS) {
|
||||
if (m.version > current) {
|
||||
// Each migration is transactional
|
||||
await engine.transaction(async (tx) => {
|
||||
// Execute the migration SQL via raw connection
|
||||
// We need to access the underlying sql connection
|
||||
const eng = tx as any;
|
||||
const sql = eng.sql || eng._sql;
|
||||
if (sql) {
|
||||
await sql.unsafe(m.sql);
|
||||
await sql`UPDATE config SET value = ${String(m.version)} WHERE key = 'version'`;
|
||||
}
|
||||
});
|
||||
// SQL migration (transactional)
|
||||
if (m.sql) {
|
||||
await engine.transaction(async (tx) => {
|
||||
const eng = tx as any;
|
||||
const sql = eng.sql || eng._sql;
|
||||
if (sql) {
|
||||
await sql.unsafe(m.sql);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
applied++;
|
||||
}
|
||||
|
||||
+29
-11
@@ -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:
|
||||
* people/pedro-franceschi.md → people/pedro-franceschi
|
||||
* daily/2026-04-05.md → daily/2026-04-05
|
||||
* notes.md → notes
|
||||
* Apple Notes/2017-05-03 ohmygreen.md → apple-notes/2017-05-03-ohmygreen
|
||||
* people/alice-smith.md → people/alice-smith
|
||||
* 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 {
|
||||
// Strip .md extension
|
||||
let slug = filePath.replace(/\.md$/, '');
|
||||
// Normalize separators
|
||||
slug = slug.replace(/\\/g, '/');
|
||||
// Strip leading slash
|
||||
slug = slug.replace(/^\//, '');
|
||||
// Add repo prefix for multi-repo setups
|
||||
let slug = slugifyPath(filePath);
|
||||
if (repoPrefix) slug = `${repoPrefix}/${slug}`;
|
||||
return slug;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user