npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

trilium-api

v1.0.4

Published

A type-safe TypeScript client for the Trilium Notes ETAPI

Readme

trilium-api

A type-safe TypeScript client for the Trilium Notes ETAPI.

Table of Contents

Features

  • Fully typed - Auto-generated types from OpenAPI specification
  • Lightweight - Built on openapi-fetch (~6kb)
  • Query Builder - Type-safe search query construction
  • Mapper - Declarative note-to-object mapping with transforms
  • StandardNote - Consistent base fields (id, title, dates) on all mapped types

Installation

npm install trilium-api
# or
pnpm add trilium-api

Quick Start

import { createTriliumClient } from 'trilium-api';

const client = createTriliumClient({
  baseUrl: 'http://localhost:8080',
  apiKey: 'your-etapi-token',
});

// Get app info
const { data: appInfo } = await client.GET('/app-info');
console.log(`Trilium version: ${appInfo?.appVersion}`);

// Get a note by ID
const { data: note } = await client.GET('/notes/{noteId}', {
  params: { path: { noteId: 'root' } },
});

// Search notes
const { data: results } = await client.GET('/notes', {
  params: { query: { search: '#blog' } },
});

API Reference

Creating a Client

import { createTriliumClient } from 'trilium-api';

const client = createTriliumClient({
  baseUrl: 'http://localhost:8080', // Your Trilium server URL
  apiKey: 'your-etapi-token',        // ETAPI token from Trilium settings
});

Common Operations

Get a Note

const { data: note, error } = await client.GET('/notes/{noteId}', {
  params: { path: { noteId: 'abc123' } },
});

if (error) {
  console.error('Failed to fetch note:', error);
} else {
  console.log(note.title);
}

Create a Note

const { data } = await client.POST('/create-note', {
  body: {
    parentNoteId: 'root',
    title: 'My New Note',
    type: 'text',
    content: '<p>Hello World!</p>',
  },
});

console.log(`Created note: ${data?.note?.noteId}`);

Update a Note

await client.PATCH('/notes/{noteId}', {
  params: { path: { noteId: 'abc123' } },
  body: { title: 'Updated Title' },
});

Delete a Note

await client.DELETE('/notes/{noteId}', {
  params: { path: { noteId: 'abc123' } },
});

Get/Update Note Content

// Get content
const { data: content } = await client.GET('/notes/{noteId}/content', {
  params: { path: { noteId: 'abc123' } },
});

// Update content
await client.PUT('/notes/{noteId}/content', {
  params: { path: { noteId: 'abc123' } },
  body: '<p>New content</p>',
});

Branches

// Create a branch (clone a note to another location)
const { data: branch } = await client.POST('/branches', {
  body: {
    noteId: 'sourceNote123',
    parentNoteId: 'targetParent456',
  },
});

// Delete a branch
await client.DELETE('/branches/{branchId}', {
  params: { path: { branchId: 'branch123' } },
});

Attributes

// Create a label
await client.POST('/attributes', {
  body: {
    noteId: 'abc123',
    type: 'label',
    name: 'status',
    value: 'published',
  },
});

// Create a relation
await client.POST('/attributes', {
  body: {
    noteId: 'abc123',
    type: 'relation',
    name: 'author',
    value: 'authorNoteId',
  },
});

Search Query Builder

Build type-safe Trilium search queries with the buildSearchQuery helper:

import { buildSearchQuery } from 'trilium-api';

// Simple label search
buildSearchQuery({ '#blog': true });
// => '#blog'

// Label with value
buildSearchQuery({ '#status': 'published' });
// => "#status = 'published'"

// Label absence check
buildSearchQuery({ '#draft': false });
// => '#!draft'

// Comparison operators
buildSearchQuery({ '#wordCount': { value: 1000, operator: '>=' } });
// => '#wordCount >= 1000'

// Note properties
buildSearchQuery({ 'note.type': 'text', title: { value: 'Blog', operator: '*=' } });
// => "note.type = 'text' AND note.title *= 'Blog'"

// Relations
buildSearchQuery({ '~author': 'John' });
// => "~author *=* 'John'"

// AND conditions
buildSearchQuery({
  AND: [
    { '#blog': true },
    { '#published': true },
  ],
});
// => '#blog AND #published'

