diff --git a/apps/obsidian/src/components/nodeTypeIdPropertyWidget.ts b/apps/obsidian/src/components/nodeTypeIdPropertyWidget.ts new file mode 100644 index 000000000..001ec298a --- /dev/null +++ b/apps/obsidian/src/components/nodeTypeIdPropertyWidget.ts @@ -0,0 +1,83 @@ +import type { + 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; + +/** + * 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: unknown) => { + const nodeTypeId = typeof data === "string" ? data : String(data ?? ""); + const nodeType = getNodeTypeById(plugin, nodeTypeId); + + const el = containerEl.createSpan({ + cls: "dg-node-type-id-value", + text: nodeType?.name ?? nodeTypeId, + }); + el.setAttr("title", nodeTypeId); + el.tabIndex = 0; + + return { + type: WIDGET_TYPE, + focus: () => el.focus(), + }; + }, +}); + +export const registerNodeTypeIdPropertyWidget = ( + plugin: DiscourseGraphPlugin, +): void => { + const metadataTypeManager = (plugin.app as AppWithUnofficialApis) + .metadataTypeManager; + + if (metadataTypeManager) + try { + metadataTypeManager.registeredTypeWidgets[WIDGET_TYPE] = + createWidget(plugin); + void metadataTypeManager.setType(NODE_TYPE_ID_PROPERTY_KEY, WIDGET_TYPE); + } catch (error) { + console.error(error); + } +}; + +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 + ) { + void metadataTypeManager.unsetType(NODE_TYPE_ID_PROPERTY_KEY); + } + delete metadataTypeManager.registeredTypeWidgets[WIDGET_TYPE]; + } catch (error) { + console.error(error); + } +}; diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d5f57e27c..e4291109d 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -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 }; @@ -68,6 +72,7 @@ export default class DiscourseGraphPlugin extends Plugin { }); registerTemplateSettingsSync(this); + registerNodeTypeIdPropertyWidget(this); if (this.settings.syncModeEnabled === true) { void initializeSupabaseSync(this).catch((error) => { @@ -437,6 +442,7 @@ export default class DiscourseGraphPlugin extends Plugin { } onunload() { + unregisterNodeTypeIdPropertyWidget(this); this.activeNodePopover?.close(); this.activeNodePopover = null; this.cleanupViewActions(); diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 0edee84e6..eeca29d6c 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -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 @@ -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; } diff --git a/apps/obsidian/src/utils/obsidianUnofficialTypes.ts b/apps/obsidian/src/utils/obsidianUnofficialTypes.ts new file mode 100644 index 000000000..f6f531364 --- /dev/null +++ b/apps/obsidian/src/utils/obsidianUnofficialTypes.ts @@ -0,0 +1,174 @@ +import { App, Events, Debouncer, Plugin, Component } from "obsidian"; + +// extracted the MetaadataTypeManager 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 PropertyWidgetEntry = { + /** + * Display name of the property widget. + */ + name: string; + /** + * The property widget type. + */ + widget: string; +}; + +type PropertyInfo = { + /** + * Name of property. + */ + name: string; + /** + * Usage count of property. + */ + occurrences: number; + /** + * Type of property. + */ + widget: string; +}; + +type TypeInfo = { + /** + * The explicitly assigned property widget type. + */ + expected: PropertyWidget; + /** + * The property widget type inferred from the value. + */ + inferred: PropertyWidget; +}; + +type PropertyWidgetType = string; + +type MetadataTypeManager = { + /** + * Registered type widgets. + */ + registeredTypeWidgets: Record; + /** + * 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; + /** + * Unset widget type for property. + * + * @param property - Property name. + * @returns A promise that resolves when the widget type is unset. + */ + unsetType(property: string): Promise; +} & Events; + +export type InternalPluginInstance = { + plugin: InternalPlugin; +}; + +type InternalPlugin = { + enabled: boolean; + instance: InternalPluginInstance; +} & Component; + +export type AppWithUnofficialApis = App & { + appId: string; + metadataTypeManager?: MetadataTypeManager; + plugins?: Events & { plugins?: Record }; + internalPlugins?: Events & { + plugins?: Record; + }; +}; diff --git a/apps/obsidian/src/utils/supabaseContext.ts b/apps/obsidian/src/utils/supabaseContext.ts index d52ce351b..77bdc31cf 100644 --- a/apps/obsidian/src/utils/supabaseContext.ts +++ b/apps/obsidian/src/utils/supabaseContext.ts @@ -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 = { @@ -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. */ diff --git a/apps/obsidian/src/utils/templates.ts b/apps/obsidian/src/utils/templates.ts index cc69b1c22..af4247364 100644 --- a/apps/obsidian/src/utils/templates.ts +++ b/apps/obsidian/src/utils/templates.ts @@ -6,6 +6,10 @@ import { getFrontMatterInfo, } from "obsidian"; import type { Settings } from "~/types"; +import type { + AppWithUnofficialApis, + InternalPluginInstance, +} from "./obsidianUnofficialTypes"; type TemplatePluginInfo = { isEnabled: boolean; @@ -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,