Skip to content
101 changes: 101 additions & 0 deletions apps/obsidian/src/components/nodeTypeIdPropertyWidget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: fileName in components should be CamelCase

AppWithUnofficialApis,
PropertyWidget,
PropertyWidgetComponentBase,
} from "~/utils/obsidianUnofficialTypes";
import type DiscourseGraphPlugin from "~/index";
import { getNodeTypeById } from "~/utils/typeUtils";

export const NODE_TYPE_ID_PROPERTY_KEY = "nodeTypeId";
const WIDGET_TYPE = "dg-node-type-id";

type NodeTypeIdPropertyWidgetComponent = PropertyWidgetComponentBase;

type NodeTypeIdPropertyWidget =
PropertyWidget<NodeTypeIdPropertyWidgetComponent>;

/**
* Obsidian's frontmatter Properties UI (reading view + live preview) renders each
* property via a widget looked up by `metadataTypeManager`. This is unofficial/
* internal API (see `obsidian-typings`), so it degrades gracefully: if Obsidian
* ever drops support, the widget type is simply unrecognized and the property
* renders as its raw text value, same as before this widget existed.
*/
const createWidget = (
plugin: DiscourseGraphPlugin,
): NodeTypeIdPropertyWidget => ({
type: WIDGET_TYPE,
icon: "shapes",
name: () => "Discourse node type",
validate: (value: unknown) => typeof value === "string",
render: (containerEl: HTMLElement, data: string) => {
const nodeType = getNodeTypeById(plugin, data);

const el = containerEl.createSpan({
cls: "dg-node-type-id-value",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stale class

text: nodeType?.name ?? data,
});
el.setAttr("title", data);
el.tabIndex = 0;

return {
type: WIDGET_TYPE,
focus: () => el.focus(),
};
},
});

let previouslyAssignedWidgetType: string | null = null;

export const registerNodeTypeIdPropertyWidget = (
plugin: DiscourseGraphPlugin,
): void => {
const metadataTypeManager = (plugin.app as AppWithUnofficialApis)
.metadataTypeManager;

if (metadataTypeManager)
try {
const assignedWidget = metadataTypeManager.getAssignedWidget(
NODE_TYPE_ID_PROPERTY_KEY,
);
if (assignedWidget !== WIDGET_TYPE) {
previouslyAssignedWidgetType = assignedWidget;
}

metadataTypeManager.registeredTypeWidgets[WIDGET_TYPE] =
createWidget(plugin);
metadataTypeManager
.setType(NODE_TYPE_ID_PROPERTY_KEY, WIDGET_TYPE)
.catch((error) => console.error(error));
} catch (error) {
console.error(error);
}
};
Comment thread
maparent marked this conversation as resolved.

export const unregisterNodeTypeIdPropertyWidget = (
plugin: DiscourseGraphPlugin,
): void => {
const metadataTypeManager = (plugin.app as AppWithUnofficialApis)
.metadataTypeManager;

if (metadataTypeManager)
try {
if (
metadataTypeManager.getAssignedWidget(NODE_TYPE_ID_PROPERTY_KEY) ===
WIDGET_TYPE
) {
if (previouslyAssignedWidgetType) {
metadataTypeManager
.setType(NODE_TYPE_ID_PROPERTY_KEY, previouslyAssignedWidgetType)
.catch((error) => console.error(error));
} else {
metadataTypeManager
.unsetType(NODE_TYPE_ID_PROPERTY_KEY)
.catch((error) => console.error(error));
}
}
delete metadataTypeManager.registeredTypeWidgets[WIDGET_TYPE];
} catch (error) {
console.error(error);
}
};
6 changes: 6 additions & 0 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {
} from "~/utils/relationsStore";
import { migrateImportFolderMetadata } from "./utils/importFolderMetadata";
import { registerTemplateSettingsSync } from "~/utils/templateSettingsSync";
import {
registerNodeTypeIdPropertyWidget,
unregisterNodeTypeIdPropertyWidget,
} from "~/components/nodeTypeIdPropertyWidget";

export default class DiscourseGraphPlugin extends Plugin {
settings: Settings = { ...DEFAULT_SETTINGS };
Expand Down Expand Up @@ -68,6 +72,7 @@ export default class DiscourseGraphPlugin extends Plugin {
});

registerTemplateSettingsSync(this);
registerNodeTypeIdPropertyWidget(this);