// OR conditions
buildSearchQuery({
  OR: [
    { '#status': 'draft' },
    { '#status': 'review' },
  ],
});
// => "#status = 'draft' OR #status = 'review'"

// NOT conditions
buildSearchQuery({
  NOT: { '#archived': true },
});
// => 'not(#archived)'

// Complex nested conditions
buildSearchQuery({
  AND: [
    { '#blog': true },
    { 'note.type': 'text' },
    { OR: [
      { '#category': 'tech' },
      { '#category': 'programming' },
    ]},
    { NOT: { '#draft': true } },
  ],
});
// => "#blog AND note.type = 'text' AND (#category = 'tech' OR #category = 'programming') AND not(#draft)"

Using with the Client

const query = buildSearchQuery({
  AND: [
    { '#blog': true },
    { '#published': true },
  ],
});

const { data } = await client.GET('/notes', {
  params: { query: { search: query, limit: 10 } },
});

Note Mapper

Map Trilium notes to strongly-typed objects using declarative field mappings.

StandardNote Base Type

All mapped types should extend StandardNote, which provides consistent base fields:

import type { StandardNote } from 'trilium-api';

// StandardNote includes:
// - id: string (note ID)
// - title: string (note title)  
// - dateCreatedUtc: Date
// - dateLastModifiedUtc: Date

interface BlogPost extends StandardNote {
  slug: string;
  tags: string[];
  isPublished: boolean;
}

Using TriliumMapper Directly

For standalone mapping (outside of searchAndMap), use TriliumMapper:

import { TriliumMapper, StandardNoteMapping, transforms, type StandardNote } from 'trilium-api';

interface BlogPost extends StandardNote {
  slug: string;
  wordCount: number;
  readTimeMinutes: number;
  tags: string[];
  isPublished: boolean;
}

// Merge StandardNoteMapping with your custom fields
const blogMapper = new TriliumMapper<BlogPost>(
  TriliumMapper.merge(
    StandardNoteMapping,
    {
      slug: { from: '#slug', required: true },
      wordCount: { from: '#wordCount', transform: transforms.number, default: 0 },
      readTimeMinutes: {
        computed: (partial) => Math.ceil((partial.wordCount || 0) / 200),
      },
      tags: { from: '#tags', transform: transforms.commaSeparated, default: [] },
      isPublished: { from: '#published', transform: transforms.boolean, default: false },
    }
  )
);

// Map notes
const post = blogMapper.map(note);
const posts = blogMapper.map(notes);

Field Mapping Options

Shorthand String Path

{
  title: 'note.title',      // Note property
  slug: '#slug',            // Label attribute
  authorId: '~author',      // Relation attribute
}

Full Configuration Object

{
  fieldName: {
    from: '#labelName',           // Source path or extractor function
    transform: transforms.number, // Optional transform function
    default: 0,                   // Default value if undefined
    required: false,              // Throw if missing (default: false)
  },
}

Custom Extractor Function

{
  labelCount: {
    from: (note) => note.attributes?.filter(a => a.type === 'label').length || 0,
  },
}

Computed Fields

{
  readTimeMinutes: {
    computed: (partial, note) => Math.ceil((partial.wordCount || 0) / 200),
    default: 1,
  },
}

Built-in Transforms

| Transform | Description | Example | |-----------|-------------|---------| | transforms.number | Convert to number | "123"123 | | transforms.boolean | Convert to boolean | "true"true | | transforms.commaSeparated | Split string to array | "a, b, c"["a", "b", "c"] | | transforms.json | Parse JSON string | '{"a":1}'{ a: 1 } | | transforms.date | Parse date string | "2024-01-15"Date | | transforms.trim | Trim whitespace | " hello ""hello" |

Search and Map

The searchAndMap method combines searching and mapping in a single call. It automatically includes StandardNoteMapping, so you only need to define your custom fields!

import { createTriliumClient, transforms, type StandardNote, type CustomMapping } from 'trilium-api';

const client = createTriliumClient({
  baseUrl: 'http://localhost:8080',
  apiKey: 'your-etapi-token',
});

// Extend StandardNote with your custom fields
interface BlogPost extends StandardNote {
  slug: string;
  published: boolean;
}

