diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index cc5a0844f..ed5b116d3 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -3,9 +3,15 @@ import getBlockProps, { type json, } from "~/utils/getBlockProps"; import setBlockProps from "~/utils/setBlockProps"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; import getBlockUidByTextOnPage from "roamjs-components/queries/getBlockUidByTextOnPage"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import { getSubTree } from "roamjs-components/util"; +import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; import internalError from "~/utils/internalError"; +import { getSetting } from "~/utils/extensionSettings"; +import { getFormattedConfigTree } from "~/utils/discourseConfigRef"; +import { roamNodeToCondition } from "~/utils/parseQuery"; import { z } from "zod"; import { @@ -106,6 +112,20 @@ const getSchemaAtPath = ( const formatSettingPath = (keys: string[]): string => keys.length === 0 ? "(root)" : keys.join(" > "); +const readPathValue = (root: unknown, keys: string[]): unknown => + keys.reduce((current, key) => { + if (Array.isArray(current)) { + const index = Number(key); + return Number.isInteger(index) && index >= 0 && index < current.length + ? current[index] + : undefined; + } + if (!isRecord(current) || !(key in current)) return undefined; + return current[key]; + }, root); + +const pathKey = (keys: string[]): string => keys.join("::"); + const validateSettingValue = ({ schema, keys, @@ -142,6 +162,367 @@ const validateSettingValue = ({ return true; }; +const DEFAULT_PERSONAL_SETTINGS = PersonalSettingsSchema.parse({}); +const DEFAULT_GLOBAL_SETTINGS = GlobalSettingsSchema.parse({}); +const DEFAULT_LEGACY_QUERY = { + conditions: [], + selections: [], + custom: "", + returnNode: "node", +}; + +const PERSONAL_SCHEMA_PATH_TO_LEGACY_KEY = new Map([ + [pathKey(["Discourse context overlay"]), "discourse-context-overlay"], + [pathKey(["Suggestive mode overlay"]), "suggestive-mode-overlay"], + [pathKey(["Text selection popup"]), "text-selection-popup"], + [pathKey(["Disable sidebar open"]), "disable-sidebar-open"], + [pathKey(["Page preview"]), "page-preview"], + [pathKey(["Hide feedback button"]), "hide-feedback-button"], + [pathKey(["Auto canvas relations"]), "auto-canvas-relations"], + [pathKey(["Overlay in canvas"]), "discourse-context-overlay-in-canvas"], + [pathKey(["Streamline styling"]), "streamline-styling"], + [pathKey(["Disable product diagnostics"]), "disallow-diagnostics"], + [pathKey(["Discourse tool shortcut"]), "discourse-tool-shortcut"], + [pathKey(["Personal node menu trigger"]), "personal-node-menu-trigger"], + [pathKey(["Node search menu trigger"]), "node-search-trigger"], + [pathKey(["Query", "Hide query metadata"]), "hide-metadata"], + [pathKey(["Query", "Default page size"]), "default-page-size"], + [pathKey(["Query", "Query pages"]), "query-pages"], + [pathKey(["Query", "Default filters"]), "default-filters"], +]); + +const getLegacyPersonalLeftSidebarSetting = (): unknown[] => { + const settings = getFormattedConfigTree(); + + /* eslint-disable @typescript-eslint/naming-convention */ + return settings.leftSidebar.personal.sections.map((section) => ({ + name: section.text, + Children: (section.children || []).map((child) => ({ + uid: child.uid, + Alias: child.alias?.value || "", + })), + Settings: { + "Truncate-result?": section.settings?.truncateResult.value ?? 75, + Folded: section.settings?.folded.value ?? false, + }, + })); + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +const getLegacyPersonalSetting = (keys: string[]): unknown => { + if (keys.length === 0) return undefined; + + const mappedOldKey = PERSONAL_SCHEMA_PATH_TO_LEGACY_KEY.get(pathKey(keys)); + if (mappedOldKey) { + return getSetting( + mappedOldKey, + readPathValue(DEFAULT_PERSONAL_SETTINGS, keys), + ); + } + + if (keys.length === 1 && keys[0] === "Query") { + const querySettings: Record = {}; + querySettings["Hide query metadata"] = getLegacyPersonalSetting([ + "Query", + "Hide query metadata", + ]); + querySettings["Default page size"] = getLegacyPersonalSetting([ + "Query", + "Default page size", + ]); + querySettings["Query pages"] = getLegacyPersonalSetting([ + "Query", + "Query pages", + ]); + querySettings["Default filters"] = getLegacyPersonalSetting([ + "Query", + "Default filters", + ]); + return querySettings; + } + + if (keys[0] === "Left sidebar") { + const leftSidebarSettings = getLegacyPersonalLeftSidebarSetting(); + if (keys.length === 1) return leftSidebarSettings; + return readPathValue(leftSidebarSettings, keys.slice(1)); + } + + return undefined; +}; + +// NOTE(ENG-1469): This returns the block props schema shape (Record). Runtime consumers use getDiscourseRelations() +// which returns a flat DiscourseRelation[] with a different structure (one entry per +// if-block, triples at top level, no nodePositions). When migrating getDiscourseRelations() +// to read from block props, it will need a conversion from this shape to the flat array. +const getLegacyRelationsSetting = (): Record => { + const settingsUid = getPageUidByPageTitle(DG_BLOCK_PROP_SETTINGS_PAGE_TITLE); + if (!settingsUid) return DEFAULT_GLOBAL_SETTINGS.Relations; + + const configTree = getBasicTreeByParentUid(settingsUid); + const grammarChildren = getSubTree({ + tree: configTree, + key: "grammar", + }).children; + const relationNodes = getSubTree({ + tree: grammarChildren, + key: "relations", + }).children; + if (relationNodes.length === 0) return DEFAULT_GLOBAL_SETTINGS.Relations; + + return Object.fromEntries( + relationNodes.map((relationNode) => { + const relationTree = relationNode.children; + const ifBlocks = getSubTree({ tree: relationTree, key: "If" }).children; + const ifConditions = ifBlocks.map((ifBlock) => { + const blockChildren = ifBlock.children; + const nodePositionsNode = blockChildren.find((c) => + /node positions/i.test(c.text), + ); + const triples = blockChildren + .filter((c) => !/node positions/i.test(c.text)) + .map( + (c) => + [ + c.text, + c.children[0]?.text || "", + c.children[0]?.children[0]?.text || "", + ] as [string, string, string], + ); + const nodePositions = Object.fromEntries( + (nodePositionsNode?.children || []).map((c) => [ + c.text, + c.children[0]?.text || "", + ]), + ); + return { triples, nodePositions }; + }); + + return [ + relationNode.uid || relationNode.text, + { + label: relationNode.text, + source: getSettingValueFromTree({ + tree: relationTree, + key: "Source", + }), + destination: getSettingValueFromTree({ + tree: relationTree, + key: "Destination", + }), + complement: getSettingValueFromTree({ + tree: relationTree, + key: "Complement", + }), + ifConditions, + }, + ]; + }), + ); +}; + +// Reconstructs global settings from getFormattedConfigTree() shape to match block-props schema shape +const getLegacyGlobalSetting = (keys: string[]): unknown => { + if (keys.length === 0) return undefined; + + const settings = getFormattedConfigTree(); + const firstKey = keys[0]; + + if (firstKey === "Trigger") { + return settings.trigger.value || DEFAULT_GLOBAL_SETTINGS.Trigger; + } + + if (firstKey === "Canvas page format") { + return ( + settings.canvasPageFormat.value || + DEFAULT_GLOBAL_SETTINGS["Canvas page format"] + ); + } + + if (firstKey === "Left sidebar") { + const leftSidebarSettings: Record = {}; + leftSidebarSettings["Children"] = settings.leftSidebar.global.children.map( + (c) => c.text, + ); + const sidebarSettingValues: Record = {}; + sidebarSettingValues["Collapsable"] = + settings.leftSidebar.global.settings?.collapsable.value ?? + DEFAULT_GLOBAL_SETTINGS["Left sidebar"].Settings.Collapsable; + sidebarSettingValues["Folded"] = + settings.leftSidebar.global.settings?.folded.value ?? + DEFAULT_GLOBAL_SETTINGS["Left sidebar"].Settings.Folded; + leftSidebarSettings["Settings"] = sidebarSettingValues; + if (keys.length === 1) return leftSidebarSettings; + return readPathValue(leftSidebarSettings, keys.slice(1)); + } + + if (firstKey === "Export") { + const exportSettings: Record = {}; + exportSettings["Remove special characters"] = + settings.export.removeSpecialCharacters.value ?? + DEFAULT_GLOBAL_SETTINGS.Export["Remove special characters"]; + exportSettings["Resolve block references"] = + settings.export.optsRefs.value ?? + DEFAULT_GLOBAL_SETTINGS.Export["Resolve block references"]; + exportSettings["Resolve block embeds"] = + settings.export.optsEmbeds.value ?? + DEFAULT_GLOBAL_SETTINGS.Export["Resolve block embeds"]; + exportSettings["Append referenced node"] = + settings.export.appendRefNodeContext.value ?? + DEFAULT_GLOBAL_SETTINGS.Export["Append referenced node"]; + exportSettings["Link type"] = + settings.export.linkType.value || + DEFAULT_GLOBAL_SETTINGS.Export["Link type"]; + exportSettings["Max filename length"] = + settings.export.maxFilenameLength.value ?? + DEFAULT_GLOBAL_SETTINGS.Export["Max filename length"]; + exportSettings["Frontmatter"] = + settings.export.frontmatter.values ?? + DEFAULT_GLOBAL_SETTINGS.Export.Frontmatter; + if (keys.length === 1) return exportSettings; + return readPathValue(exportSettings, keys.slice(1)); + } + + if (firstKey === "Suggestive mode") { + const suggestiveModeSettings: Record = {}; + suggestiveModeSettings["Include current page relations"] = + settings.suggestiveMode.includePageRelations.value ?? + DEFAULT_GLOBAL_SETTINGS["Suggestive mode"][ + "Include current page relations" + ]; + suggestiveModeSettings["Include parent and child blocks"] = + settings.suggestiveMode.includeParentAndChildren.value ?? + DEFAULT_GLOBAL_SETTINGS["Suggestive mode"][ + "Include parent and child blocks" + ]; + suggestiveModeSettings["Page groups"] = + settings.suggestiveMode.pageGroups.groups.map((group) => ({ + name: group.name, + pages: group.pages.map((page) => page.name), + })); + if (keys.length === 1) return suggestiveModeSettings; + return readPathValue(suggestiveModeSettings, keys.slice(1)); + } + + if (firstKey === "Relations") { + const relationsSettings = getLegacyRelationsSetting(); + if (keys.length === 1) return relationsSettings; + return readPathValue(relationsSettings, keys.slice(1)); + } + + return undefined; +}; + +const getLegacyQuerySettingByParentUid = (parentUid: string) => { + const scratchNode = getSubTree({ parentUid, key: "scratch" }); + const conditionsNode = getSubTree({ + tree: scratchNode.children, + key: "conditions", + }); + const selectionsNode = getSubTree({ + tree: scratchNode.children, + key: "selections", + }); + const customNode = getSubTree({ tree: scratchNode.children, key: "custom" }); + + return { + conditions: conditionsNode.children.map(roamNodeToCondition), + selections: selectionsNode.children.map((s) => ({ + text: s.text, + label: s.children[0]?.text || "", + })), + custom: customNode.children[0]?.text || "", + returnNode: "node", + }; +}; + +// Reconstructs per-node settings from Roam tree structure to match block-props schema shape +const getLegacyDiscourseNodeSetting = ( + nodeType: string, + keys: string[], +): unknown => { + let nodeUid = nodeType; + let tree = getBasicTreeByParentUid(nodeUid); + + if (tree.length === 0) { + const lookedUpUid = getPageUidByPageTitle( + `${DISCOURSE_NODE_PAGE_PREFIX}${nodeType}`, + ); + if (lookedUpUid) { + nodeUid = lookedUpUid; + tree = getBasicTreeByParentUid(nodeUid); + } + } + + if (tree.length === 0) return undefined; + + const rawCanvas = Object.fromEntries( + getSubTree({ tree, key: "canvas" }).children.map((c) => [ + c.text, + c.children[0]?.text || "", + ]), + ); + /* eslint-disable @typescript-eslint/naming-convention */ + const canvasSettings = { + color: rawCanvas["color"] || "", + alias: rawCanvas["alias"] || "", + "key-image": rawCanvas["key-image"] === "true", + "key-image-option": rawCanvas["key-image-option"] || "first-image", + "query-builder-alias": rawCanvas["query-builder-alias"] || "", + }; + /* eslint-enable @typescript-eslint/naming-convention */ + const attributes = Object.fromEntries( + getSubTree({ tree, key: "Attributes" }).children.map((c) => [ + c.text, + c.children[0]?.text || "", + ]), + ); + const overlayUid = getSubTree({ tree, key: "Overlay" }).uid; + const suggestiveRulesTree = getSubTree({ + tree, + key: "Suggestive Rules", + }).children; + const indexUid = getSubTree({ tree, key: "Index" }).uid; + const specificationUid = getSubTree({ tree, key: "Specification" }).uid; + + const legacySettings = { + type: nodeUid, + format: getSettingValueFromTree({ tree, key: "format" }), + shortcut: getSettingValueFromTree({ tree, key: "shortcut" }), + tag: getSettingValueFromTree({ tree, key: "tag" }), + graphOverview: tree.some((c) => c.text === "Graph Overview"), + description: getSettingValueFromTree({ tree, key: "description" }), + overlay: overlayUid + ? getBasicTreeByParentUid(overlayUid)[0]?.text || "" + : "", + attributes, + template: getSubTree({ tree, key: "template" }).children, + canvasSettings, + suggestiveRules: { + embeddingRef: + getSubTree({ tree: suggestiveRulesTree, key: "Embedding Block Ref" }) + .children[0]?.text || "", + isFirstChild: !!getSubTree({ + tree: suggestiveRulesTree, + key: "First Child", + }).uid, + }, + index: indexUid + ? getLegacyQuerySettingByParentUid(indexUid) + : DEFAULT_LEGACY_QUERY, + specification: { + enabled: specificationUid + ? !!getSubTree({ parentUid: specificationUid, key: "enabled" }).uid + : false, + query: specificationUid + ? getLegacyQuerySettingByParentUid(specificationUid) + : DEFAULT_LEGACY_QUERY, + }, + }; + + return readPathValue(legacySettings, keys); +}; + const getBlockPropsByUid = ( blockUid: string, keys: string[], @@ -312,12 +693,20 @@ export const getGlobalSettings = (): GlobalSettings => { export const getGlobalSetting = ( keys: string[], ): T | undefined => { - const settings = getGlobalSettings(); + if (!isNewSettingsStoreEnabled()) { + return getLegacyGlobalSetting(keys) as T | undefined; + } - return keys.reduce((current, key) => { - if (!isRecord(current) || !(key in current)) return undefined; - return current[key]; - }, settings) as T | undefined; + const settings = getGlobalSettings(); + const blockPropsValue = readPathValue(settings, keys); + const legacyValue = getLegacyGlobalSetting(keys); + if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + console.warn( + `[DG Dual-Read] Mismatch at Global > ${formatSettingPath(keys)}`, + { blockProps: blockPropsValue, legacy: legacyValue }, + ); + } + return blockPropsValue as T | undefined; }; export const setGlobalSetting = (keys: string[], value: json): void => { @@ -368,12 +757,20 @@ export const getPersonalSettings = (): PersonalSettings => { export const getPersonalSetting = ( keys: string[], ): T | undefined => { - const settings = getPersonalSettings(); + if (!isNewSettingsStoreEnabled()) { + return getLegacyPersonalSetting(keys) as T | undefined; + } - return keys.reduce((current, key) => { - if (!isRecord(current) || !(key in current)) return undefined; - return current[key]; - }, settings) as T | undefined; + const settings = getPersonalSettings(); + const blockPropsValue = readPathValue(settings, keys); + const legacyValue = getLegacyPersonalSetting(keys); + if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + console.warn( + `[DG Dual-Read] Mismatch at Personal > ${formatSettingPath(keys)}`, + { blockProps: blockPropsValue, legacy: legacyValue }, + ); + } + return blockPropsValue as T | undefined; }; export const setPersonalSetting = (keys: string[], value: json): void => { @@ -404,9 +801,7 @@ export const setPersonalSetting = (keys: string[], value: json): void => { }); }; -export const getDiscourseNodeSettings = ( - nodeType: string, -): DiscourseNodeSettings | undefined => { +const getRawDiscourseNodeBlockProps = (nodeType: string): json | undefined => { let pageUid = nodeType; let blockProps = getBlockPropsByUid(pageUid, []); @@ -420,6 +815,13 @@ export const getDiscourseNodeSettings = ( } } + return blockProps; +}; + +export const getDiscourseNodeSettings = ( + nodeType: string, +): DiscourseNodeSettings | undefined => { + const blockProps = getRawDiscourseNodeBlockProps(nodeType); if (!blockProps) return undefined; const result = DiscourseNodeSchema.safeParse(blockProps); @@ -439,14 +841,20 @@ export const getDiscourseNodeSetting = ( nodeType: string, keys: string[], ): T | undefined => { - const settings = getDiscourseNodeSettings(nodeType); - - if (!settings) return undefined; + if (!isNewSettingsStoreEnabled()) { + return getLegacyDiscourseNodeSetting(nodeType, keys) as T | undefined; + } - return keys.reduce((current, key) => { - if (!isRecord(current) || !(key in current)) return undefined; - return current[key]; - }, settings) as T | undefined; + const settings = getDiscourseNodeSettings(nodeType); + const blockPropsValue = settings ? readPathValue(settings, keys) : undefined; + const legacyValue = getLegacyDiscourseNodeSetting(nodeType, keys); + if (JSON.stringify(blockPropsValue) !== JSON.stringify(legacyValue)) { + console.warn( + `[DG Dual-Read] Mismatch at Discourse Node (${nodeType}) > ${formatSettingPath(keys)}`, + { blockProps: blockPropsValue, legacy: legacyValue }, + ); + } + return blockPropsValue as T | undefined; }; export const setDiscourseNodeSetting = ( diff --git a/apps/roam/src/components/settings/utils/zodSchema.example.ts b/apps/roam/src/components/settings/utils/zodSchema.example.ts index 2e6bfa960..7a9041666 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.example.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.example.ts @@ -404,9 +404,9 @@ const defaultPersonalSettings: PersonalSettings = { "Auto canvas relations": false, "Disable product diagnostics": false, Query: { - "Hide query metadata": false, + "Hide query metadata": true, "Default page size": 10, - "Query pages": [], + "Query pages": ["discourse-graph/queries/*"], "Default filters": {}, }, }; diff --git a/apps/roam/src/components/settings/utils/zodSchema.ts b/apps/roam/src/components/settings/utils/zodSchema.ts index 8c8486ed7..212c09e21 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.ts @@ -95,6 +95,19 @@ const booleanWithDefault = (defaultVal: boolean) => .optional() .transform((val) => val ?? defaultVal); +const defaultNodeIndex = () => ({ + conditions: [] as { + type: string; + relation: string; + source: string; + uid: string; + not: boolean; + }[], + selections: [] as { text: string; label: string }[], + custom: "", + returnNode: "node", +}); + export const DiscourseNodeSchema = z.object({ text: z.string(), type: z.string(), @@ -109,13 +122,7 @@ export const DiscourseNodeSchema = z.object({ }) .nullable() .optional() - .transform( - (val) => - val ?? { - enabled: false, - query: { conditions: [], selections: [], custom: "", returnNode: "" }, - }, - ), + .transform((val) => val ?? { enabled: false, query: defaultNodeIndex() }), template: z .array(RoamNodeSchema) .nullable() @@ -129,7 +136,9 @@ export const DiscourseNodeSchema = z.object({ .optional() .transform((val) => val ?? {}), overlay: stringWithDefault(""), - index: IndexSchema.nullable().optional(), + index: IndexSchema.nullable() + .optional() + .transform((val) => val ?? defaultNodeIndex()), suggestiveRules: SuggestiveRulesSchema.default({}), backedBy: z .enum(["user", "default", "relation"]) @@ -230,9 +239,9 @@ export const StoredFiltersSchema = z.object({ }); export const QuerySettingsSchema = z.object({ - "Hide query metadata": z.boolean().default(false), + "Hide query metadata": z.boolean().default(true), "Default page size": z.number().default(10), - "Query pages": z.array(z.string()).default([]), + "Query pages": z.array(z.string()).default(["discourse-graph/queries/*"]), "Default filters": z.record(z.string(), StoredFiltersSchema).default({}), }); diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 0579784c3..9eb8095aa 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -42,6 +42,7 @@ import { DISALLOW_DIAGNOSTICS, } from "./data/userSettings"; import { initSchema } from "./components/settings/utils/init"; + export const DEFAULT_CANVAS_PAGE_FORMAT = "Canvas/*"; export default runExtension(async (onloadArgs) => {