mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { slugifySegment, slugifyPath } from '../src/core/sync.ts';
|
|
|
|
// Test the validateSlug behavior via the engine
|
|
// We can't import validateSlug directly (it's private), so we test through putPage mock behavior
|
|
// Instead, test the regex logic directly
|
|
|
|
function validateSlug(slug: string): boolean {
|
|
// Mirrors the logic in postgres-engine.ts
|
|
if (!slug || /\.\./.test(slug) || /^\//.test(slug)) return false;
|
|
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)', () => {
|
|
test('accepts clean slug', () => {
|
|
expect(validateSlug('people/sarah-chen')).toBe(true);
|
|
});
|
|
|
|
test('accepts slug with spaces (Apple Notes)', () => {
|
|
expect(validateSlug('apple-notes/2017-05-03 ohmygreen')).toBe(true);
|
|
});
|
|
|
|
test('accepts slug with parens', () => {
|
|
expect(validateSlug('apple-notes/notes (march 2024)')).toBe(true);
|
|
});
|
|
|
|
test('accepts slug with special chars', () => {
|
|
expect(validateSlug("notes/it's a test")).toBe(true);
|
|
expect(validateSlug('notes/file@2024')).toBe(true);
|
|
expect(validateSlug('notes/50% complete')).toBe(true);
|
|
});
|
|
|
|
test('accepts slug with unicode', () => {
|
|
expect(validateSlug('notes/日本語テスト')).toBe(true);
|
|
expect(validateSlug('notes/café-meeting')).toBe(true);
|
|
});
|
|
|
|
test('rejects empty slug', () => {
|
|
expect(validateSlug('')).toBe(false);
|
|
});
|
|
|
|
test('rejects path traversal', () => {
|
|
expect(validateSlug('../etc/passwd')).toBe(false);
|
|
expect(validateSlug('notes/../../etc')).toBe(false);
|
|
});
|
|
|
|
test('rejects leading slash', () => {
|
|
expect(validateSlug('/absolute/path')).toBe(false);
|
|
});
|
|
|
|
test('accepts slug with dots (not traversal)', () => {
|
|
expect(validateSlug('notes/v1.0.0')).toBe(true);
|
|
expect(validateSlug('notes/file.name.md')).toBe(true);
|
|
});
|
|
});
|