// Use CustomMapping<T> for clean typing - excludes StandardNote fields automatically
const blogMapping: CustomMapping<BlogPost> = {
  slug: '#slug',
  published: { from: '#published', transform: transforms.boolean, default: false },
};

// Just pass your custom mapping - StandardNoteMapping is auto-merged!
const { data, failures } = await client.searchAndMap<BlogPost>({
  query: { '#blog': true, '#published': true },
  mapping: blogMapping,
  limit: 10,
  orderBy: 'dateModified',
  orderDirection: 'desc',
});

// Each post has: id, title, dateCreatedUtc, dateLastModifiedUtc, slug, published
data.forEach(post => {
  console.log(`${post.title} (${post.id}) - ${post.slug}`);
});

// Check for mapping failures
if (failures.length > 0) {
  console.warn(`${failures.length} notes failed to map:`);
  failures.forEach(f => console.warn(`  - ${f.noteTitle}: ${f.reason}`));
}

Options

| Option | Type | Description | |--------|------|-------------| | query | string \| object | Search query string or structured query object | | mapping | CustomMapping<T> | Field mapping for your custom fields (StandardNote fields auto-merged) | | limit | number | Maximum number of results | | orderBy | string | Field to order by (e.g., 'dateModified', 'title') | | orderDirection | 'asc' \| 'desc' | Sort direction | | fastSearch | boolean | Enable fast search mode (less accurate but faster) |

Return Value

{
  data: T[],              // Successfully mapped objects
  failures: MappingFailure[]  // Notes that failed to map
}

Handling Failures

When a note fails to map (e.g., missing required field, transform error), it's added to the failures array instead of throwing:

interface MappingFailure {
  noteId: string;    // The note ID that failed
  noteTitle: string; // The note title for identification
  reason: string;    // Error message explaining the failure
  note: TriliumNote; // The original note object for debugging
}

This allows you to process partial results while still knowing which notes had issues:

interface BlogPost extends StandardNote {
  slug: string;
}

const { data, failures } = await client.searchAndMap<BlogPost>({
  query: '#blog',
  mapping: {
    slug: { from: '#slug', required: true }, // Will fail if missing
  },
});

// data contains all successfully mapped posts
// failures contains notes missing the required #slug label

Error Handling

API or network errors throw an exception:

try {
  const { data, failures } = await client.searchAndMap<BlogPost>({
    query: '#blog',
    mapping: blogMapping,
  });
} catch (err) {
  console.error('Search failed:', err);
}

Types

The package exports a focused set of types for common use cases:

// Main imports for typical usage
import { 
  createTriliumClient, 
  transforms, 
  buildSearchQuery,
} from 'trilium-api';

import type { 
  // Your mapped types should extend this
  StandardNote,
  // For typing your custom field mappings
  CustomMapping,
  // For typing query objects
  TriliumSearchHelpers,
  // For error handling
  MappingFailure,
  // Trilium entity types (for API responses)
  TriliumNote,
  TriliumBranch,
  TriliumAttribute,
  TriliumAttachment,
  TriliumAppInfo,
} from 'trilium-api';

// Advanced: for standalone TriliumMapper usage (outside searchAndMap)
import { TriliumMapper, StandardNoteMapping } from 'trilium-api';
import type { MappingConfig } from 'trilium-api';

Error Handling

The client returns { data, error } for all operations:

const { data, error } = await client.GET('/notes/{noteId}', {
  params: { path: { noteId: 'nonexistent' } },
});

if (error) {
  // error contains the response body on failure
  console.error('Error:', error);
} else {
  // data is typed based on the endpoint
  console.log('Note:', data.title);
}

Demo

Several demo scripts are included to help you understand the library's features.

Note Tree Demo

Connects to a local Trilium instance and displays a tree view of your notes.

# Set your ETAPI token and run
TRILIUM_API_KEY=your-token pnpm demo

# On Windows PowerShell
$env:TRILIUM_API_KEY="your-token"; pnpm demo

Configuration:

| Variable | Default | Description | |----------|---------|-------------| | TRILIUM_URL | http://localhost:8080 | Trilium server URL | | TRILIUM_API_KEY | - | Your ETAPI token (required) | | MAX_DEPTH | 3 | Maximum depth of the note tree |

Getting Your ETAPI Token:

  1. Open Trilium Notes
  2. Go to Menu > Options > ETAPI
  3. Click Create new ETAPI token
  4. Copy the generated token

