diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 3f116ad63..ec1fdc4c2 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -470,7 +470,7 @@ export const setDiscourseNodeSetting = ( } let pageUid = nodeType; - const blockProps = getBlockPropsByUid(pageUid, []); + let blockProps = getBlockPropsByUid(pageUid, []); if (!blockProps || Object.keys(blockProps).length === 0) { const lookedUpUid = getPageUidByPageTitle( @@ -478,10 +478,11 @@ export const setDiscourseNodeSetting = ( ); if (lookedUpUid) { pageUid = lookedUpUid; + blockProps = getBlockPropsByUid(pageUid, []); } } - if (!pageUid) { + if (!blockProps || Object.keys(blockProps).length === 0) { internalError({ error: `setDiscourseNodeSetting - could not find page for: ${nodeType}`, type: "DG Accessor", @@ -534,4 +535,4 @@ export const getAllDiscourseNodes = (): DiscourseNodeSettings[] => { } return nodes; -}; +}; \ No newline at end of file diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index d50d0027e..f862c3f83 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -128,7 +128,12 @@ const initSingleDiscourseNode = async ( const initDiscourseNodePages = async (): Promise> => { if (hasNonDefaultNodes()) { - return {}; + const existingNodes = getAllDiscourseNodes(); + const nodePageUids: Record = {}; + for (const node of existingNodes) { + nodePageUids[node.text] = node.type; + } + return nodePageUids; } const results = await Promise.all( @@ -153,6 +158,5 @@ export type InitSchemaResult = { export const initSchema = async (): Promise => { const blockUids = await initSettingsPageBlocks(); const nodePageUids = await initDiscourseNodePages(); - return { blockUids, nodePageUids }; }; diff --git a/apps/roam/src/components/settings/utils/pullWatchers.ts b/apps/roam/src/components/settings/utils/pullWatchers.ts new file mode 100644 index 000000000..5b3c097e8 --- /dev/null +++ b/apps/roam/src/components/settings/utils/pullWatchers.ts @@ -0,0 +1,283 @@ +import { type json, normalizeProps } from "~/utils/getBlockProps"; +import type { AddPullWatch, PullBlock } from "roamjs-components/types"; +import { + TOP_LEVEL_BLOCK_PROP_KEYS, + getPersonalSettingsKey, + FeatureFlagsSchema, + GlobalSettingsSchema, + PersonalSettingsSchema, + DiscourseNodeSchema, + type FeatureFlags, + type GlobalSettings, + type PersonalSettings, + type DiscourseNodeSettings, +} from "./zodSchema"; + +type PullWatchCallback = Parameters[2]; + +// Need assertions to bridge type defs between the (roamjs-components) and json type (getBlockProps.ts) +const getNormalizedProps = (data: PullBlock | null): Record => { + return normalizeProps((data?.[":block/props"] || {}) as json) as Record< + string, + json + >; +}; + +const hasPropChanged = ( + before: PullBlock | null, + after: PullBlock | null, + key?: string, +): boolean => { + const beforeProps = getNormalizedProps(before); + const afterProps = getNormalizedProps(after); + + if (key) { + return JSON.stringify(beforeProps[key]) !== JSON.stringify(afterProps[key]); + } + + return JSON.stringify(beforeProps) !== JSON.stringify(afterProps); +}; + +const createCleanupFn = (watches: Parameters[]): (() => void) => { + return () => { + watches.forEach(([pattern, entityId, callback]) => { + window.roamAlphaAPI.data.removePullWatch(pattern, entityId, callback); + }); + }; +}; + +const createSettingsWatchCallback = ( + schema: { safeParse: (data: unknown) => { success: boolean; data?: T } }, + onSettingsChange: (context: { + newSettings: T; + oldSettings: T | null; + before: PullBlock | null; + after: PullBlock | null; + }) => void, +): PullWatchCallback => { + return (before, after) => { + const beforeProps = getNormalizedProps(before); + const afterProps = getNormalizedProps(after); + const beforeResult = schema.safeParse(beforeProps); + const afterResult = schema.safeParse(afterProps); + + if (!afterResult.success) return; + + const oldSettings = beforeResult.success + ? (beforeResult.data ?? null) + : null; + const newSettings = afterResult.data as T; + + onSettingsChange({ newSettings, oldSettings, before, after }); + }; +}; + +const addPullWatch = ( + watches: Parameters[], + blockUid: string, + callback: PullWatchCallback, +): void => { + const pattern = "[:block/props]"; + const entityId = `[:block/uid "${blockUid}"]`; + + window.roamAlphaAPI.data.addPullWatch(pattern, entityId, callback); + watches.push([pattern, entityId, callback]); +}; + +export const featureFlagHandlers: Partial< + Record< + keyof FeatureFlags, + (newValue: boolean, oldValue: boolean, allFlags: FeatureFlags) => void + > +> = { + // Add handlers as needed: + // "Enable Left Sidebar": (newValue) => { ... }, + // "Suggestive Mode Enabled": (newValue) => { ... }, + // "Reified Relation Triples": (newValue) => { ... }, +}; + +type GlobalSettingsHandlers = { + [K in keyof GlobalSettings]?: ( + newValue: GlobalSettings[K], + oldValue: GlobalSettings[K], + allSettings: GlobalSettings, + ) => void; +}; + +export const globalSettingsHandlers: GlobalSettingsHandlers = { + // Add handlers as needed: + // "Trigger": (newValue) => { ... }, + // "Canvas Page Format": (newValue) => { ... }, + // "Left Sidebar": (newValue) => { ... }, + // "Export": (newValue) => { ... }, + // "Suggestive Mode": (newValue) => { ... }, +}; + +type PersonalSettingsHandlers = { + [K in keyof PersonalSettings]?: ( + newValue: PersonalSettings[K], + oldValue: PersonalSettings[K], + allSettings: PersonalSettings, + ) => void; +}; + +export const personalSettingsHandlers: PersonalSettingsHandlers = { + // "Left Sidebar" stub for testing with stubSetLeftSidebarPersonalSections() in accessors.ts + /* eslint-disable @typescript-eslint/naming-convention */ + "Left Sidebar": (newValue, oldValue) => { + const oldSections = Object.keys(oldValue || {}); + const newSections = Object.keys(newValue || {}); + + if (newSections.length === 0 && oldSections.length === 0) return; + + console.group("👤 [PullWatch] Personal Settings Changed: Left Sidebar"); + console.log("Old value:", JSON.stringify(oldValue, null, 2)); + console.log("New value:", JSON.stringify(newValue, null, 2)); + + const addedSections = newSections.filter((s) => !oldSections.includes(s)); + const removedSections = oldSections.filter((s) => !newSections.includes(s)); + + if (addedSections.length > 0) { + console.log(" → Sections added:", addedSections); + } + if (removedSections.length > 0) { + console.log(" → Sections removed:", removedSections); + } + console.groupEnd(); + }, + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +export const discourseNodeHandlers: Array< + ( + nodeType: string, + newSettings: DiscourseNodeSettings, + oldSettings: DiscourseNodeSettings | null, + ) => void +> = [ + // Add handlers as needed: + // (nodeType, newSettings, oldSettings) => { ... }, +]; + +export const setupPullWatchOnSettingsPage = ( + blockUids: Record, +): (() => void) => { + const watches: Parameters[] = []; + + const featureFlagsBlockUid = + blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.featureFlags]; + const globalSettingsBlockUid = blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.global]; + const personalSettingsKey = getPersonalSettingsKey(); + const personalSettingsBlockUid = blockUids[personalSettingsKey]; + + if (featureFlagsBlockUid && Object.keys(featureFlagHandlers).length > 0) { + addPullWatch( + watches, + featureFlagsBlockUid, + createSettingsWatchCallback( + FeatureFlagsSchema, + ({ newSettings, oldSettings, before, after }) => { + for (const [key, handler] of Object.entries(featureFlagHandlers)) { + const typedKey = key as keyof FeatureFlags; + if (hasPropChanged(before, after, key) && handler) { + handler( + newSettings[typedKey], + oldSettings?.[typedKey] ?? false, + newSettings, + ); + } + } + }, + ), + ); + } + + if ( + globalSettingsBlockUid && + Object.keys(globalSettingsHandlers).length > 0 + ) { + addPullWatch( + watches, + globalSettingsBlockUid, + createSettingsWatchCallback( + GlobalSettingsSchema, + ({ newSettings, oldSettings, before, after }) => { + for (const [key, handler] of Object.entries(globalSettingsHandlers)) { + const typedKey = key as keyof GlobalSettings; + if (hasPropChanged(before, after, key) && handler) { + // Object.entries loses key-handler correlation, but data is Zod-validated + ( + handler as ( + newValue: unknown, + oldValue: unknown, + allSettings: GlobalSettings, + ) => void + )(newSettings[typedKey], oldSettings?.[typedKey], newSettings); + } + } + }, + ), + ); + } + + if ( + personalSettingsBlockUid && + Object.keys(personalSettingsHandlers).length > 0 + ) { + addPullWatch( + watches, + personalSettingsBlockUid, + createSettingsWatchCallback( + PersonalSettingsSchema, + ({ newSettings, oldSettings, before, after }) => { + for (const [key, handler] of Object.entries( + personalSettingsHandlers, + )) { + const typedKey = key as keyof PersonalSettings; + if (hasPropChanged(before, after, key) && handler) { + // Object.entries loses key-handler correlation, but data is Zod-validated + ( + handler as ( + newValue: unknown, + oldValue: unknown, + allSettings: PersonalSettings, + ) => void + )(newSettings[typedKey], oldSettings?.[typedKey], newSettings); + } + } + }, + ), + ); + } + + return createCleanupFn(watches); +}; + +export const setupPullWatchDiscourseNodes = ( + nodePageUids: Record, +): (() => void) => { + const watches: Parameters[] = []; + + if (discourseNodeHandlers.length === 0) { + return () => {}; + } + + Object.entries(nodePageUids).forEach(([nodeType, pageUid]) => { + addPullWatch( + watches, + pageUid, + createSettingsWatchCallback( + DiscourseNodeSchema, + ({ newSettings, oldSettings }) => { + for (const handler of discourseNodeHandlers) { + handler(nodeType, newSettings, oldSettings); + } + }, + ), + ); + }); + + return createCleanupFn(watches); +}; + +export { getNormalizedProps, hasPropChanged }; diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 11426c546..1bed828d4 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -41,7 +41,6 @@ import { STREAMLINE_STYLING_KEY, DISALLOW_DIAGNOSTICS, } from "./data/userSettings"; -import { initSchema } from "./components/settings/utils/init"; export const DEFAULT_CANVAS_PAGE_FORMAT = "Canvas/*";