obsidian#Component TypeScript Examples

The following examples show how to use obsidian#Component. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: render.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Render simple fields compactly, removing wrapping content like paragraph and span. */
export async function renderCompactMarkdown(
    markdown: string,
    container: HTMLElement,
    sourcePath: string,
    component: Component
) {
    let subcontainer = container.createSpan();
    await MarkdownRenderer.renderMarkdown(markdown, subcontainer, sourcePath, component);

    let paragraph = subcontainer.querySelector(":scope > p");
    if (subcontainer.children.length == 1 && paragraph) {
        while (paragraph.firstChild) {
            subcontainer.appendChild(paragraph.firstChild);
        }
        subcontainer.removeChild(paragraph);
    }
}
Example #2
Source File: inline-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Make a full API context which a script can be evaluted in. */
export function makeApiContext(
    index: FullIndex,
    component: Component,
    app: App,
    settings: DataviewSettings,
    verNum: string,
    container: HTMLElement,
    originFile: string
): DataviewInlineApi {
    return new DataviewInlineApi(index, component, container, app, settings, verNum, originFile);
}
Example #3
Source File: plugin-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
///////////////
    // Rendering //
    ///////////////

    /** Render a dataview list of the given values. */
    public async list(
        values: any[] | DataArray<any> | undefined,
        container: HTMLElement,
        component: Component,
        filePath: string
    ) {
        if (!values) return;
        if (values !== undefined && values !== null && !Array.isArray(values) && !DataArray.isDataArray(values))
            values = Array.from(values);

        // Append a child div, since React will keep re-rendering otherwise.
        let subcontainer = container.createEl("div");
        component.addChild(
            createFixedListView(
                { app: this.app, settings: this.settings, index: this.index, container: subcontainer },
                values as LiteralValue[],
                filePath
            )
        );
    }
Example #4
Source File: plugin-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Render a dataview table with the given headers, and the 2D array of values. */
    public async table(
        headers: string[],
        values: any[][] | DataArray<any> | undefined,
        container: HTMLElement,
        component: Component,
        filePath: string
    ) {
        if (!headers) headers = [];
        if (!values) values = [];
        if (!Array.isArray(headers) && !DataArray.isDataArray(headers)) headers = Array.from(headers);

        // Append a child div, since React will keep re-rendering otherwise.
        let subcontainer = container.createEl("div");
        component.addChild(
            createFixedTableView(
                { app: this.app, settings: this.settings, index: this.index, container: subcontainer },
                headers,
                values as Literal[][],
                filePath
            )
        );
    }
Example #5
Source File: plugin-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Render a dataview task view with the given tasks. */
    public async taskList(
        tasks: Grouping<SListItem>,
        groupByFile: boolean = true,
        container: HTMLElement,
        component: Component,
        filePath: string = ""
    ) {
        let groupedTasks =
            !Groupings.isGrouping(tasks) && groupByFile ? this.array(tasks).groupBy(t => Link.file(t.path)) : tasks;

        // Append a child div, since React will override several task lists otherwise.
        let taskContainer = container.createEl("div");
        component.addChild(
            createFixedTaskView(
                { app: this.app, settings: this.settings, index: this.index, container: taskContainer },
                groupedTasks as Grouping<SListItem>,
                filePath
            )
        );
    }
Example #6
Source File: plugin-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Render an arbitrary value into a container. */
    public async renderValue(
        value: any,
        container: HTMLElement,
        component: Component,
        filePath: string,
        inline: boolean = false
    ) {
        return renderValue(value as Literal, container, filePath, component, this.settings, inline);
    }
Example #7
Source File: inline-api.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
constructor(
        index: FullIndex,
        component: Component,
        container: HTMLElement,
        app: App,
        settings: DataviewSettings,
        verNum: string,
        currentFilePath: string
    ) {
        this.index = index;
        this.component = component;
        this.container = container;
        this.app = app;
        this.currentFilePath = currentFilePath;
        this.settings = settings;

        this.api = new DataviewApi(this.app, this.index, this.settings, verNum);
        this.io = new DataviewInlineIOApi(this.api.io, this.currentFilePath);

        // Set up the evaluation context with variables from the current file.
        let fileMeta = this.index.pages.get(this.currentFilePath)?.serialize(this.index) ?? {};
        this.evaluationContext = new Context(defaultLinkHandler(this.index, this.currentFilePath), settings, {
            this: fileMeta,
        });

        this.func = Functions.bindAll(DEFAULT_FUNCTIONS, this.evaluationContext);
    }
Example #8
Source File: main.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Generate a DataviewJS view running the given source in the given element. */
    public async dataviewjs(
        source: string,
        el: HTMLElement,
        component: Component | MarkdownPostProcessorContext,
        sourcePath: string
    ) {
        if (isDataviewDisabled(sourcePath)) {
            renderCodeBlock(el, source, "javascript");
            return;
        }

        component.addChild(
            new DataviewJSRenderer(source, el, this.app, this.index, sourcePath, this.settings, this.manifest.version)
        );
    }
Example #9
Source File: index.ts    From obsidian-dataview with MIT License 6 votes vote down vote up
/** Optional connector to the Obsidian 'Starred' plugin which allows for efficiently querying if a file is starred or not. */
export class StarredCache extends Component {
    /** How frequently to check for star updates. */
    public static REFRESH_INTERVAL = 30 * 1_000;

    /** Set of all starred file paths. */
    private stars: Set<string>;

    public constructor(public app: App, public onUpdate: () => void) {
        super();

        this.stars = StarredCache.fetch(this.app);
        this.registerInterval(window.setInterval(() => this.reload(), StarredCache.REFRESH_INTERVAL));
    }

    /** Determines if the given path is starred. */
    public starred(path: string): boolean {
        return this.stars.has(path);
    }

