/**
* Core LogseqOperations class with base page and block operations.
* @module
*/
import { LogseqClient } from "../logseq-client.js";
import type {
Page,
Block,
SearchResult,
CreateBlockOptions,
UpdateBlockOptions,
QueryResult,
ApiResponse,
BlockNode,
CreateBlocksResult,
CreatePageOptions,
CreatePageResult,
GetPagesResult,
GetPagesResultItem,
GraphStats,
FindMissingPagesResult,
MissingPage,
FindPagesByPropertiesOptions,
FindPagesByPropertiesResult,
PageWithProperties,
FindOrphanPagesOptions,
FindOrphanPagesResult,
OrphanPage,
UpdatePagePropertiesOptions,
UpdatePagePropertiesResult,
PageWithContext,
BacklinkReference,
} from "../types.js";
import { LogseqApiError, LogseqNotFoundError, LogseqValidationError } from "../errors.js";
/**
* High-level operations for Logseq.
*
* This class provides a simplified interface for common Logseq operations,
* with automatic error handling that throws typed exceptions instead of
* returning error responses.
*
* @remarks
* Use this class instead of {@link LogseqClient} for most operations.
* It provides better error messages and a more ergonomic API.
*
* @example
* ```typescript
* import { LogseqClient, LogseqOperations } from "@logseq-ai/core";
*
* const client = new LogseqClient();
* const ops = new LogseqOperations(client);
*
* // Search for pages
* const results = await ops.search("meeting");
*
* // Get page content as text
* const content = await ops.getPageContent("My Notes");
*
* // Create a new block
* const block = await ops.createBlock({
* pageName: "My Notes",
* content: "New idea!"
* });
* ```
*
* @throws {LogseqApiError} When an API call fails
* @throws {LogseqConnectionError} When unable to connect to Logseq
* @throws {LogseqValidationError} When invalid arguments are provided
* @throws {LogseqNotFoundError} When a requested resource doesn't exist
*/
export class LogseqOperations {
protected client: LogseqClient;
/**
* Creates a new LogseqOperations instance.
*
* @param client - The LogseqClient to use for API calls
*/
constructor(client: LogseqClient) {
this.client = client;
}
/**
* Throws an appropriate error based on the API response.
* @internal
*/
protected throwOnError<T>(
result: ApiResponse<T>,
operation: string
): asserts result is ApiResponse<T> & { success: true } {
if (!result.success) {
const details = result.errorDetails;
if (details?.type === "api" && details.method) {
throw LogseqApiError.fromMethodError(details.method, result.error ?? "Unknown error");
}
throw new LogseqApiError(
`${operation} failed: ${result.error}`,
details?.method ?? "unknown",
details?.statusCode
);
}
}
/**
* Searches for pages and blocks matching a query.
*
* @param query - Search query string
* @returns Array of search results
* @throws {LogseqApiError} If the search fails
*
* @example
* ```typescript
* const results = await ops.search("project ideas");
* results.forEach(r => console.log(r.page));
* ```
*/
async search(query: string): Promise<SearchResult[]> {
const result = await this.client.search(query);
this.throwOnError(result, "Search");
return result.data ?? [];
}
/**
* Retrieves a page with its block tree.
*
* @param pageName - Name of the page to retrieve
* @returns Page data with blocks, or null if not found
* @throws {LogseqApiError} If the API call fails
*/
async getPage(pageName: string): Promise<{ page: Page; blocks: Block[] } | null> {
const pageResult = await this.client.getPage(pageName);
this.throwOnError(pageResult, `Get page "${pageName}"`);
if (!pageResult.data) {
return null;
}
const blocksResult = await this.client.getPageBlocksTree(pageName);
this.throwOnError(blocksResult, `Get blocks for page "${pageName}"`);
return {
page: pageResult.data,
blocks: blocksResult.data ?? [],
};
}
/**
* Retrieves page content as formatted plain text.
*
* @param pageName - Name of the page
* @returns Page content as indented text, or null if page not found
* @throws {LogseqApiError} If the API call fails
*
* @remarks
* Blocks are formatted as a bulleted list with indentation for nested blocks.
*
* @example
* ```typescript
* const content = await ops.getPageContent("Meeting Notes");
* // Returns:
* // - First block
* // - Second block
* // - Nested block
* ```
*/
async getPageContent(pageName: string): Promise<string | null> {
const pageData = await this.getPage(pageName);
if (!pageData) {
return null;
}
return this.blocksToText(pageData.blocks);
}
/**
* Convert blocks tree to plain text
*/
blocksToText(blocks: Block[], indent = 0): string {
const lines: string[] = [];
const prefix = " ".repeat(indent);
for (const block of blocks) {
if (block.content) {
lines.push(`${prefix}- ${block.content}`);
}
if (block.children && block.children.length > 0) {
lines.push(this.blocksToText(block.children, indent + 1));
}
}
return lines.join("\n");
}
/**
* Parse properties from the first block's content.
* Properties are in format "key:: value" on separate lines.
*/
protected parsePropertiesFromContent(content: string): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const lines = content.split("\n");
for (const line of lines) {
const match = line.match(/^([a-zA-Z_-]+)::\s*(.*)$/);
if (match && match[1] && match[2] !== undefined) {
properties[match[1]] = match[2].trim();
}
}
return properties;
}
/**
* Retrieves multiple pages in a single batch operation.
*
* This is more efficient than calling {@link getPage} multiple times
* when you need to check several pages at once.
*
* @param pageNames - Array of page names to retrieve
* @returns Batch result with page data and existence flags
* @throws {LogseqApiError} If the API calls fail (not for missing pages)
*
* @remarks
* Missing pages are returned with `exists: false` rather than throwing.
* Properties are parsed from the first block if it contains `key:: value` lines.
*
* @example
* ```typescript
* const result = await ops.getPages(["Milvus", "MLflow", "NonExistent"]);
* console.log(`Found ${result.found}, missing ${result.missing}`);
* result.pages.forEach(p => {
* if (p.exists) {
* console.log(`${p.name}: ${p.properties?.type}`);
* }
* });
* ```
*/
async getPages(pageNames: string[]): Promise<GetPagesResult> {
// Fetch all pages in parallel
const results = await Promise.all(
pageNames.map(async (name): Promise<GetPagesResultItem> => {
try {
const pageData = await this.getPage(name);
if (!pageData) {
return { name, exists: false };
}
const content = this.blocksToText(pageData.blocks);
// Parse properties from first block if it exists
let properties: Record<string, unknown> | undefined;
const firstBlock = pageData.blocks[0];
if (firstBlock && firstBlock.content) {
const parsed = this.parsePropertiesFromContent(firstBlock.content);
if (Object.keys(parsed).length > 0) {
properties = parsed;
}
}
return {
name,
exists: true,
content,
properties,
};
} catch {
// If there's an error fetching, treat as not found
return { name, exists: false };
}
})
);
const found = results.filter((r) => r.exists).length;
return {
pages: results,
found,
missing: results.length - found,
};
}
/**
* Lists all pages in the current graph.
*
* @returns Array of all pages
* @throws {LogseqApiError} If the API call fails
*
* @remarks
* This can be slow on large graphs. Consider using {@link search} instead.
*/
async listPages(): Promise<Page[]> {
const result = await this.client.getAllPages();
this.throwOnError(result, "List pages");
return result.data ?? [];
}
/**
* Gets statistics about the current graph.
*
* Provides an overview of graph health including page counts by type,
* orphan pages (no incoming links), and missing pages (referenced but don't exist).
*
* @returns Graph statistics
* @throws {LogseqApiError} If the queries fail
*
* @example
* ```typescript
* const stats = await ops.getGraphStats();
* console.log(`Total: ${stats.totalPages}, Orphans: ${stats.orphanPages}`);
* console.log(`Types: ${JSON.stringify(stats.pagesByType)}`);
* ```
*/
async getGraphStats(): Promise<GraphStats> {
// Get all pages
const allPages = await this.listPages();
const totalPages = allPages.length;
const journalPages = allPages.filter((p) => p.journal).length;
// Count pages by type using Datalog query
// Find all pages with a type property
const typeQuery = `
[:find ?type (count ?p)
:where
[?p :block/name]
[?p :block/file]
[?b :block/page ?p]
[?b :block/properties ?props]
[(get ?props :type) ?type]
[(some? ?type)]]
`;
const pagesByType: Record<string, number> = {};
try {
const typeResults = await this.query(typeQuery);
for (const row of typeResults) {
if (Array.isArray(row) && row.length >= 2) {
const typeValue = String(row[0]);
const count = Number(row[1]);
if (typeValue && !isNaN(count)) {
pagesByType[typeValue] = count;
}
}
}
} catch {
// Query might fail if no pages have type property - that's ok
}
// Find orphan pages (pages with no incoming references)
// A page is orphan if no other block references it
const orphanQuery = `
[:find (count ?p)
:where
[?p :block/name ?name]
[?p :block/file]
(not [?p :block/journal?])
(not [?b :block/refs ?p]
[?b :block/page ?other]
[(not= ?other ?p)])]
`;
let orphanPages = 0;
try {
const orphanResults = await this.query(orphanQuery);
if (orphanResults.length > 0 && Array.isArray(orphanResults[0])) {
orphanPages = Number(orphanResults[0][0]) || 0;
}
} catch {
// Query might fail - that's ok
}
// Find missing pages (referenced but don't exist)
const missingQuery = `
[:find (count ?p)
:where
[?b :block/refs ?p]
[?p :block/name]
(not [?p :block/file])]
`;
let missingPages = 0;
try {
const missingResults = await this.query(missingQuery);
if (missingResults.length > 0 && Array.isArray(missingResults[0])) {
missingPages = Number(missingResults[0][0]) || 0;
}
} catch {
// Query might fail - that's ok
}
return {
totalPages,
pagesByType,
orphanPages,
missingPages,
journalPages,
};
}
/**
* Finds pages that are referenced but don't exist.
*
* Useful for identifying broken links and pages that should be created.
*
* @param minReferences - Minimum number of references to include (default: 1)
* @returns List of missing pages with reference counts and referencing pages
* @throws {LogseqApiError} If the query fails
*
* @example
* ```typescript
* // Find all missing pages referenced 3+ times
* const result = await ops.findMissingPages(3);
* result.missingPages.forEach(p => {
* console.log(`${p.name}: ${p.referenceCount} refs from ${p.referencedBy.join(", ")}`);
* });
* ```
*/
async findMissingPages(minReferences = 1): Promise<FindMissingPagesResult> {
// Query to find all references to pages that don't have a file (don't exist)
// Returns: [missing-page-name, referencing-block-id, referencing-page-name]
const query = `
[:find ?name ?ref-page-name
:where
[?b :block/refs ?p]
[?p :block/name ?name]
(not [?p :block/file])
[?b :block/page ?ref-page]
[?ref-page :block/name ?ref-page-name]]
`;
const results = await this.query(query);
// Group by missing page name
const pageMap = new Map<string, Set<string>>();
for (const row of results) {
if (Array.isArray(row) && row.length >= 2) {
const missingName = String(row[0]);
const referencingPage = String(row[1]);
if (!pageMap.has(missingName)) {
pageMap.set(missingName, new Set());
}
pageMap.get(missingName)!.add(referencingPage);
}
}
// Convert to array and filter by minReferences
const missingPages: MissingPage[] = [];
for (const [name, referencingPages] of pageMap) {
const referenceCount = referencingPages.size;
if (referenceCount >= minReferences) {
missingPages.push({
name,
referenceCount,
referencedBy: Array.from(referencingPages).sort(),
});
}
}
// Sort by reference count descending
missingPages.sort((a, b) => b.referenceCount - a.referenceCount);
return {
missingPages,
total: missingPages.length,
};
}
/**
* Finds pages by their properties.
*
* Can filter by specific property values and/or find pages missing certain properties.
* Useful for finding incomplete pages that need enrichment.
*
* @param options - Search options including properties to match and missing properties
* @returns List of matching pages with their properties
* @throws {LogseqApiError} If the query fails
*
* @example
* ```typescript
* // Find all person pages missing job-title
* const result = await ops.findPagesByProperties({
* properties: { type: "#person" },
* missingProperties: ["job-title", "email"]
* });
* ```
*/
async findPagesByProperties(
options: FindPagesByPropertiesOptions = {}
): Promise<FindPagesByPropertiesResult> {
const { properties = {}, missingProperties = [], limit = 100 } = options;
// Build query based on required properties
// If we have properties to match, query for those
// Otherwise, get all pages with any properties
const propertyEntries = Object.entries(properties);
let query: string;
if (propertyEntries.length > 0) {
// Query for pages with specific property value
const firstEntry = propertyEntries[0]!;
const firstKey = firstEntry[0];
const firstValue = firstEntry[1];
query = `
[:find ?page-name ?props
:where
[?p :block/name ?page-name]
[?p :block/file]
[?b :block/page ?p]
[?b :block/properties ?props]
[(get ?props :${firstKey}) ?v]
[(= ?v "${firstValue}")]]
`;
} else {
// Get all pages with properties
query = `
[:find ?page-name ?props
:where
[?p :block/name ?page-name]
[?p :block/file]
[?b :block/page ?p]
[?b :block/properties ?props]
[(not-empty ?props)]]
`;
}
const results = await this.query(query);
// Process results and filter
const pageMap = new Map<string, Record<string, string>>();
for (const row of results) {
if (Array.isArray(row) && row.length >= 2) {
const pageName = String(row[0]);
const props = row[1] as Record<string, unknown>;
// Convert properties to string values
const stringProps: Record<string, string> = {};
for (const [key, value] of Object.entries(props)) {
if (value !== null && value !== undefined) {
stringProps[key] = String(value);
}
}
// Merge properties if page already seen (multiple blocks with properties)
if (pageMap.has(pageName)) {
Object.assign(pageMap.get(pageName)!, stringProps);
} else {
pageMap.set(pageName, stringProps);
}
}
}
// Filter by all required properties and missing properties
const matchingPages: PageWithProperties[] = [];
for (const [name, props] of pageMap) {
// Check all required properties match
let allMatch = true;
for (const [key, value] of propertyEntries) {
if (props[key] !== value) {
allMatch = false;
break;
}
}
if (!allMatch) continue;
// Check for missing properties
if (missingProperties.length > 0) {
const hasMissing = missingProperties.some((prop) => !props[prop]);
if (!hasMissing) continue;
}
matchingPages.push({ name, properties: props });
if (matchingPages.length >= limit) break;
}
// Sort alphabetically by name
matchingPages.sort((a, b) => a.name.localeCompare(b.name));
return {
pages: matchingPages,
total: matchingPages.length,
};
}
/**
* Finds pages with no incoming links (orphan pages).
*
* Orphan pages are pages that exist but are not referenced by any other page.
* These may be outdated, forgotten, or need to be linked from somewhere.
*
* @param options - Search options including type filter and journal inclusion
* @returns List of orphan pages
* @throws {LogseqApiError} If the query fails
*
* @example
* ```typescript
* // Find all orphan person pages
* const result = await ops.findOrphanPages({ type: "#person" });
* result.orphanPages.forEach(p => console.log(p.name));
* ```
*/
async findOrphanPages(options: FindOrphanPagesOptions = {}): Promise<FindOrphanPagesResult> {
const { type, includeJournals = false, limit = 100 } = options;
// Query for pages with no incoming references from other pages
// A page is orphan if no block from another page references it
const query = `
[:find ?page-name ?journal
:where
[?p :block/name ?page-name]
[?p :block/file]
[(get ?p :block/journal? false) ?journal]
(not-join [?p]
[?b :block/refs ?p]
[?b :block/page ?other-page]
[(not= ?other-page ?p)])]
`;
const results = await this.query(query);
// Also get type properties for filtering
const typeQuery = `
[:find ?page-name ?type
:where
[?p :block/name ?page-name]
[?p :block/file]
[?b :block/page ?p]
[?b :block/properties ?props]
[(get ?props :type) ?type]]
`;
const typeMap = new Map<string, string>();
try {
const typeResults = await this.query(typeQuery);
for (const row of typeResults) {
if (Array.isArray(row) && row.length >= 2) {
typeMap.set(String(row[0]), String(row[1]));
}
}
} catch {
// Type query might fail - that's ok
}
// Process results
const orphanPages: OrphanPage[] = [];
for (const row of results) {
if (Array.isArray(row) && row.length >= 2) {
const name = String(row[0]);
const isJournal = Boolean(row[1]);
// Skip journals unless explicitly included
if (isJournal && !includeJournals) continue;
const pageType = typeMap.get(name);
// Filter by type if specified
if (type && pageType !== type) continue;
orphanPages.push({
name,
type: pageType,
isJournal,
});
if (orphanPages.length >= limit) break;
}
}
// Sort alphabetically
orphanPages.sort((a, b) => a.name.localeCompare(b.name));
return {
orphanPages,
total: orphanPages.length,
};
}
/**
* Updates properties on an existing page.
*
* Properties are stored in the first block of a page. This method finds that block,
* parses existing properties, and updates them according to the merge option.
*
* @param options - Update options including page name, properties, and merge flag
* @returns Result with updated properties
* @throws {LogseqNotFoundError} If the page doesn't exist
* @throws {LogseqApiError} If the update fails
*
* @remarks
* - If `alias::` is present, it will always be kept as the first property
* - When merge=true (default), new properties are added/updated while keeping existing ones
* - When merge=false, all existing properties are replaced
*
* @example
* ```typescript
* // Update team and job-title, keeping other properties
* await ops.updatePageProperties({
* pageName: "John Doe",
* properties: { team: "[[New Team]]", "job-title": "Senior Engineer" }
* });
*
* // Replace all properties
* await ops.updatePageProperties({
* pageName: "John Doe",
* properties: { type: "#person", team: "[[New Team]]" },
* merge: false
* });
* ```
*/
async updatePageProperties(
options: UpdatePagePropertiesOptions
): Promise<UpdatePagePropertiesResult> {
const { pageName, properties, merge = true } = options;
// Get the page and its blocks
const pageData = await this.getPage(pageName);
if (!pageData) {
throw new LogseqNotFoundError("page", pageName);
}
// Find the first block (properties block)
const firstBlock = pageData.blocks[0];
if (!firstBlock) {
throw new LogseqApiError(`Page "${pageName}" has no blocks`, "updatePageProperties");
}
// Parse existing properties from the first block
const existingProps = this.parsePropertiesFromContent(firstBlock.content);
// Determine final properties
let finalProps: Record<string, string>;
if (merge) {
// Merge: keep existing, update/add new
finalProps = { ...existingProps } as Record<string, string>;
for (const [key, value] of Object.entries(properties)) {
finalProps[key] = value;
}
} else {
// Replace: use only new properties
finalProps = { ...properties };
}
// Build new content, ensuring alias is first if present
const propLines: string[] = [];
// Handle alias first if it exists
if (finalProps["alias"]) {
propLines.push(`alias:: ${finalProps["alias"]}`);
}
// Add remaining properties in sorted order for consistency
const sortedKeys = Object.keys(finalProps)
.filter((k) => k !== "alias")
.sort();
for (const key of sortedKeys) {
propLines.push(`${key}:: ${finalProps[key]}`);
}
const newContent = propLines.join("\n");
// Update the block
await this.updateBlock({ uuid: firstBlock.uuid, content: newContent });
// Count changes
let changed = 0;
for (const [key, value] of Object.entries(properties)) {
if (existingProps[key] !== value) {
changed++;
}
}
return {
pageName,
properties: finalProps,
changed,
};
}
/**
* Gets a page with its full context including backlinks and forward links.
*
* Combines page content, properties, backlinks (pages linking TO this page),
* and forward links (pages this page links TO) in a single call.
*
* @param pageName - Name of the page to retrieve
* @returns Page with context, or null if page doesn't exist
* @throws {LogseqApiError} If the queries fail
*
* @example
* ```typescript
* const context = await ops.getPageWithContext("MLOps");
* if (context) {
* console.log(`Backlinks: ${context.backlinks.length}`);
* console.log(`Forward links: ${context.forwardLinks.join(", ")}`);
* }
* ```
*/
async getPageWithContext(pageName: string): Promise<PageWithContext | null> {
// Get the page and its content
const pageData = await this.getPage(pageName);
if (!pageData) {
return null;
}
const content = this.blocksToText(pageData.blocks);
// Parse properties from first block
const firstBlock = pageData.blocks[0];
const properties = firstBlock
? (this.parsePropertiesFromContent(firstBlock.content) as Record<string, string>)
: {};
// Get backlinks (pages that reference this page)
const pageNameLower = pageName.toLowerCase();
const backlinkQuery = `
[:find ?ref-page-name ?block-content ?block-uuid
:where
[?p :block/name "${pageNameLower}"]
[?b :block/refs ?p]
[?b :block/content ?block-content]
[?b :block/uuid ?block-uuid]
[?b :block/page ?ref-page]
[?ref-page :block/name ?ref-page-name]
[(not= ?ref-page-name "${pageNameLower}")]]
`;
const backlinks: BacklinkReference[] = [];
try {
const backlinkResults = await this.client.query(backlinkQuery);
if (backlinkResults.data) {
for (const row of backlinkResults.data) {
if (Array.isArray(row) && row.length >= 3) {
backlinks.push({
pageName: String(row[0]),
blockContent: String(row[1]),
blockUuid: String(row[2]),
});
}
}
}
} catch {
// Backlink query might fail - that's ok
}
// Get forward links (pages this page references)
const forwardLinkQuery = `
[:find ?linked-page-name
:where
[?p :block/name "${pageNameLower}"]
[?b :block/page ?p]
[?b :block/refs ?linked]
[?linked :block/name ?linked-page-name]
[(not= ?linked-page-name "${pageNameLower}")]]
`;
const forwardLinks: string[] = [];
try {
const forwardResults = await this.client.query(forwardLinkQuery);
if (forwardResults.data) {
const linkSet = new Set<string>();
for (const row of forwardResults.data) {
if (Array.isArray(row) && row.length >= 1) {
linkSet.add(String(row[0]));
}
}
forwardLinks.push(...Array.from(linkSet).sort());
}
} catch {
// Forward link query might fail - that's ok
}
// Sort backlinks by page name
backlinks.sort((a, b) => a.pageName.localeCompare(b.pageName));
return {
page: {
name: pageName,
content,
properties,
},
backlinks,
forwardLinks,
};
}
/**
* Creates a new page with optional initial content and blocks.
*
* @param pageNameOrOptions - Page name (string) or full options object
* @param content - Optional initial content for the first block (when using string pageName)
* @param properties - Optional page properties (when using string pageName)
* @returns The created page (simple) or CreatePageResult (with blocks)
* @throws {LogseqApiError} If page creation fails
*
* @example
* ```typescript
* // Simple usage (backward compatible)
* const page = await ops.createPage("New Project", "Initial notes here");
*
* // With blocks (new)
* const result = await ops.createPage({
* pageName: "New Tool",
* content: "type:: #Tool\nowner:: [[MLOps]]",
* blocks: [
* { content: "## Overview", children: [{ content: "Description" }] },
* { content: "## Links" }
* ]
* });
* console.log(`Created ${result.blocksCreated} blocks`);
* ```
*/
async createPage(
pageNameOrOptions: string | CreatePageOptions,
content?: string,
properties?: Record<string, unknown>
): Promise<Page | CreatePageResult> {
// Normalize arguments
const options: CreatePageOptions =
typeof pageNameOrOptions === "string"
? { pageName: pageNameOrOptions, content, properties }
: pageNameOrOptions;
// Always set createFirstBlock to false - we'll add content manually if needed
// This avoids creating an extra blank block
const result = await this.client.createPage(options.pageName, options.properties, {
createFirstBlock: false,
});
this.throwOnError(result, `Create page "${options.pageName}"`);
if (!result.data) {
throw new LogseqApiError(
`Failed to create page "${options.pageName}": No data returned`,
"logseq.Editor.createPage"
);
}
let blocksCreated = 0;
// Add initial content if provided
if (options.content) {
await this.client.appendBlockToPage(options.pageName, options.content);
blocksCreated++;
}
// If no blocks provided, return simple Page (backward compatible)
if (!options.blocks || options.blocks.length === 0) {
return result.data;
}
// Create additional blocks
const blocksResult = await this.createBlocks(options.pageName, options.blocks);
blocksCreated += blocksResult.created;
return {
page: result.data,
blocksCreated,
rootBlocks: blocksResult.rootBlocks,
};
}
/**
* Deletes a page by name.
*
* @param pageName - Name of the page to delete
* @throws {LogseqApiError} If deletion fails
*
* @remarks
* This permanently deletes the page and all its blocks.
*/
async deletePage(pageName: string): Promise<void> {
const result = await this.client.deletePage(pageName);
this.throwOnError(result, `Delete page "${pageName}"`);
}
/**
* Creates a new block.
*
* @param options - Block creation options
* @param options.content - Block content (supports Markdown)
* @param options.pageName - Page to append the block to (mutually exclusive with parentBlockUuid)
* @param options.parentBlockUuid - Parent block UUID for nesting (mutually exclusive with pageName)
* @param options.properties - Optional block properties
* @param options.before - Insert before sibling (only with parentBlockUuid)
* @returns The created block
* @throws {LogseqValidationError} If neither pageName nor parentBlockUuid is provided
* @throws {LogseqApiError} If block creation fails
*
* @example
* ```typescript
* // Append to page
* const block = await ops.createBlock({
* pageName: "My Notes",
* content: "New thought"
* });
*
* // Nest under existing block
* const nested = await ops.createBlock({
* parentBlockUuid: block.uuid,
* content: "Sub-item"
* });
* ```
*/
async createBlock(options: CreateBlockOptions): Promise<Block> {
if (!options.pageName && !options.parentBlockUuid) {
throw new LogseqValidationError(
"Either pageName or parentBlockUuid must be provided",
"pageName | parentBlockUuid"
);
}
let result;
if (options.pageName) {
// Append to page
result = await this.client.appendBlockToPage(
options.pageName,
options.content,
options.properties ? { properties: options.properties } : undefined
);
} else {
// Insert as child of block
const insertOptions: {
before?: boolean;
sibling?: boolean;
properties?: Record<string, unknown>;
} = {
sibling: false,
};
if (options.before !== undefined) {
insertOptions.before = options.before;
}
if (options.properties !== undefined) {
insertOptions.properties = options.properties;
}
result = await this.client.insertBlock(
options.parentBlockUuid!,
options.content,
insertOptions
);
}
this.throwOnError(result, "Create block");
if (!result.data) {
throw new LogseqApiError(
"Failed to create block: No data returned",
"logseq.Editor.insertBlock"
);
}
return result.data;
}
/**
* Updates an existing block.
*
* @param options - Update options
* @param options.uuid - UUID of the block to update
* @param options.content - New content (if omitted, preserves existing content)
* @param options.properties - Properties to update
* @throws {LogseqValidationError} If neither content nor properties is provided
* @throws {LogseqNotFoundError} If the block doesn't exist
* @throws {LogseqApiError} If the update fails
*/
async updateBlock(options: UpdateBlockOptions): Promise<void> {
if (!options.content && !options.properties) {
throw new LogseqValidationError(
"Either content or properties must be provided",
"content | properties"
);
}
// Get current block if we need to preserve content
let content = options.content;
if (!content) {
const blockResult = await this.client.getBlock(options.uuid);
this.throwOnError(blockResult, `Get block "${options.uuid}"`);
if (!blockResult.data) {
throw new LogseqNotFoundError("block", options.uuid);
}
content = blockResult.data.content;
}
const result = await this.client.updateBlock(
options.uuid,
content,
options.properties ? { properties: options.properties } : undefined
);
this.throwOnError(result, `Update block "${options.uuid}"`);
}
/**
* Deletes a block by UUID.
*
* @param uuid - UUID of the block to delete
* @throws {LogseqApiError} If deletion fails
*
* @remarks
* This also deletes all child blocks.
*/
async deleteBlock(uuid: string): Promise<void> {
const result = await this.client.deleteBlock(uuid);
this.throwOnError(result, `Delete block "${uuid}"`);
}
/**
* Retrieves a block by UUID.
*
* @param uuid - UUID of the block
* @returns The block, or null if not found
* @throws {LogseqApiError} If the API call fails
*/
async getBlock(uuid: string): Promise<Block | null> {
const result = await this.client.getBlock(uuid);
this.throwOnError(result, `Get block "${uuid}"`);
return result.data ?? null;
}
/**
* Executes a Datalog query.
*
* @param queryString - Datalog query string
* @returns Query results as nested arrays
* @throws {LogseqApiError} If the query fails
*
* @example
* ```typescript
* // Find all TODO items
* const todos = await ops.query(`
* [:find (pull ?b [*])
* :where [?b :block/marker "TODO"]]
* `);
* ```
*
* @see https://docs.logseq.com/#/page/advanced%20queries
*/
async query(queryString: string): Promise<QueryResult> {
const result = await this.client.query(queryString);
this.throwOnError(result, "Execute query");
return result.data ?? [];
}
/**
* Gets information about the currently open graph.
*
* @returns Graph name and path, or null if no graph is open
* @throws {LogseqApiError} If the API call fails
*/
async getCurrentGraph(): Promise<{ name: string; path: string } | null> {
const result = await this.client.getCurrentGraph();
this.throwOnError(result, "Get current graph");
return result.data ?? null;
}
/**
* Creates multiple blocks with hierarchy in a single operation.
*
* This is more efficient than calling {@link createBlock} multiple times
* when creating structured content like documentation pages.
*
* @param pageName - Target page (must already exist)
* @param blocks - Tree of blocks to create
* @returns Summary of created blocks
* @throws {LogseqNotFoundError} If the page doesn't exist
* @throws {LogseqApiError} If block creation fails
*
* @remarks
* Each block's content should be one logical unit (one heading, one paragraph,
* one list item). Do NOT include multiple headings or markdown lists within
* a single block's content - they won't render correctly in Logseq.
*
* @example
* ```typescript
* const result = await ops.createBlocks("My Page", [
* {
* content: "## Section 1",
* children: [
* { content: "First point" },
* { content: "Second point" },
* ]
* },
* {
* content: "## Section 2",
* children: [
* { content: "Another point" },
* ]
* }
* ]);
* console.log(`Created ${result.created} blocks`);
* ```
*/
async createBlocks(pageName: string, blocks: BlockNode[]): Promise<CreateBlocksResult> {
// Verify page exists
const pageResult = await this.client.getPage(pageName);
this.throwOnError(pageResult, `Get page "${pageName}"`);
if (!pageResult.data) {
throw new LogseqNotFoundError("page", pageName);
}
const rootBlocks: CreateBlocksResult["rootBlocks"] = [];
let created = 0;
// Recursively create blocks
const createBlockTree = async (nodes: BlockNode[], parentBlockUuid?: string): Promise<void> => {
for (const node of nodes) {
let block: Block;
if (parentBlockUuid) {
// Create as child of parent block
block = await this.createBlock({
parentBlockUuid,
content: node.content,
});
} else {
// Create as top-level block on page
block = await this.createBlock({
pageName,
content: node.content,
});
// Track root blocks for the result
rootBlocks.push({
uuid: block.uuid,
content:
node.content.length > 50 ? node.content.substring(0, 50) + "..." : node.content,
});
}
created++;
// Recursively create children
if (node.children && node.children.length > 0) {
await createBlockTree(node.children, block.uuid);
}
}
};
await createBlockTree(blocks);
return { created, rootBlocks };
}
}