Search Query Builder Demo

Demonstrates how to build type-safe search queries (no Trilium connection required).

pnpm demo:search

Example output:

1. Simple label search:
   Code: buildSearchQuery({ "#blog": true })
   Result: #blog

2. Label with value:
   Code: buildSearchQuery({ "#status": "published" })
   Result: #status = 'published'

3. Complex nested conditions:
   Result: #blog AND (#status = 'published' OR #status = 'featured') AND #wordCount >= 500

Note Mapper Demo

Demonstrates how to map Trilium notes to strongly-typed objects (no Trilium connection required).

pnpm demo:mapper

Example output:

Title: Getting Started with TypeScript
  ID: note1
  Slug: getting-started-typescript
  Status: published
  Word Count: 1500
  Tags: [typescript, programming, tutorial]
  Published: 2024-01-20T00:00:00.000Z
  Read Time: 8 min

Development

Prerequisites

  • Node.js 18+
  • pnpm (recommended) or npm

Setup

# Clone the repository
git clone https://github.com/your-username/trilium-api.git
cd trilium-api

# Install dependencies
pnpm install

Scripts

| Script | Description | |--------|-------------| | pnpm test | Run tests in watch mode | | pnpm run test:run | Run tests once | | pnpm run test:ts | Type check without emitting | | pnpm run generate-api | Regenerate types from OpenAPI spec |

Regenerating API Types

The TypeScript types are auto-generated from the TriliumNext OpenAPI specification. To regenerate types after an API update:

pnpm run generate-api

This runs openapi-typescript which:

  1. Fetches the latest OpenAPI spec from the TriliumNext repository
  2. Generates TypeScript types to src/generated/trilium.d.ts
  3. Creates fully typed paths, components, and operations interfaces

Using a Different OpenAPI Source

To generate from a local file or different URL, modify the command in package.json:

{
  "scripts": {
    "generate-api": "openapi-typescript ./path/to/local/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
  }
}

Or from a different URL:

{
  "scripts": {
    "generate-api": "openapi-typescript https://your-server.com/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
  }
}

Verifying Generation

After regenerating, always verify:

# 1. Check TypeScript compilation
pnpm run test:ts

# 2. Run all tests
pnpm run test:run

# 3. Check for any breaking changes in the generated types
git diff src/generated/trilium.d.ts

Writing Tests

Tests are written using Vitest and located alongside source files with .test.ts extension.

Test File Structure

src/
├── client.ts          # API client
├── client.test.ts     # Client tests
├── mapper.ts          # Mapper utilities
├── mapper.test.ts     # Mapper tests
└── generated/
    └── trilium.d.ts   # Generated types (don't test directly)

Adding Tests for the Client

The client tests mock fetch globally. Here's how to add a new test:

// src/client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTriliumClient } from './client.js';

const mockFetch = vi.fn();
globalThis.fetch = mockFetch;

// Helper to create mock responses
function createMockResponse(body: any, status = 200, contentType = 'application/json') {
  return {
    ok: status >= 200 && status < 300,
    status,
    headers: new Headers({ 'content-type': contentType }),
    json: async () => body,
    text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
    blob: async () => new Blob([JSON.stringify(body)]),
    clone: function() { return this; },
  };
}

describe('my new feature', () => {
  const config = {
    baseUrl: 'http://localhost:8080',
    apiKey: 'test-api-key',
  };

  beforeEach(() => {
    mockFetch.mockReset();
  });

  it('should do something', async () => {
    // 1. Setup mock response
    mockFetch.mockResolvedValueOnce(createMockResponse({ 
      noteId: 'test123',
      title: 'Test Note' 
    }));

    // 2. Create client and make request
    const client = createTriliumClient(config);
    const { data, error } = await client.GET('/notes/{noteId}', {
      params: { path: { noteId: 'test123' } },
    });

    // 3. Assert results
    expect(error).toBeUndefined();
    expect(data?.title).toBe('Test Note');

    // 4. Verify the request (openapi-fetch uses Request objects)
    const request = mockFetch.mock.calls[0]![0] as Request;
    expect(request.url).toBe('http://localhost:8080/etapi/notes/test123');
    expect(request.method).toBe('GET');
    expect(request.headers.get('Authorization')).toBe('test-api-key');
  });
});