    private reload() {
        let newStars = StarredCache.fetch(this.app);
        if (!setsEqual(this.stars, newStars)) {
            this.stars = newStars;
            this.onUpdate();
        }
    }

    /** Fetch all starred files from the stars plugin, if present. */
    private static fetch(app: App): Set<string> {
        let items = (app as any)?.internalPlugins?.plugins?.starred?.instance?.items as StarredEntry[];
        if (items == undefined) return new Set();

        return new Set(
            items.filter((l): l is { type: "file"; path: string; title: string } => l.type === "file").map(l => l.path)
        );
    }
}
Example #10
Source File: inline-api.ts    From obsidian-dataview with MIT License 5 votes vote down vote up
/** The component that handles the lifetime of this view. Use it if you are adding custom event handlers/components. */
    public component: Component;
Example #11
Source File: main.ts    From obsidian-dataview with MIT License 5 votes vote down vote up
/**
     * Create an API element localized to the given path, with lifecycle management managed by the given component.
     * The API will output results to the given HTML element.
     */
    public localApi(path: string, component: Component, el: HTMLElement): DataviewInlineApi {
        return new DataviewInlineApi(this.index, component, el, this.app, this.settings, this.manifest.version, path);
    }
Example #12
Source File: main.ts    From obsidian-dataview with MIT License 5 votes vote down vote up
/** Render all dataview inline expressions in the given element. */
    public async dataviewInline(
        el: HTMLElement,
        component: Component | MarkdownPostProcessorContext,
        sourcePath: string
    ) {
        if (isDataviewDisabled(sourcePath)) return;

        // Search for <code> blocks inside this element; for each one, look for things of the form `= ...`.
        let codeblocks = el.querySelectorAll("code");
        for (let index = 0; index < codeblocks.length; index++) {
            let codeblock = codeblocks.item(index);

            let text = codeblock.innerText.trim();
            if (this.settings.inlineJsQueryPrefix.length > 0 && text.startsWith(this.settings.inlineJsQueryPrefix)) {
                let code = text.substring(this.settings.inlineJsQueryPrefix.length).trim();
                if (code.length == 0) continue;

                component.addChild(
                    new DataviewInlineJSRenderer(
                        code,
                        el,
                        codeblock,
                        this.app,
                        this.index,
                        sourcePath,
                        this.settings,
                        this.manifest.version
                    )
                );
            } else if (this.settings.inlineQueryPrefix.length > 0 && text.startsWith(this.settings.inlineQueryPrefix)) {
                let potentialField = text.substring(this.settings.inlineQueryPrefix.length).trim();
                if (potentialField.length == 0) continue;

                let field = tryOrPropogate(() => parseField(potentialField));
                if (!field.successful) {
                    let errorBlock = el.createEl("div");
                    renderErrorPre(errorBlock, `Dataview (inline field '${potentialField}'): ${field.error}`);
                } else {
                    let fieldValue = field.value;
                    component.addChild(
                        new DataviewInlineRenderer(
                            fieldValue,
                            text,
                            el,
                            codeblock,
                            this.index,
                            sourcePath,
                            this.settings,
                            this.app
                        )
                    );
                }
            }
        }
    }
Example #13
Source File: main.ts    From obsidian-dataview with MIT License 5 votes vote down vote up
/**
     * Based on the source, generate a dataview view. This works by doing an initial parsing pass, and then adding
     * a long-lived view object to the given component for life-cycle management.
     */
    public async dataview(
        source: string,
        el: HTMLElement,
        component: Component | MarkdownPostProcessorContext,
        sourcePath: string
    ) {
        if (isDataviewDisabled(sourcePath)) {
            renderCodeBlock(el, source);
            return;
        }

        let maybeQuery = tryOrPropogate(() => parseQuery(source));

        // In case of parse error, just render the error.
        if (!maybeQuery.successful) {
            renderErrorPre(el, "Dataview: " + maybeQuery.error);
            return;
        }

        let query = maybeQuery.value;
        let init = { app: this.app, settings: this.settings, index: this.index, container: el };
        switch (query.header.type) {
            case "task":
                component.addChild(createTaskView(init, query as Query, sourcePath));
                break;
            case "list":
                component.addChild(createListView(init, query as Query, sourcePath));
                break;
            case "table":
                component.addChild(createTableView(init, query as Query, sourcePath));
                break;
            case "calendar":
                component.addChild(
                    new DataviewCalendarRenderer(query as Query, el, this.index, sourcePath, this.settings, this.app)
                );
                break;
        }
    }
Example #14
Source File: index.ts    From obsidian-dataview with MIT License 5 votes vote down vote up
/** Indexes files by their full prefix - essentially a simple prefix tree. */
export class PrefixIndex extends Component {
    public static create(vault: Vault, updateRevision: () => void): PrefixIndex {
        return new PrefixIndex(vault, updateRevision);
    }

    constructor(public vault: Vault, public updateRevision: () => void) {
        super();
    }

    private *walk(folder: TFolder, filter?: (path: string) => boolean): Generator<string> {
        for (const file of folder.children) {
            if (file instanceof TFolder) {
                yield* this.walk(file, filter);
            } else if (filter ? filter(file.path) : true) {
                yield file.path;
            }
        }
    }

    /** Get the list of all files under the given path. */
    public get(prefix: string, filter?: (path: string) => boolean): Set<string> {
        let folder = this.vault.getAbstractFileByPath(prefix || "/");
        return new Set(folder instanceof TFolder ? this.walk(folder, filter) : []);
    }

    /** Determines if the given path exists in the prefix index. */
    public pathExists(path: string): boolean {
        return this.vault.getAbstractFileByPath(path || "/") != null;
    }

