/**
* Template operations for Logseq.
* Supports Full House Templates plugin features.
* @module
*/
import type {
Block,
TemplateInfo,
ListTemplatesResult,
Template,
TemplateBlock,
CreatePageFromTemplateOptions,
CreatePageFromTemplateResult,
CreateBlocksFromTemplateOptions,
CreateBlocksFromTemplateResult,
Page,
} from "../types.js";
import { LogseqNotFoundError, LogseqValidationError } from "../errors.js";
import { LogseqOperations } from "./base.js";
// ============================================================================
// Helper Functions
// ============================================================================
/** Pattern to detect Full House template expressions */
const FULL_HOUSE_PATTERN = /``[^`]+``/g;
/**
* Checks if content contains Full House template expressions.
*/
function hasFullHouseFeatures(content: string): boolean {
return FULL_HOUSE_PATTERN.test(content);
}
/**
* Extracts Full House expressions from content.
*/
function extractFullHouseExpressions(content: string): string[] {
const matches = content.match(FULL_HOUSE_PATTERN) || [];
return matches.map((m) => m.slice(2, -2)); // Remove backticks
}
/**
* Extracts fillable properties (empty property values) from content.
*/
function extractFillableProperties(content: string): string[] {
const properties: string[] = [];
const lines = content.split("\n");
for (const line of lines) {
// Match "property::" with optional whitespace but no value (or just {||})
const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*)::\s*(\{\|\|\})?\s*$/);
if (match && match[1]) {
properties.push(match[1]);
}
}
return properties;
}
/**
* Substitutes variables into template content.
* Replaces empty property values with provided values.
*/
function substituteVariables(
content: string,
variables: Record<string, string>
): { content: string; filled: string[] } {
let result = content;
const filled: string[] = [];
for (const [key, value] of Object.entries(variables)) {
// Replace empty property values: "key::" or "key:: {||}" → "key:: value"
const pattern = new RegExp(`(${key}::)\\s*(\\{\\|\\|\\})?\\s*$`, "gm");
const newContent = result.replace(pattern, `$1 ${value}`);
if (newContent !== result) {
filled.push(key);
result = newContent;
}
}
return { content: result, filled };
}
// ============================================================================
// Template Operations
// ============================================================================
/**
* Gets child blocks of a template block recursively.
*/
async function getTemplateBlocks(
ops: LogseqOperations,
parentUuid: string
): Promise<TemplateBlock[]> {
const queryStr = `
[:find ?uuid ?content
:where
[?p :block/uuid #uuid "${parentUuid}"]
[?c :block/parent ?p]
[?c :block/uuid ?uuid]
[?c :block/content ?content]]
`;
const result = await ops.query(queryStr);
const blocks: TemplateBlock[] = [];
for (const row of result) {
if (Array.isArray(row) && row.length >= 2) {
const uuid = String(row[0]);
const content = String(row[1]);
const children = await getTemplateBlocks(ops, uuid);
blocks.push({ uuid, content, children });
}
}
return blocks;
}
/**
* Lists all templates in the graph.
*
* @param ops - LogseqOperations instance
* @returns List of templates with basic info
*/
export async function listTemplates(ops: LogseqOperations): Promise<ListTemplatesResult> {
// Query for all blocks with template:: property
const queryStr = `
[:find ?name ?uuid ?content
:where
[?b :block/properties ?props]
[(get ?props :template) ?name]
[?b :block/uuid ?uuid]
[?b :block/content ?content]]
`;
const result = await ops.query(queryStr);
const templates: TemplateInfo[] = [];
for (const row of result) {
if (Array.isArray(row) && row.length >= 3) {
const name = String(row[0]);
const uuid = String(row[1]);
const content = String(row[2]);
// Get child blocks to check for Full House features and properties
const childQueryStr = `
[:find ?child-content
:where
[?p :block/uuid #uuid "${uuid}"]
[?c :block/parent ?p]
[?c :block/content ?child-content]]
`;
let allContent = content;
try {
const childResult = await ops.query(childQueryStr);
for (const childRow of childResult) {
if (Array.isArray(childRow) && childRow.length >= 1) {
allContent += "\n" + String(childRow[0]);
}
}
} catch {
// Ignore child query errors
}
templates.push({
name,
uuid,
hasFullHouseFeatures: hasFullHouseFeatures(allContent),
properties: extractFillableProperties(allContent),
});
}
}
// Sort by name
templates.sort((a, b) => a.name.localeCompare(b.name));
return {
templates,
total: templates.length,
};
}
/**
* Gets detailed information about a specific template.
*
* @param ops - LogseqOperations instance
* @param templateName - Name of the template to retrieve
* @returns Template details or null if not found
*/
export async function getTemplate(
ops: LogseqOperations,
templateName: string
): Promise<Template | null> {
// Find the template block
const queryStr = `
[:find ?uuid ?content
:where
[?b :block/properties ?props]
[(get ?props :template) ?name]
[(= ?name "${templateName}")]
[?b :block/uuid ?uuid]
[?b :block/content ?content]]
`;
const result = await ops.query(queryStr);
if (result.length === 0) {
return null;
}
const row = result[0];
if (!Array.isArray(row) || row.length < 2) {
return null;
}
const uuid = String(row[0]);
const content = String(row[1]);
// Get child blocks recursively
const blocks = await getTemplateBlocks(ops, uuid);
// Collect all content for analysis
let allContent = content;
const collectContent = (blockList: TemplateBlock[]): void => {
for (const block of blockList) {
allContent += "\n" + block.content;
collectContent(block.children);
}
};
collectContent(blocks);
return {
name: templateName,
uuid,
hasFullHouseFeatures: hasFullHouseFeatures(allContent),
fullHouseExpressions: extractFullHouseExpressions(allContent),
fillableProperties: extractFillableProperties(allContent),
blocks,
};
}
/**
* Creates a new page using a template.
*
* @param ops - LogseqOperations instance
* @param options - Options including page name, template name, and optional variables
* @returns Result with created page and metadata
* @throws {LogseqNotFoundError} If template not found
* @throws {LogseqApiError} If page creation fails
*/
export async function createPageFromTemplate(
ops: LogseqOperations,
options: CreatePageFromTemplateOptions
): Promise<CreatePageFromTemplateResult> {
const { pageName, template: templateName, variables = {} } = options;
// Get the template
const template = await getTemplate(ops, templateName);
if (!template) {
throw new LogseqNotFoundError("template", templateName);
}
// Create the page (without initial content - we'll add blocks)
const page = (await ops.createPage(pageName)) as Page;
// Process and create blocks from template
let blocksCreated = 0;
const allFilledProperties: string[] = [];
const createBlocksFromTemplate = async (
templateBlocks: TemplateBlock[],
parentBlockUuid?: string
): Promise<void> => {
for (const templateBlock of templateBlocks) {
// Substitute variables in content
const { content, filled } = substituteVariables(templateBlock.content, variables);
allFilledProperties.push(...filled);
let block: Block;
if (parentBlockUuid) {
block = await ops.createBlock({
parentBlockUuid,
content,
});
} else {
block = await ops.createBlock({
pageName,
content,
});
}
blocksCreated++;
// Recursively create children
if (templateBlock.children.length > 0) {
await createBlocksFromTemplate(templateBlock.children, block.uuid);
}
}
};
await createBlocksFromTemplate(template.blocks);
return {
page,
templateName,
blocksCreated,
filledProperties: [...new Set(allFilledProperties)], // Dedupe
hasUnprocessedExpressions: template.hasFullHouseFeatures,
};
}
/**
* Creates blocks from a template on an existing page.
*
* @param ops - LogseqOperations instance
* @param options - Options including page name, template name, insert position, and variables
* @returns Result with created blocks and metadata
* @throws {LogseqNotFoundError} If template or page not found
* @throws {LogseqValidationError} If heading required but not provided
* @throws {LogseqApiError} If block creation fails
*/
export async function createBlocksFromTemplate(
ops: LogseqOperations,
options: CreateBlocksFromTemplateOptions
): Promise<CreateBlocksFromTemplateResult> {
const {
pageName,
template: templateName,
insertPosition = "append",
heading,
variables = {},
} = options;
// Validate heading is provided when needed
if (insertPosition === "under-heading" && !heading) {
throw new LogseqValidationError("heading is required when insertPosition is 'under-heading'");
}
// Get the template
const template = await getTemplate(ops, templateName);
if (!template) {
throw new LogseqNotFoundError("template", templateName);
}
// Verify page exists
const page = await ops.getPage(pageName);
if (!page) {
throw new LogseqNotFoundError("page", pageName);
}
// Find parent block if inserting under heading
let parentBlockUuid: string | undefined;
if (insertPosition === "under-heading" && heading) {
// Search for the heading block on this page
const headingQuery = `
[:find ?uuid
:where
[?p :block/name "${pageName.toLowerCase()}"]
[?b :block/page ?p]
[?b :block/content ?content]
[(clojure.string/starts-with? ?content "${heading}")]
[?b :block/uuid ?uuid]]
`;
const headingResult = await ops.query(headingQuery);
if (headingResult.length === 0) {
throw new LogseqNotFoundError("block", `heading "${heading}" on page "${pageName}"`);
}
const firstResult = headingResult[0];
if (Array.isArray(firstResult) && firstResult.length > 0) {
parentBlockUuid = String(firstResult[0]);
}
}
// Track created blocks
let blocksCreated = 0;
const allFilledProperties: string[] = [];
const rootBlockUuids: string[] = [];
// Helper to create blocks recursively
const createTemplateBlocks = async (
templateBlocks: TemplateBlock[],
parentUuid?: string
): Promise<void> => {
for (const templateBlock of templateBlocks) {
// Substitute variables in content
const { content, filled } = substituteVariables(templateBlock.content, variables);
allFilledProperties.push(...filled);
let block: Block;
if (parentUuid) {
block = await ops.createBlock({
parentBlockUuid: parentUuid,
content,
});
} else {
block = await ops.createBlock({
pageName,
content,
});
rootBlockUuids.push(block.uuid);
}
blocksCreated++;
// Recursively create children
if (templateBlock.children.length > 0) {
await createTemplateBlocks(templateBlock.children, block.uuid);
}
}
};
// Create blocks based on insert position
if (insertPosition === "under-heading" && parentBlockUuid) {
// Insert as children of the heading block
await createTemplateBlocks(template.blocks, parentBlockUuid);
} else {
// For append/prepend, create as top-level blocks on page
// Note: Logseq API always appends, so prepend would need special handling
// For now, we'll just append in both cases
await createTemplateBlocks(template.blocks);
}
return {
pageName,
templateName,
blocksCreated,
filledProperties: [...new Set(allFilledProperties)],
hasUnprocessedExpressions: template.hasFullHouseFeatures,
rootBlockUuids,
};
}