Adding Tests for the Mapper

Mapper tests don't require fetch mocking—just create mock note objects:

// src/mapper.test.ts
import { describe, it, expect } from 'vitest';
import { TriliumMapper, transforms, buildSearchQuery } from './mapper.js';
import type { TriliumNote } from './client.js';

// Helper to create mock notes
function createMockNote(overrides: Partial<TriliumNote> = {}): TriliumNote {
  return {
    noteId: 'test123',
    title: 'Test Note',
    type: 'text',
    mime: 'text/html',
    isProtected: false,
    blobId: 'blob123',
    attributes: [],
    parentNoteIds: ['root'],
    childNoteIds: [],
    parentBranchIds: ['branch123'],
    childBranchIds: [],
    dateCreated: '2024-01-01 12:00:00.000+0000',
    dateModified: '2024-01-01 12:00:00.000+0000',
    utcDateCreated: '2024-01-01 12:00:00.000Z',
    utcDateModified: '2024-01-01 12:00:00.000Z',
    ...overrides,
  };
}

describe('my mapper feature', () => {
  it('should map custom fields', () => {
    interface MyType {
      customField: string;
    }

    const mapper = new TriliumMapper<MyType>({
      customField: {
        from: '#myLabel',
        transform: (value) => String(value).toUpperCase(),
      },
    });

    const note = createMockNote({
      attributes: [{
        attributeId: 'a1',
        noteId: 'test123',
        type: 'label',
        name: 'myLabel',
        value: 'hello',
        position: 0,
        isInheritable: false,
      }],
    });

    const result = mapper.map(note);
    expect(result.customField).toBe('HELLO');
  });
});

describe('buildSearchQuery', () => {
  it('should handle my custom query', () => {
    const query = buildSearchQuery({
      '#myLabel': { value: 100, operator: '>=' },
    });
    expect(query).toBe('#myLabel >= 100');
  });
});

Adding a New Transform

To add a custom transform function:

// src/mapper.ts - add to the transforms object
export const transforms = {
  // ... existing transforms ...

  /** Convert to lowercase */
  lowercase: (value: unknown): string | undefined => {
    if (value === undefined || value === null) return undefined;
    return String(value).toLowerCase();
  },

  /** Parse as URL */
  url: (value: unknown): URL | undefined => {
    if (value === undefined || value === null || value === '') return undefined;
    try {
      return new URL(String(value));
    } catch {
      return undefined;
    }
  },
};

Then add tests:

// src/mapper.test.ts
describe('transforms', () => {
  describe('lowercase', () => {
    it('should convert to lowercase', () => {
      expect(transforms.lowercase('HELLO')).toBe('hello');
      expect(transforms.lowercase('HeLLo WoRLD')).toBe('hello world');
    });

    it('should return undefined for null/undefined', () => {
      expect(transforms.lowercase(undefined)).toBeUndefined();
      expect(transforms.lowercase(null)).toBeUndefined();
    });
  });
});

Running Specific Tests

# Run tests matching a pattern
pnpm test -- -t "buildSearchQuery"

# Run tests in a specific file
pnpm test -- src/mapper.test.ts

# Run with coverage
pnpm test -- --coverage

Debugging Tests

Add .only to focus on specific tests:

describe.only('focused suite', () => {
  it.only('focused test', () => {
    // Only this test will run
  });
});

Use console.log or the Vitest UI:

pnpm test -- --ui

Releasing

This project uses GitHub Actions to automatically version, release, and publish to npm.

Creating a Release

  1. Go to ActionsPublish to npmRun workflow
  2. Select the version bump type:
    • patch - Bug fixes (1.0.0 → 1.0.1) - default
    • minor - New features (1.0.0 → 1.1.0)
    • major - Breaking changes (1.0.0 → 2.0.0)
  3. Click Run workflow

The workflow will automatically:

  • Run type checking and tests
  • Bump the version in package.json
  • Commit the version change and create a git tag
  • Build the package (CJS, ESM, and TypeScript declarations)
  • Publish to npm
  • Create a GitHub Release with auto-generated release notes

Verifying the Release

  • Check the Actions tab for the workflow status
  • Verify the package on npm

License

This project is licensed under the GNU Affero General Public License v3.0.