    /** Determines if the given prefix exists in the prefix index. */
    public nodeExists(prefix: string): boolean {
        return this.vault.getAbstractFileByPath(prefix || "/") instanceof TFolder;
    }

    /**
     * Use the in-memory prefix index to convert a relative path to an absolute one.
     */
    public resolveRelative(path: string, origin?: string): string {
        if (!origin) return path;
        else if (path.startsWith("/")) return path.substring(1);

        let relativePath = getParentFolder(origin) + "/" + path;
        if (this.pathExists(relativePath)) return relativePath;
        else return path;
    }
}
Example #15
Source File: render.ts    From obsidian-dataview with MIT License 4 votes vote down vote up
/** Prettily render a value into a container with the given settings. */
export async function renderValue(
    field: Literal,
    container: HTMLElement,
    originFile: string,
    component: Component,
    settings: QuerySettings,
    expandList: boolean = false,
    context: ValueRenderContext = "root",
    depth: number = 0
) {
    // Prevent infinite recursion.
    if (depth > settings.maxRecursiveRenderDepth) {
        container.appendText("...");
        return;
    }

    if (Values.isNull(field)) {
        await renderCompactMarkdown(settings.renderNullAs, container, originFile, component);
    } else if (Values.isDate(field)) {
        container.appendText(renderMinimalDate(field, settings, currentLocale()));
    } else if (Values.isDuration(field)) {
        container.appendText(renderMinimalDuration(field));
    } else if (Values.isString(field) || Values.isBoolean(field) || Values.isNumber(field)) {
        await renderCompactMarkdown("" + field, container, originFile, component);
    } else if (Values.isLink(field)) {
        await renderCompactMarkdown(field.markdown(), container, originFile, component);
    } else if (Values.isHtml(field)) {
        container.appendChild(field);
    } else if (Values.isFunction(field)) {
        container.appendText("<function>");
    } else if (Values.isArray(field) || DataArray.isDataArray(field)) {
        if (expandList) {
            let list = container.createEl("ul", {
                cls: [
                    "dataview",
                    "dataview-ul",
                    context == "list" ? "dataview-result-list-ul" : "dataview-result-list-root-ul",
                ],
            });
            for (let child of field) {
                let li = list.createEl("li", { cls: "dataview-result-list-li" });
                await renderValue(child, li, originFile, component, settings, expandList, "list", depth + 1);
            }
        } else {
            if (field.length == 0) {
                container.appendText("<empty list>");
                return;
            }

            let span = container.createEl("span", { cls: ["dataview", "dataview-result-list-span"] });
            let first = true;
            for (let val of field) {
                if (first) first = false;
                else span.appendText(", ");

                await renderValue(val, span, originFile, component, settings, expandList, "list", depth + 1);
            }
        }
    } else if (Values.isObject(field)) {
        // Don't render classes in case they have recursive references; spoopy.
        if (field?.constructor?.name && field?.constructor?.name != "Object") {
            container.appendText(`<${field.constructor.name}>`);
            return;
        }

        if (expandList) {
            let list = container.createEl("ul", { cls: ["dataview", "dataview-ul", "dataview-result-object-ul"] });
            for (let [key, value] of Object.entries(field)) {
                let li = list.createEl("li", { cls: ["dataview", "dataview-li", "dataview-result-object-li"] });
                li.appendText(key + ": ");
                await renderValue(value, li, originFile, component, settings, expandList, "list", depth + 1);
            }
        } else {
            if (Object.keys(field).length == 0) {
                container.appendText("<empty object>");
                return;
            }

            let span = container.createEl("span", { cls: ["dataview", "dataview-result-object-span"] });
            let first = true;
            for (let [key, value] of Object.entries(field)) {
                if (first) first = false;
                else span.appendText(", ");

                span.appendText(key + ": ");
                await renderValue(value, span, originFile, component, settings, expandList, "list", depth + 1);
            }
        }
    } else {
        container.appendText("Unrecognized: " + JSON.stringify(field));
    }
}
Example #16
Source File: watcher.ts    From obsidian-fantasy-calendar with MIT License 4 votes vote down vote up
export class Watcher extends Component {
    queue: Set<string> = new Set();
    get calendars() {
        return this.plugin.data.calendars;
    }
    get metadataCache() {
        return this.plugin.app.metadataCache;
    }
    get vault() {
        return this.plugin.app.vault;
    }
    constructor(public plugin: FantasyCalendar) {
        super();
    }

    tree: CalendarEventTree = new Map();