if (this.settings.syncModeEnabled === true) {
void initializeSupabaseSync(this).catch((error) => {
Expand Down Expand Up @@ -437,6 +442,7 @@ export default class DiscourseGraphPlugin extends Plugin {
}

onunload() {
unregisterNodeTypeIdPropertyWidget(this);
this.activeNodePopover?.close();
this.activeNodePopover = null;
this.cleanupViewActions();
Expand Down
14 changes: 10 additions & 4 deletions apps/obsidian/src/services/QueryEngine.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { TFile, App } from "obsidian";
import { TFile, App, Plugin } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import { BulkImportPattern, BulkImportCandidate, DiscourseNode } from "~/types";
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
import { extractContentFromTitle } from "~/utils/extractContentFromTitle";
import { AppWithUnofficialApis } from "~/utils/obsidianUnofficialTypes";

// This is a workaround to get the datacore API.
// TODO: Remove once we can use datacore npm package
Expand Down Expand Up @@ -31,10 +32,15 @@ export class QueryEngine {
private readonly MIN_QUERY_LENGTH = 2;

constructor(app: App) {
const appWithPlugins = app as AppWithPlugins;
this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api as
| { query: (query: string) => DatacorePage[] }
const appWithPlugins = app as AppWithUnofficialApis;
const datacorePlugin = appWithPlugins.plugins?.plugins?.["datacore"] as
| (Plugin & {
api: {
query: (query: string) => DatacorePage[];
};
})
| undefined;
this.dc = datacorePlugin?.api;
this.app = app;
}

Expand Down
137 changes: 137 additions & 0 deletions apps/obsidian/src/utils/obsidianUnofficialTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { App, Events, Plugin, Component } from "obsidian";

// extracted the MetadataTypeManager from "obsidian-types",
// and parts of internalPlugins, plugins.

type FocusMode = "both" | "end" | "start";

export type PropertyWidgetComponentBase = {
/**
* The type of the property widget.
*/
type: string;
/**
* Focus the property widget.
*
* @param mode - The focus mode.
*/
focus(mode?: FocusMode): void;
};

type PropertyRenderContext = {
/**
* Reference to the app.
*/
app: App;
/**
* Key of the property field.
*/
key: string;
/**
* Determine the source path of current context.
*/
sourcePath: string;
/**
* Callback called on property field unfocus.
*/
blur(): void;
/**
* Callback called on property value change.
*
* @param value - The new property value.
*/
onChange(value: unknown): void;
};

export type PropertyWidget<
ComponentType extends
PropertyWidgetComponentBase = PropertyWidgetComponentBase,
> = {
/**
* Lucide-dev icon associated with the widget.
*/
icon: string;
/**
* Reserved keys for the widget.
*/
reservedKeys?: string[];
/**
* Identifier for the widget.
*/
type: string;
/**
* Returns the I18N name of the widget.
*
* @returns The localized name of the widget.
*/
name(): string;
/**
* Render function for the widget on field container given context and data.
*
* @param containerEl - The container element to render the widget into.
* @param data - The property data to render.
* @param context - The rendering context for the property.
* @returns The rendered widget component.
*/
render(
containerEl: HTMLElement,
data: unknown,
context: PropertyRenderContext,
): ComponentType;
/**
* Validate whether the input value to the widget is correct.
*
* @param value - The value to validate.
* @returns Whether the value is valid.
*/
validate(value: unknown): boolean;
};

type PropertyWidgetType = string;

type MetadataTypeManager = {
/**
* Registered type widgets.
*/
registeredTypeWidgets: Record<PropertyWidgetType, PropertyWidget>;
/**
* Get assigned widget type for property.
*
* @param property - Property name.
* @returns The assigned widget type, or `null`.
*/
getAssignedWidget(property: string): null | PropertyWidgetType;
/**
* Set widget type for property.
*
* @param property - Property name.
* @param type - Widget type to assign.
* @returns A promise that resolves when the widget type is set.
*/
setType(property: string, type: PropertyWidgetType): Promise<void>;
/**
* Unset widget type for property.
*
* @param property - Property name.
* @returns A promise that resolves when the widget type is unset.
*/
unsetType(property: string): Promise<void>;
} & Events;

export type InternalPluginInstance = {
plugin: InternalPlugin;
};

type InternalPlugin = {
enabled: boolean;
instance: InternalPluginInstance;
} & Component;

export type AppWithUnofficialApis = App & {
Comment thread
trangdoan982 marked this conversation as resolved.
appId: string;
metadataTypeManager?: MetadataTypeManager;
plugins?: Events & { plugins?: Record<string, Plugin> };
internalPlugins?: Events & {
plugins?: Record<string, InternalPlugin>;
};
};
4 changes: 2 additions & 2 deletions apps/obsidian/src/utils/supabaseContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
FatalError,
} from "@repo/database/lib/contextFunctions";
import type DiscourseGraphPlugin from "~/index";

import type { AppWithUnofficialApis } from "./obsidianUnofficialTypes";
type Platform = Enums<"Platform">;

export type SupabaseContext = {
Expand Down Expand Up @@ -58,7 +58,7 @@ const getOrCreateAccountLocalId = async (
* @see https://help.obsidian.md/Extending+Obsidian/Obsidian+URI
*/
export const getVaultId = (app: DiscourseGraphPlugin["app"]): string => {
return (app as unknown as { appId: string }).appId;
return (app as AppWithUnofficialApis).appId;
};

/** Canonical space URL for an Obsidian vault; stored as Space.url in the DB. */
Expand Down
13 changes: 11 additions & 2 deletions apps/obsidian/src/utils/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
getFrontMatterInfo,
} from "obsidian";
import type { Settings } from "~/types";
import type {
AppWithUnofficialApis,
InternalPluginInstance,
} from "./obsidianUnofficialTypes";

type TemplatePluginInfo = {
isEnabled: boolean;
Expand Down Expand Up @@ -47,13 +51,18 @@ const mergeFrontmatter = (

export const getTemplatePluginInfo = (app: App): TemplatePluginInfo => {
try {
const templatesPlugin = (app as any).internalPlugins?.plugins?.templates;
const templatesPlugin = (app as AppWithUnofficialApis).internalPlugins
?.plugins?.templates;

if (!templatesPlugin || !templatesPlugin.enabled) {
return { isEnabled: false, folderPath: "" };
}

const folderPath = templatesPlugin.instance?.options?.folder || "";
const instance = templatesPlugin.instance as InternalPluginInstance & {
options?: { folder?: string };
};

const folderPath = instance.options?.folder || "";

return {
isEnabled: true,
Expand Down