All files / src wiki-sync.ts

100% Statements 62/62
100% Branches 22/22
100% Functions 12/12
100% Lines 54/54

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119          3x       22x             11x 11x 10x     1x       12x                 32x 32x 32x       9x 9x   9x   9x 9x 8x 8x 8x 6x 6x 6x       9x 9x       3x 3x 3x 3x       14x   14x 14x 28x 28x 28x   24x 24x 8x 6x   5x 5x 4x 4x       14x       3x       8x 8x   4x 4x   3x 3x 2x       12x 36x 36x 6x              
import type { DataAdapter } from "obsidian";
import type { WikiPage, WikiPageDetail } from "./types";
import { PUBLISHED_WIKI_PAGE_TYPES } from "./types";
import type { LilbeeClient } from "./api";
 
const MANAGED_MARKER = "lilbee_managed";
 
function pageVaultPath(folder: string, page: WikiPage): string {
    // The slug already includes the subdir (e.g. "summaries/lilbee for Obsidian")
    return `${folder}/${page.slug}.md`;
}
 
function buildFileContent(page: WikiPageDetail): string {
    // The server's content already includes YAML frontmatter (generated_by, sources, etc.).
    // Inject the managed marker into the frontmatter so Obsidian parses it correctly.
    // Obsidian requires `---` as the very first line to recognize frontmatter.
    const content = page.content;
    if (content.startsWith("---\n")) {
        return content.replace("---\n", `---\n${MANAGED_MARKER}: true\n`);
    }
    // No frontmatter — wrap content with frontmatter containing only the marker
    return `---\n${MANAGED_MARKER}: true\n---\n\n${content}`;
}
 
function isManagedFile(content: string): boolean {
    return content.includes(MANAGED_MARKER);
}
 
export class WikiSync {
    private api: LilbeeClient;
    private vault: DataAdapter;
    private folder: string;
 
    constructor(api: LilbeeClient, vault: DataAdapter, folder: string) {
        this.api = api;
        this.vault = vault;
        this.folder = folder;
    }
 
    async reconcile(): Promise<{ written: number; removed: number }> {
        const pages = await this.api.wikiList();
        const publishedPages = pages.filter((p) => PUBLISHED_WIKI_PAGE_TYPES.has(p.page_type));
 
        await this.ensureFolders();
 
        let written = 0;
        for (const page of publishedPages) {
            const path = pageVaultPath(this.folder, page);
            const needsWrite = await this.needsUpdate(path, page);
            if (needsWrite) {
                const detail = await this.api.wikiPage(page.slug);
                await this.vault.write(path, buildFileContent(detail));
                written++;
            }
        }
 
        const removed = await this.removeStalePages(publishedPages);
        return { written, removed };
    }
 
    async writePage(slug: string): Promise<void> {
        const detail = await this.api.wikiPage(slug);
        const path = pageVaultPath(this.folder, detail);
        await this.ensureFolders();
        await this.vault.write(path, buildFileContent(detail));
    }
 
    async removeStalePages(currentPages: WikiPage[]): Promise<number> {
        const currentPaths = new Set(currentPages.map((p) => pageVaultPath(this.folder, p)));
 
        let removed = 0;
        for (const subdir of ["summaries", "concepts"]) {
            const dirPath = `${this.folder}/${subdir}`;
            const dirExists = await this.vault.exists(dirPath);
            if (!dirExists) continue;
 
            const listing = await this.vault.list(dirPath);
            for (const filePath of listing.files) {
                if (!filePath.endsWith(".md")) continue;
                if (currentPaths.has(filePath)) continue;
 
                const content = await this.vault.read(filePath);
                if (isManagedFile(content)) {
                    await this.vault.remove(filePath);
                    removed++;
                }
            }
        }
        return removed;
    }
 
    isWikiPath(path: string): boolean {
        return path.startsWith(this.folder + "/");
    }
 
    private async needsUpdate(path: string, page: WikiPage): Promise<boolean> {
        const exists = await this.vault.exists(path);
        if (!exists) return true;
 
        const content = await this.vault.read(path);
        if (!isManagedFile(content)) return false;
 
        const match = content.match(/generated_at:\s*(.+)/);
        if (!match) return true;
        return page.created_at === null || match[1].trim() !== page.created_at;
    }
 
    private async ensureFolders(): Promise<void> {
        for (const dir of [this.folder, `${this.folder}/summaries`, `${this.folder}/concepts`]) {
            const exists = await this.vault.exists(dir);
            if (!exists) {
                await this.vault.mkdir(dir);
            }
        }
    }
}
 
export { pageVaultPath, buildFileContent, isManagedFile, MANAGED_MARKER };