    worker = new Worker();
    onload() {
        this.plugin.addCommand({
            id: "rescan-events",
            name: "Rescan Events",
            callback: () => {
                if (this.plugin.data.debug) {
                    console.info("Beginning full rescan for calendar events");
                }
                this.start();
            }
        });
        this.plugin.addCommand({
            id: "rescan-events-for-calendar",
            name: "Rescan Events for Calendar",
            callback: () => {
                const modal = new CalendarPickerModal(this.plugin);
                modal.onClose = () => {
                    if (modal.chosen) {
                        if (this.plugin.data.debug) {
                            console.info(
                                "Beginning full rescan for calendar events for calendar " +
                                    modal.chosen.name
                            );
                        }
                        this.start(modal.chosen);
                    }
                };
                modal.open();
            }
        });

        /** Send the worker the calendars so I don't have to with every message. */
        this.worker.postMessage<CalendarsMessage>({
            type: "calendars",
            calendars: this.calendars
        });
        this.registerEvent(
            this.plugin.app.workspace.on("fantasy-calendars-updated", () => {
                this.worker.postMessage<CalendarsMessage>({
                    type: "calendars",
                    calendars: this.calendars
                });
            })
        );
        /** Send the workers the options so I don't have to with every message. */
        this.worker.postMessage<OptionsMessage>({
            type: "options",
            parseTitle: this.plugin.data.parseDates,
            addToDefaultIfMissing: this.plugin.data.addToDefaultIfMissing,
            format: this.plugin.format,
            defaultCalendar: this.plugin.defaultCalendar?.name,
            debug: this.plugin.data.debug
        });
        this.registerEvent(
            this.plugin.app.workspace.on(
                "fantasy-calendar-settings-change",
                () => {
                    this.worker.postMessage<OptionsMessage>({
                        type: "options",
                        parseTitle: this.plugin.data.parseDates,
                        addToDefaultIfMissing:
                            this.plugin.data.addToDefaultIfMissing,
                        format: this.plugin.format,
                        defaultCalendar: this.plugin.defaultCalendar?.name,
                        debug: this.plugin.data.debug
                    });
                }
            )
        );

        /** Metadata for a file has changed and the file should be checked. */
        this.registerEvent(
            this.metadataCache.on("changed", (file) => {
                if (this.queue.has(file.path)) return;
                this.startParsing([file.path]);
            })
        );
        /** A file has been renamed and should be checked for events.
         */
        this.registerEvent(
            this.vault.on("rename", async (abstractFile, oldPath) => {
                if (!this.calendars.length) return;
                if (!(abstractFile instanceof TFile)) return;
                for (const calendar of this.calendars) {
                    calendar.events = calendar.events.filter(
                        (event) => event.note != oldPath
                    );
                }
                this.worker.postMessage<CalendarsMessage>({
                    type: "calendars",
                    calendars: this.calendars
                });
                this.startParsing([abstractFile.path]);
            })
        );
        /** A file has been deleted and should be checked for events to unlink. */
        this.registerEvent(
            this.vault.on("delete", (abstractFile) => {
                if (!(abstractFile instanceof TFile)) return;
                for (let calendar of this.calendars) {
                    const events = calendar.events.filter(
                        (event) => event.note === abstractFile.path
                    );
                    calendar.events = calendar.events.filter(
                        (event) => event.note != abstractFile.path
                    );
                    for (const event of events) {
                        this.addToTree(calendar, event);
                    }
                }
                this.plugin.saveCalendar();
                this.plugin.app.workspace.trigger(
                    "fantasy-calendars-event-update",
                    this.tree
                );
                this.tree = new Map();
            })
        );

        //worker messages
        /** The worker will ask for file information from files in its queue here */
        this.worker.addEventListener(
            "message",
            async (event: MessageEvent<GetFileCacheMessage>) => {
                if (event.data.type == "get") {
                    const { path } = event.data;
                    this.queue.delete(path);
                    const file =
                        this.plugin.app.vault.getAbstractFileByPath(path);
                    if (file instanceof TFile) {
                        const cache = this.metadataCache.getFileCache(file);
                        const allTags = getAllTags(cache);
                        const data = await this.vault.cachedRead(file);
                        this.worker.postMessage<FileCacheMessage>({
                            type: "file",
                            path,
                            cache,
                            file: { path: file.path, basename: file.basename },
                            allTags,
                            data
                        });
                    } else if (file instanceof TFolder) {
                        const paths = file.children.map((f) => f.path);
                        this.startParsing(paths);
                    }
                }
            }
        );

        /** The worker has found an event that should be updated. */
        this.worker.addEventListener(
            "message",
            async (evt: MessageEvent<UpdateEventMessage>) => {
                if (evt.data.type == "update") {
                    const { id, index, event, original } = evt.data;

                    const calendar = this.calendars.find((c) => c.id == id);

                    if (!calendar) return;
                    if (index == -1) {
                        if (this.plugin.data.debug)
                            console.info(
                                `Adding '${event.name}' to ${calendar.name}`
                            );
                        calendar.events.push(event);
                    } else {
                        if (this.plugin.data.debug)
                            console.info(
                                `Updating '${event.name}' in calendar ${calendar.name}`
                            );
                        calendar.events.splice(
                            index,
                            index >= 0 ? 1 : 0,
                            event
                        );
                    }

                    this.addToTree(calendar, event);
                    if (original) {
                        this.addToTree(calendar, original);
                    }
                }
            }
        );

        this.worker.addEventListener(
            "message",
            async (evt: MessageEvent<DeleteEventMessage>) => {
                if (evt.data.type == "delete") {
                    const { id, index, event } = evt.data;
                    if (!event) return;
                    const calendar = this.calendars.find((c) => c.id == id);
                    if (!calendar) return;
                    if (this.plugin.data.debug)
                        console.info(
                            `Removing '${event.name}' from ${calendar.name}`
                        );
                    calendar.events = calendar.events.filter(
                        (e) => e.id != event.id
                    );
                    this.addToTree(calendar, event);
                }
            }
        );

        /** The worker has parsed all files in its queue. */
        this.worker.addEventListener(
            "message",
            async (evt: MessageEvent<SaveMessage>) => {
                if (evt.data.type == "save") {
                    if (this.plugin.data.debug) {
                        console.info("Received save event from file watcher");
                    }
                    this.plugin.app.workspace.trigger(
                        "fantasy-calendars-event-update",
                        this.tree
                    );
                    this.tree = new Map();
                    await this.plugin.saveCalendar();
                }
            }
        );
        this.start();
    }
    start(calendar?: Calendar) {
        const calendars = calendar ? [calendar] : this.calendars;
        if (!calendars.length) return;
        let folders: Set<string> = new Set();

        for (const calendar of calendars) {
            if (!calendar) continue;
            if (!calendar.autoParse) continue;
            const folder = this.vault.getAbstractFileByPath(calendar.path);
            if (!folder || !(folder instanceof TFolder)) continue;
            for (const child of folder.children) {
                folders.add(child.path);
            }
        }

        if (!folders.size) return;
        if (this.plugin.data.debug) {
            if (calendar) {
                console.info(
                    `Starting rescan for ${calendar.name} (${folders.size})`
                );
            } else {
                console.info(
                    `Starting rescan for ${calendars.length} calendars (${folders.size})`
                );
            }
        }
        this.startParsing([...folders]);
    }
    addToTree(calendar: Calendar, event: Event) {
        if (!this.tree.has(calendar.id)) {
            this.tree.set(calendar.id, new Set());
        }
        const calendarTree = this.tree.get(calendar.id);

        if (calendarTree.has(event.date.year)) return;

        calendarTree.add(event.date.year);

        if (event.end && event.end.year != event.date.year) {
            for (let i = event.date.year + 1; i <= event.end.year; i++) {
                calendarTree.add(event.date.year);
            }
        }
    }
    startParsing(paths: string[]) {
        for (const path of paths) {
            this.queue.add(path);
        }
        this.worker.postMessage<QueueMessage>({
            type: "queue",
            paths
        });
    }
    onunload() {
        this.worker.terminate();
        this.worker = null;
    }
}
Example #17
Source File: manager.ts    From obsidian-admonition with MIT License 4 votes vote down vote up
export default class CalloutManager extends Component {
    /* ruleMap: Map<string, number> = new Map(); */
    constructor(public plugin: ObsidianAdmonition) {
        super();
    }

    onload() {
        //build sheet for custom admonitions

        document.head.appendChild(this.style);

        for (const admonition of Object.values(
            this.plugin.data.userAdmonitions
        )) {
            this.addAdmonition(admonition);
        }
        this.setUseSnippet();

        this.plugin.registerEditorSuggest(new CalloutSuggest(this.plugin));

        this.plugin.registerMarkdownPostProcessor(
            this.calloutProcessor.bind(this)
        );
    }
    heights: Array<keyof Heights> = [
        "height",
        "padding-top",
        "padding-bottom",
        "margin-top",
        "margin-bottom"
    ];
    heightMap: WeakMap<HTMLDivElement, Heights> = new WeakMap();
    calloutProcessor(el: HTMLElement, ctx: MarkdownPostProcessorContext) {
        const callout = el?.querySelector<HTMLDivElement>(".callout");
        if (!callout) return;
        //apply metadata

        const type = callout.dataset.callout;
        const admonition = this.plugin.admonitions[type];
        if (!admonition) return;

        const titleEl = callout.querySelector<HTMLDivElement>(".callout-title");
        const content =
            callout.querySelector<HTMLDivElement>(".callout-content");

        const section = ctx.getSectionInfo(el);
        if (section) {
            const { text, lineStart, lineEnd } = section;
            const definition = text.split("\n")[lineStart];

            const [, metadata] = definition.match(/> \[!.+\|(.*)]/) ?? [];
            if (metadata) {
                callout.dataset.calloutMetadata = metadata;
            }

            if (
                this.plugin.admonitions[type].copy ??
                this.plugin.data.copyButton
            ) {
                let copy = content.createDiv("admonition-content-copy");
                setIcon(copy, "copy");
                copy.addEventListener("click", () => {
                    navigator.clipboard
                        .writeText(
                            text
                                .split("\n")
                                .slice(lineStart + 1, lineEnd + 1)
                                .join("\n")
                                .replace(/^> /gm, "")
                        )
                        .then(async () => {
                            new Notice("Callout content copied to clipboard.");
                        });
                });
            }
        }

        if (admonition.noTitle && !callout.dataset.calloutFold) {
            titleEl.addClass("no-title");
        }
        if (
            !admonition.noTitle &&
            this.plugin.data.autoCollapse &&
            !callout.dataset.calloutFold
        ) {
            this.setCollapsible(callout);
        }

        if (
            admonition.title &&
            titleEl.textContent ==
                type[0].toUpperCase() + type.slice(1).toLowerCase()
        ) {
            const titleContentEl = titleEl.querySelector<HTMLDivElement>(
                ".callout-title-inner"
            );
            if (titleContentEl) {
                titleContentEl.setText(admonition.title);
            }
        }
        if (this.plugin.data.dropShadow) {
            callout.addClass("drop-shadow");
        }
    }

    setCollapsible(callout: HTMLElement) {
        const titleEl = callout.querySelector<HTMLDivElement>(".callout-title");
        const content =
            callout.querySelector<HTMLDivElement>(".callout-content");

        if (!content) return;
        callout.addClass("is-collapsible");
        if (this.plugin.data.defaultCollapseType == "closed") {
            callout.dataset.calloutFold = "-";
            callout.addClass("is-collapsed");
        } else {
            callout.dataset.calloutFold = "+";
        }

        const iconEl = titleEl.createDiv("callout-fold");

        setIcon(iconEl, "chevron-down");

        let collapsed = callout.hasClass("is-collapsed");

        this.getComputedHeights(content);

        if (collapsed) {
            for (const prop of this.heights) {
                content.style.setProperty(prop, "0px");
            }
        }
        titleEl.onclick = (event: MouseEvent) => {
            event.preventDefault();

            function transitionEnd(evt: TransitionEvent) {
                content.removeEventListener("transitionend", transitionEnd);
                content.style.removeProperty("transition");
            }
            content.addEventListener("transitionend", transitionEnd);
            content.style.setProperty(
                "transition",
                "all 100ms cubic-bezier(.02, .01, .47, 1)"
            );
            collapsed = callout.hasClass("is-collapsed");
            if (event.button == 0) {
                for (const prop of this.heights) {
                    const heights = this.getComputedHeights(content);
                    content.style.setProperty(
                        prop,
                        collapsed ? heights[prop] : "0px"
                    );
                }

                callout.toggleClass("is-collapsed", !collapsed);
            }
        };
    }

    getComputedHeights(el: HTMLDivElement): Heights {
        if (this.heightMap.has(el)) {
            return this.heightMap.get(el);
        }
        const style = getComputedStyle(el);
        const heights: Heights = {};
        for (const key of this.heights) {
            heights[key] = style.getPropertyValue(key);
        }
        this.heightMap.set(el, heights);
        return heights;
    }
    generateCssString() {
        const sheet = [
            `/* This snippet was auto-generated by the Admonitions plugin on ${new Date().toLocaleString()} */\n\n`
        ];
        for (const rule of Array.from(this.sheet.cssRules)) {
            sheet.push(rule.cssText);
        }
        return sheet.join("\n\n");
    }
    addAdmonition(admonition: Admonition) {
        if (!admonition.icon) return;
        let rule: string;
        const color = admonition.injectColor ?? this.plugin.data.injectColor ? `--callout-color: ${admonition.color};` : '';
        if (admonition.icon.type == "obsidian") {
            rule = `.callout[data-callout="${admonition.type}"] {
    ${color}
    --callout-icon: ${admonition.icon.name};  /* Icon name from the Obsidian Icon Set */
}`;
        } else {
            rule = `.callout[data-callout="${admonition.type}"] {
       ${color}
        --callout-icon: '${(
            this.plugin.iconManager.getIconNode(admonition.icon)?.outerHTML ??
            ""
        ).replace(/(width|height)=(\\?"|')\d+(\\?"|')/g, "")}';
    }`;
        }
        if (this.indexing.contains(admonition.type)) {
            this.sheet.deleteRule(this.indexing.indexOf(admonition.type));
        }
        this.indexing = [
            ...this.indexing.filter((type) => type != admonition.type),
            admonition.type
        ];
        this.sheet.insertRule(rule, this.sheet.cssRules.length);
        this.updateSnippet();
    }
    indexing: string[] = [];
    removeAdmonition(admonition: Admonition) {
        if (!this.indexing.contains(admonition.type)) return;
        const index = this.indexing.indexOf(admonition.type);
        this.sheet.deleteRule(index);
        this.indexing.splice(index, 1);
        this.updateSnippet();
    }
    style = document.head.createEl("style", {
        attr: { id: "ADMONITIONS_CUSTOM_STYLE_SHEET" }
    });
    get sheet() {
        return this.style.sheet;
    }

    unload() {
        this.style.detach();
    }

    get snippetPath() {
        return this.plugin.app.customCss.getSnippetPath(
            this.plugin.data.snippetPath
        );
    }
    setUseSnippet() {
        if (this.plugin.data.useSnippet) {
            this.updateSnippet();
        }
    }
    async updateSnippet() {
        if (!this.plugin.data.useSnippet) return;
        if (await this.plugin.app.vault.adapter.exists(this.snippetPath)) {
            await this.plugin.app.vault.adapter.write(
                this.snippetPath,
                this.generateCssString()
            );
        } else {
            await this.plugin.app.vault.create(
                this.snippetPath,
                this.generateCssString()
            );
        }
        this.plugin.app.customCss.setCssEnabledStatus(
            this.plugin.data.snippetPath,
            true
        );
        this.plugin.app.customCss.readCssFolders();
    }
}
Example #18
Source File: index.ts    From obsidian-dataview with MIT License 4 votes vote down vote up
/**
 * Caches in-use CSVs to make high-frequency reloads (such as actively looking at a document
 * that uses CSV) fast.
 */
export class CsvCache extends Component {
    public static CACHE_EXPIRY_SECONDS: number = 5 * 60;

    // Cache of loaded CSVs; old entries will periodically be removed
    cache: Map<string, { data: DataObject[]; loadTime: DateTime }>;
    // Periodic job which clears out the cache based on time.
    cacheClearInterval: number;

    public constructor(public vault: Vault) {
        super();

        this.cache = new Map();

        // Force-flush the cache on CSV file deletions or modifications.
        this.registerEvent(
            this.vault.on("modify", file => {
                if (file instanceof TFile && PathFilters.csv(file.path)) this.cache.delete(file.path);
            })
        );

        this.registerEvent(
            this.vault.on("delete", file => {
                if (file instanceof TFile && PathFilters.csv(file.path)) this.cache.delete(file.path);
            })
        );
    }

    /** Load a CSV file from the cache, doing a fresh load if it has not been loaded. */
    public async get(path: string): Promise<Result<DataObject[], string>> {
        // Clear old entries on every fresh load, since the path being loaded may be stale.
        this.clearOldEntries();

        let existing = this.cache.get(path);
        if (existing) return Result.success(existing.data);
        else {
            let value = await this.loadInternal(path);
            if (value.successful) this.cache.set(path, { data: value.value, loadTime: DateTime.now() });
            return value;
        }
    }

    /** Do the actual raw loading of a CSV path (which is either local or an HTTP request). */
    private async loadInternal(path: string): Promise<Result<DataObject[], string>> {
        // Allow http://, https://, and file:// prefixes which use AJAX.
        if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("file://")) {
            try {
                let result = await fetch(path, {
                    method: "GET",
                    mode: "no-cors",
                    redirect: "follow",
                });

                return Result.success(parseCsv(await result.text()));
            } catch (ex) {
                return Result.failure("" + ex + "\n\n" + ex.stack);
            }
        }

        // Otherwise, assume it is a fully-qualified file path.
        try {
            let fileData = await this.vault.adapter.read(path);
            return Result.success(parseCsv(fileData));
        } catch (ex) {
            return Result.failure(`Failed to load data from path '${path}'.`);
        }
    }

    /** Clear old entries in the cache (as measured by insertion time). */
    private clearOldEntries() {
        let currentTime = DateTime.now();
        let keysToRemove = new Set<string>();
        for (let [key, value] of this.cache.entries()) {
            let entryAge = Math.abs(currentTime.diff(value.loadTime, "seconds").seconds);
            if (entryAge > CsvCache.CACHE_EXPIRY_SECONDS) keysToRemove.add(key);
        }

        keysToRemove.forEach(key => this.cache.delete(key));
    }
}
Example #19
Source File: index.ts    From obsidian-dataview with MIT License 4 votes vote down vote up
/** Aggregate index which has several sub-indices and will initialize all of them. */
export class FullIndex extends Component {
    /** Generate a full index from the given vault. */
    public static create(app: App, indexVersion: string, onChange: () => void): FullIndex {
        return new FullIndex(app, indexVersion, onChange);
    }

    /** Whether all files in the vault have been indexed at least once. */
    public initialized: boolean;

    /** I/O access to the Obsidian vault contents. */
    public vault: Vault;
    /** Access to in-memory metadata, useful for parsing and metadata lookups. */
    public metadataCache: MetadataCache;
    /** Persistent IndexedDB backing store, used for faster startup. */
    public persister: LocalStorageCache;

    /* Maps path -> markdown metadata for all markdown pages. */
    public pages: Map<string, PageMetadata>;

    /** Map files -> tags in that file, and tags -> files. This version includes subtags. */
    public tags: IndexMap;
    /** Map files -> exact tags in that file, and tags -> files. This version does not automatically add subtags. */
    public etags: IndexMap;
    /** Map files -> linked files in that file, and linked file -> files that link to it. */
    public links: IndexMap;
    /** Search files by path prefix. */
    public prefix: PrefixIndex;
    /** Allows for efficient lookups of whether a file is starred or not. */
    public starred: StarredCache;
    /** Caches data in CSV files. */
    // TODO: CSV parsing should be done by a worker thread asynchronously to avoid frontend stalls.
    public csv: CsvCache;

    /**
     * The current "revision" of the index, which monotonically increases for every index change. Use this to determine
     * if you are up to date.
     */
    public revision: number;

    /** Asynchronously parses files in the background using web workers. */
    public importer: FileImporter;

    /** Construct a new index using the app data and a current data version. */
    private constructor(public app: App, public indexVersion: string, public onChange: () => void) {
        super();

        this.initialized = false;

        this.vault = app.vault;
        this.metadataCache = app.metadataCache;
        this.persister = new LocalStorageCache(app.appId || "shared", indexVersion);

        this.pages = new Map();
        this.tags = new IndexMap();
        this.etags = new IndexMap();
        this.links = new IndexMap();
        this.revision = 0;

        // Handles asynchronous reloading of files on web workers.
        this.addChild((this.importer = new FileImporter(2, this.vault, this.metadataCache)));
        // Prefix listens to file creation/deletion/rename, and not modifies, so we let it set up it's own listeners.
        this.addChild((this.prefix = PrefixIndex.create(this.vault, () => this.touch())));
        // The CSV cache also needs to listen to filesystem events for cache invalidation.
        this.addChild((this.csv = new CsvCache(this.vault)));
        // The starred cache fetches starred entries semi-regularly via an interval.
        this.addChild((this.starred = new StarredCache(this.app, () => this.touch())));
    }

    trigger(...args: IndexEvtTriggerArgs): void {
        this.metadataCache.trigger("dataview:metadata-change" as IndexEvtFullName, ...args);
    }

    /** "Touch" the index, incrementing the revision number and causing downstream views to reload. */
    public touch() {
        this.revision += 1;
        this.onChange();
    }

    /** Runs through the whole vault to set up initial file metadata. */
    public initialize() {
        // The metadata cache is updated on initial file index and file loads.
        this.registerEvent(this.metadataCache.on("resolve", file => this.reload(file)));

        // Renames do not set off the metadata cache; catch these explicitly.
        this.registerEvent(this.vault.on("rename", this.rename, this));

        // File creation does cause a metadata change, but deletes do not. Clear the caches for this.
        this.registerEvent(
            this.vault.on("delete", af => {
                if (!(af instanceof TFile) || !PathFilters.markdown(af.path)) return;
                let file = af as TFile;

                this.pages.delete(file.path);
                this.tags.delete(file.path);
                this.etags.delete(file.path);
                this.links.delete(file.path);

                this.touch();
                this.trigger("delete", file);
            })
        );

        // Asynchronously initialize actual content in the background.
        this._initialize(this.vault.getMarkdownFiles());
    }

    /** Internal asynchronous initializer. */
    private async _initialize(files: TFile[]) {
        let reloadStart = Date.now();
        let promises = files.map(l => this.reload(l));
        let results = await Promise.all(promises);

        let cached = 0,
            skipped = 0;
        for (let item of results) {
            if (item.skipped) {
                skipped += 1;
                continue;
            }

            if (item.cached) cached += 1;
        }

        this.initialized = true;
        this.metadataCache.trigger("dataview:index-ready");
        console.log(
            `Dataview: all ${files.length} files have been indexed in ${
                (Date.now() - reloadStart) / 1000.0
            }s (${cached} cached, ${skipped} skipped).`
        );

        // Drop keys for files which do not exist anymore.
        let remaining = await this.persister.synchronize(files.map(l => l.path));
        if (remaining.size > 0) {
            console.log(`Dataview: Dropped cache entries for ${remaining.size} deleted files.`);
        }
    }

    public rename(file: TAbstractFile, oldPath: string) {
        if (!(file instanceof TFile) || !PathFilters.markdown(file.path)) return;

        if (this.pages.has(oldPath)) {
            const oldMeta = this.pages.get(oldPath);
            this.pages.delete(oldPath);
            if (oldMeta) {
                oldMeta.path = file.path;
                this.pages.set(file.path, oldMeta);
            }
        }

        this.tags.rename(oldPath, file.path);
        this.links.rename(oldPath, file.path);
        this.etags.rename(oldPath, file.path);

        this.touch();
        this.trigger("rename", file, oldPath);
    }

    /** Queue a file for reloading; this is done asynchronously in the background and may take a few seconds. */
    public async reload(file: TFile): Promise<{ cached: boolean; skipped: boolean }> {
        if (!PathFilters.markdown(file.path)) return { cached: false, skipped: true };

        // The first load of a file is attempted from persisted cache; subsequent loads just use the importer.
        if (this.pages.has(file.path) || this.initialized) {
            await this.import(file);
            return { cached: false, skipped: false };
        } else {
            // Check the cache for the latest data; if it is out of date or non-existent, then reload.
            return this.persister.loadFile(file.path).then(async cached => {
                if (!cached || cached.time < file.stat.mtime || cached.version != this.indexVersion) {
                    // This cache value is out of data, reload via the importer and update the cache.
                    // We will skip files with no active file metadata - they will be caught by a later reload
                    // via the 'resolve' metadata event.
                    let fileCache = this.metadataCache.getFileCache(file);
                    if (fileCache === undefined || fileCache === null) return { cached: false, skipped: true };

                    await this.import(file);
                    return { cached: false, skipped: false };
                } else {
                    // Use the cached data since it is up to date and on the same version.
                    this.finish(file, cached.data);
                    return { cached: true, skipped: false };
                }
            });
        }
    }

    /** Import a file directly from disk, skipping the cache. */
    private async import(file: TFile): Promise<void> {
        return this.importer.reload<Partial<PageMetadata>>(file).then(r => {
            this.finish(file, r);
            this.persister.storeFile(file.path, r);
        });
    }

    /** Finish the reloading of file metadata by adding it to in memory indexes. */
    private finish(file: TFile, parsed: Partial<PageMetadata>) {
        let meta = PageMetadata.canonicalize(parsed, link => {
            let realPath = this.metadataCache.getFirstLinkpathDest(link.path, file.path);
            if (realPath) return link.withPath(realPath.path);
            else return link;
        });

        this.pages.set(file.path, meta);
        this.tags.set(file.path, meta.fullTags());
        this.etags.set(file.path, meta.tags);
        this.links.set(file.path, new Set<string>(meta.links.map(l => l.path)));

        this.touch();
        this.trigger("update", file);
    }
}
Example #20
Source File: import-manager.ts    From obsidian-dataview with MIT License 4 votes vote down vote up
/** Multi-threaded file parser which debounces rapid file requests automatically. */
export class FileImporter extends Component {
    /* Background workers which do the actual file parsing. */
    workers: Worker[];
    /** Tracks which workers are actively parsing a file, to make sure we properly delegate results. */
    busy: boolean[];

    /** List of files which have been queued for a reload. */
    reloadQueue: TFile[];
    /** Fast-access set which holds the list of files queued to be reloaded; used for debouncing. */
    reloadSet: Set<string>;
    /** Paths -> promises for file reloads which have not yet been queued. */
    callbacks: Map<string, FileCallback[]>;

    public constructor(public numWorkers: number, public vault: Vault, public metadataCache: MetadataCache) {
        super();
        this.workers = [];
        this.busy = [];

        this.reloadQueue = [];
        this.reloadSet = new Set();
        this.callbacks = new Map();

        for (let index = 0; index < numWorkers; index++) {
            let worker = new DataviewImportWorker({ name: "Dataview Indexer " + (index + 1) });

            worker.onmessage = evt => this.finish(evt.data.path, Transferable.value(evt.data.result), index);
            this.workers.push(worker);
            this.register(() => worker.terminate());
            this.busy.push(false);
        }
    }

    /**
     * Queue the given file for reloading. Multiple reload requests for the same file in a short time period will be de-bounced
     * and all be resolved by a single actual file reload.
     */
    public reload<T>(file: TFile): Promise<T> {
        let promise: Promise<T> = new Promise((resolve, _reject) => {
            if (this.callbacks.has(file.path)) this.callbacks.get(file.path)?.push(resolve);
            else this.callbacks.set(file.path, [resolve]);
        });

        // De-bounce repeated requests for the same file.
        if (this.reloadSet.has(file.path)) return promise;
        this.reloadSet.add(file.path);

        // Immediately run this task if there are available workers; otherwise, add it to the queue.
        let workerId = this.nextAvailableWorker();
        if (workerId !== undefined) {
            this.send(file, workerId);
        } else {
            this.reloadQueue.push(file);
        }

        return promise;
    }

    /** Finish the parsing of a file, potentially queueing a new file. */
    private finish(path: string, data: any, index: number) {
        // Cache the callbacks before we do book-keeping.
        let calls = ([] as FileCallback[]).concat(this.callbacks.get(path) ?? []);

        // Book-keeping to clear metadata & allow the file to be re-loaded again.
        this.reloadSet.delete(path);
        this.callbacks.delete(path);

        // Notify the queue this file is available for new work.
        this.busy[index] = false;

        // Queue a new job onto this worker.
        let job = this.reloadQueue.shift();
        if (job !== undefined) this.send(job, index);

        // Resolve promises to let users know this file has finished.
        for (let callback of calls) callback(data);
    }

    /** Send a new task to the given worker ID. */
    private send(file: TFile, workerId: number) {
        this.busy[workerId] = true;

        this.vault.cachedRead(file).then(c =>
            this.workers[workerId].postMessage({
                path: file.path,
                contents: c,
                stat: file.stat,
                metadata: this.metadataCache.getFileCache(file),
            })
        );
    }

    /** Find the next available, non-busy worker; return undefined if all workers are busy. */
    private nextAvailableWorker(): number | undefined {
        let index = this.busy.indexOf(false);
        return index == -1 ? undefined : index;
    }
}