diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index c1b2d7cc9..1556b3042 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -107,7 +107,6 @@ const DiscourseNodeConfigPanel: React.FC = ({ type: valueUid, shortcut, format, - backedBy: "user", }), ); setNodes([ diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index cdf79a92e..477e7821a 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -10,15 +10,19 @@ import { getSubTree } from "roamjs-components/util"; import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; import internalError from "~/utils/internalError"; import { getSetting } from "~/utils/extensionSettings"; -import discourseConfigRef, { - getFormattedConfigTree, -} from "~/utils/discourseConfigRef"; +import { USE_REIFIED_RELATIONS } from "~/data/userSettings"; +import discourseConfigRef from "~/utils/discourseConfigRef"; import { roamNodeToCondition } from "~/utils/parseQuery"; import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import type { Condition } from "~/utils/types"; import { z } from "zod"; -import { getUidAndBooleanSetting } from "~/utils/getExportSettings"; +import { + getExportSettingsAndUids, + getUidAndBooleanSetting, + getUidAndStringSetting, +} from "~/utils/getExportSettings"; +import { getSuggestiveModeConfigAndUids } from "~/utils/getSuggestiveModeConfigSettings"; import { getLeftSidebarSettings } from "~/utils/getLeftSidebarSettings"; import { @@ -36,7 +40,7 @@ import { type DiscourseNodeSettings, type Condition as SchemaCondition, } from "./zodSchema"; -import { PERSONAL_KEYS, QUERY_KEYS } from "./settingKeys"; +import { PERSONAL_KEYS, QUERY_KEYS, GLOBAL_KEYS } from "./settingKeys"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -342,35 +346,39 @@ const getLegacyRelationsSetting = (): Record => { ); }; -// Reconstructs global settings from getFormattedConfigTree() shape to match block-props schema shape +// Reconstructs global settings from legacy Roam tree to match block-props schema shape const getLegacyGlobalSetting = (keys: string[]): unknown => { if (keys.length === 0) return undefined; - const settings = getFormattedConfigTree(); + const tree = discourseConfigRef.tree; const firstKey = keys[0]; if (firstKey === "Trigger") { - return settings.trigger.value || DEFAULT_GLOBAL_SETTINGS.Trigger; + return ( + getUidAndStringSetting({ tree, text: "trigger" }).value || + DEFAULT_GLOBAL_SETTINGS.Trigger + ); } if (firstKey === "Canvas page format") { return ( - settings.canvasPageFormat.value || + getUidAndStringSetting({ tree, text: "Canvas Page Format" }).value || DEFAULT_GLOBAL_SETTINGS["Canvas page format"] ); } if (firstKey === "Left sidebar") { + const sidebar = getLeftSidebarSettings(tree); const leftSidebarSettings: Record = {}; - leftSidebarSettings["Children"] = settings.leftSidebar.global.children.map( + leftSidebarSettings["Children"] = sidebar.global.children.map( (c) => c.text, ); const sidebarSettingValues: Record = {}; sidebarSettingValues["Collapsable"] = - settings.leftSidebar.global.settings?.collapsable.value ?? + sidebar.global.settings?.collapsable.value ?? DEFAULT_GLOBAL_SETTINGS["Left sidebar"].Settings.Collapsable; sidebarSettingValues["Folded"] = - settings.leftSidebar.global.settings?.folded.value ?? + sidebar.global.settings?.folded.value ?? DEFAULT_GLOBAL_SETTINGS["Left sidebar"].Settings.Folded; leftSidebarSettings["Settings"] = sidebarSettingValues; if (keys.length === 1) return leftSidebarSettings; @@ -378,49 +386,50 @@ const getLegacyGlobalSetting = (keys: string[]): unknown => { } if (firstKey === "Export") { + const exp = getExportSettingsAndUids(); const exportSettings: Record = {}; exportSettings["Remove special characters"] = - settings.export.removeSpecialCharacters.value ?? + exp.removeSpecialCharacters.value ?? DEFAULT_GLOBAL_SETTINGS.Export["Remove special characters"]; exportSettings["Resolve block references"] = - settings.export.optsRefs.value ?? + exp.optsRefs.value ?? DEFAULT_GLOBAL_SETTINGS.Export["Resolve block references"]; exportSettings["Resolve block embeds"] = - settings.export.optsEmbeds.value ?? + exp.optsEmbeds.value ?? DEFAULT_GLOBAL_SETTINGS.Export["Resolve block embeds"]; exportSettings["Append referenced node"] = - settings.export.appendRefNodeContext.value ?? + exp.appendRefNodeContext.value ?? DEFAULT_GLOBAL_SETTINGS.Export["Append referenced node"]; exportSettings["Link type"] = - settings.export.linkType.value || - DEFAULT_GLOBAL_SETTINGS.Export["Link type"]; + exp.linkType.value || DEFAULT_GLOBAL_SETTINGS.Export["Link type"]; exportSettings["Max filename length"] = - settings.export.maxFilenameLength.value ?? + exp.maxFilenameLength.value ?? DEFAULT_GLOBAL_SETTINGS.Export["Max filename length"]; exportSettings["Frontmatter"] = - settings.export.frontmatter.values ?? - DEFAULT_GLOBAL_SETTINGS.Export.Frontmatter; + exp.frontmatter.values ?? DEFAULT_GLOBAL_SETTINGS.Export.Frontmatter; if (keys.length === 1) return exportSettings; return readPathValue(exportSettings, keys.slice(1)); } if (firstKey === "Suggestive mode") { + const sm = getSuggestiveModeConfigAndUids(tree); const suggestiveModeSettings: Record = {}; suggestiveModeSettings["Include current page relations"] = - settings.suggestiveMode.includePageRelations.value ?? + sm.includePageRelations.value ?? DEFAULT_GLOBAL_SETTINGS["Suggestive mode"][ "Include current page relations" ]; suggestiveModeSettings["Include parent and child blocks"] = - settings.suggestiveMode.includeParentAndChildren.value ?? + sm.includeParentAndChildren.value ?? DEFAULT_GLOBAL_SETTINGS["Suggestive mode"][ "Include parent and child blocks" ]; - suggestiveModeSettings["Page groups"] = - settings.suggestiveMode.pageGroups.groups.map((group) => ({ + suggestiveModeSettings["Page groups"] = sm.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)); } @@ -728,6 +737,44 @@ export const isNewSettingsStoreEnabled = (): boolean => { return getFeatureFlag("Use new settings store"); }; +export const readAllLegacyFeatureFlags = (): Partial => { + const flags: Partial = {}; + for (const [key, reader] of Object.entries(FEATURE_FLAG_LEGACY_MAP)) { + flags[key as keyof FeatureFlags] = reader(); + } + flags["Reified relation triples"] = getSetting( + USE_REIFIED_RELATIONS, + false, + ); + flags["Use new settings store"] = false; + return flags; +}; + +export const readAllLegacyGlobalSettings = (): Record => { + const result: Record = {}; + for (const key of Object.values(GLOBAL_KEYS)) { + result[key] = getLegacyGlobalSetting([key]); + } + return result; +}; + +export const readAllLegacyPersonalSettings = (): Record => { + const result: Record = {}; + for (const key of Object.values(PERSONAL_KEYS)) { + result[key] = getLegacyPersonalSetting([key]); + } + return result; +}; + +export const readAllLegacyDiscourseNodeSettings = ( + nodeType: string, + nodeTitle: string, +): Record | undefined => { + const raw = getLegacyDiscourseNodeSetting(nodeType, []); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return { ...(raw as Record), text: nodeTitle }; +}; + export const setFeatureFlag = ( key: keyof FeatureFlags, value: boolean, diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index 90536774f..86e19ed82 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -7,6 +7,10 @@ import type { json } from "~/utils/getBlockProps"; import INITIAL_NODE_VALUES from "~/data/defaultDiscourseNodes"; import DEFAULT_RELATIONS_BLOCK_PROPS from "~/components/settings/data/defaultRelationsBlockProps"; import { getAllDiscourseNodes } from "./accessors"; +import { + migrateGraphLevel, + migratePersonalSettings, +} from "./migrateLegacyToBlockProps"; import { DiscourseNodeSchema, getTopLevelBlockPropsConfig, @@ -147,7 +151,6 @@ const initSingleDiscourseNode = async ( tag: node.tag || "", graphOverview: node.graphOverview ?? false, canvasSettings: node.canvasSettings || {}, - backedBy: "user", }); setBlockProps(pageUid, nodeData, false); @@ -256,6 +259,8 @@ export type InitSchemaResult = { export const initSchema = async (): Promise => { const blockUids = await initSettingsPageBlocks(); + await migrateGraphLevel(blockUids); const nodePageUids = await initDiscourseNodePages(); + await migratePersonalSettings(blockUids); return { blockUids, nodePageUids }; }; diff --git a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts new file mode 100644 index 000000000..8ca1fe496 --- /dev/null +++ b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts @@ -0,0 +1,318 @@ +import getBlockProps from "~/utils/getBlockProps"; +import type { json } from "~/utils/getBlockProps"; +import setBlockProps from "~/utils/setBlockProps"; +import getBlockUidByTextOnPage from "roamjs-components/queries/getBlockUidByTextOnPage"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import { createBlock } from "roamjs-components/writes"; +import { getSetting, setSetting } from "~/utils/extensionSettings"; +import internalError from "~/utils/internalError"; +import { + readAllLegacyFeatureFlags, + readAllLegacyGlobalSettings, + readAllLegacyPersonalSettings, + readAllLegacyDiscourseNodeSettings, +} from "./accessors"; +import { + FeatureFlagsSchema, + GlobalSettingsSchema, + PersonalSettingsSchema, + DiscourseNodeSchema, + DG_BLOCK_PROP_SETTINGS_PAGE_TITLE, + DISCOURSE_NODE_PAGE_PREFIX, + TOP_LEVEL_BLOCK_PROP_KEYS, + getPersonalSettingsKey, +} from "./zodSchema"; +import type { z } from "zod"; + +const LOG_PREFIX = "[DG BlockProps Migration]"; +const GRAPH_MIGRATION_MARKER = "Block props migrated"; +const PERSONAL_MIGRATION_MARKER = "dg-personal-settings-migrated"; +const MAX_ERROR_CONTEXT_LENGTH = 5000; + +const hasGraphMigrationMarker = (): boolean => + !!getBlockUidByTextOnPage({ + text: GRAPH_MIGRATION_MARKER, + title: DG_BLOCK_PROP_SETTINGS_PAGE_TITLE, + }); + +const isPropsValid = ( + schema: z.ZodTypeAny, + props: Record | null, +): boolean => + !!props && Object.keys(props).length > 0 && schema.safeParse(props).success; + +const serializeErrorContext = (value: unknown): string => { + try { + return JSON.stringify(value).slice(0, MAX_ERROR_CONTEXT_LENGTH); + } catch { + return String(value); + } +}; + +const shouldWrite = ( + schema: z.ZodTypeAny, + currentProps: Record | null, + parsedLegacy: Record, +): boolean => { + if (!isPropsValid(schema, currentProps)) { + return true; + } + + return JSON.stringify(parsedLegacy) !== JSON.stringify(currentProps); +}; + +const migrateSection = ({ + label, + blockUid, + schema, + legacyData, +}: { + label: string; + blockUid: string; + schema: z.ZodTypeAny; + legacyData: Record; +}): boolean => { + const currentProps = getBlockProps(blockUid); + + const parseResult = schema.safeParse(legacyData); + if (!parseResult.success) { + if (isPropsValid(schema, currentProps)) { + console.log( + `${LOG_PREFIX} ${label}: legacy malformed but props already valid, skipping`, + ); + return true; + } + console.warn(`${LOG_PREFIX} ${label}: Zod validation failed, skipping`, { + error: parseResult.error.message, + }); + internalError({ + error: parseResult.error, + type: "DG Block Props Migration", + context: { + label, + blockUid, + legacyData: serializeErrorContext(legacyData), + currentProps: serializeErrorContext(currentProps), + }, + sendEmail: false, + }); + return false; + } + + const parsedLegacy = parseResult.data as Record; + if (!shouldWrite(schema, currentProps, parsedLegacy)) { + console.log(`${LOG_PREFIX} ${label}: props already non-default, skipping`); + return true; + } + + setBlockProps(blockUid, parsedLegacy, false); + console.log(`${LOG_PREFIX} ${label}: migrated`); + return true; +}; + +const migrateDiscourseNodes = async (): Promise => { + const nodePages = (await window.roamAlphaAPI.data.async.fast.q(` + [:find ?uid ?title + :where + [?page :node/title ?title] + [?page :block/uid ?uid] + [(clojure.string/starts-with? ?title "${DISCOURSE_NODE_PAGE_PREFIX}")]] + `)) as [string, string][]; + + let allOk = true; + + for (const [nodePageUid, title] of nodePages) { + if (typeof nodePageUid !== "string" || typeof title !== "string") continue; + + const nodeText = title.replace(DISCOURSE_NODE_PAGE_PREFIX, ""); + const legacyData = readAllLegacyDiscourseNodeSettings( + nodePageUid, + nodeText, + ); + if (!legacyData) { + if (isPropsValid(DiscourseNodeSchema, getBlockProps(nodePageUid))) { + console.log( + `${LOG_PREFIX} Discourse Node (${nodeText}): legacy unreadable but props already valid, skipping`, + ); + continue; + } + console.warn( + `${LOG_PREFIX} Discourse Node (${nodeText}): legacy data unreadable`, + ); + internalError({ + error: `Legacy discourse node data unreadable: ${nodeText}`, + type: "DG Block Props Migration", + context: { + label: `Discourse Node (${nodeText})`, + blockUid: nodePageUid, + currentProps: serializeErrorContext(getBlockProps(nodePageUid)), + }, + sendEmail: false, + }); + allOk = false; + continue; + } + + if ( + !migrateSection({ + label: `Discourse Node (${nodeText})`, + blockUid: nodePageUid, + schema: DiscourseNodeSchema, + legacyData, + }) + ) { + allOk = false; + } + } + + return allOk; +}; + +export const migrateGraphLevel = async ( + blockUids: Record, +): Promise => { + const pageUid = getPageUidByPageTitle(DG_BLOCK_PROP_SETTINGS_PAGE_TITLE); + if (!pageUid) { + internalError({ + error: `Settings page not found: ${DG_BLOCK_PROP_SETTINGS_PAGE_TITLE}`, + type: "DG Block Props Migration", + context: { scope: "graph" }, + sendEmail: false, + }); + return; + } + + if (hasGraphMigrationMarker()) { + console.log(`${LOG_PREFIX} graph-level: skipped (already migrated)`); + return; + } + + let failures = 0; + + const featureFlagUid = blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.featureFlags]; + if (!featureFlagUid) { + internalError({ + error: `Missing block uid for ${TOP_LEVEL_BLOCK_PROP_KEYS.featureFlags}`, + type: "DG Block Props Migration", + context: { + scope: "graph", + blockUids: serializeErrorContext(blockUids), + }, + sendEmail: false, + }); + failures++; + } else { + const legacyFlags = readAllLegacyFeatureFlags(); + if ( + !migrateSection({ + label: "Feature Flags", + blockUid: featureFlagUid, + schema: FeatureFlagsSchema, + legacyData: legacyFlags as Record, + }) + ) { + failures++; + } + } + + const globalUid = blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.global]; + if (!globalUid) { + internalError({ + error: `Missing block uid for ${TOP_LEVEL_BLOCK_PROP_KEYS.global}`, + type: "DG Block Props Migration", + context: { + scope: "graph", + blockUids: serializeErrorContext(blockUids), + }, + sendEmail: false, + }); + failures++; + } else { + const legacyGlobal = readAllLegacyGlobalSettings(); + if ( + !migrateSection({ + label: "Global", + blockUid: globalUid, + schema: GlobalSettingsSchema, + legacyData: legacyGlobal, + }) + ) { + failures++; + } + } + + if (!(await migrateDiscourseNodes())) { + failures++; + } + + if (failures === 0) { + try { + await createBlock({ + parentUid: pageUid, + node: { text: GRAPH_MIGRATION_MARKER }, + }); + console.log(`${LOG_PREFIX} graph-level: completed`); + } catch (e) { + console.warn( + `${LOG_PREFIX} graph-level: data migrated but marker write failed (will retry next load)`, + e, + ); + } + } else { + console.warn( + `${LOG_PREFIX} graph-level: ${failures} section(s) failed, marker not created (will retry next load)`, + ); + } +}; + +export const migratePersonalSettings = async ( + blockUids: Record, +): Promise => { + if (getSetting(PERSONAL_MIGRATION_MARKER, false)) { + console.log(`${LOG_PREFIX} personal: skipped (already migrated)`); + return; + } + + const personalKey = getPersonalSettingsKey(); + const personalUid = blockUids[personalKey]; + if (!personalUid) { + console.warn( + `${LOG_PREFIX} personal: block not found for key "${personalKey}", skipping`, + ); + internalError({ + error: `Missing personal settings block for key "${personalKey}"`, + type: "DG Block Props Migration", + context: { + scope: "personal", + personalKey, + blockUids: serializeErrorContext(blockUids), + }, + sendEmail: false, + }); + return; + } + + const legacyPersonal = readAllLegacyPersonalSettings(); + const ok = migrateSection({ + label: "Personal", + blockUid: personalUid, + schema: PersonalSettingsSchema, + legacyData: legacyPersonal, + }); + + if (ok) { + try { + await setSetting(PERSONAL_MIGRATION_MARKER, true); + console.log(`${LOG_PREFIX} personal: completed`); + } catch (e) { + console.warn( + `${LOG_PREFIX} personal: data migrated but marker write failed (will retry next load)`, + e, + ); + } + } else { + console.warn( + `${LOG_PREFIX} personal: failed, marker not created (will retry next load)`, + ); + } +}; diff --git a/apps/roam/src/components/settings/utils/zodSchema.example.ts b/apps/roam/src/components/settings/utils/zodSchema.example.ts index 7a9041666..861e25508 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.example.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.example.ts @@ -83,7 +83,6 @@ const discourseNodeSettings: DiscourseNodeSettings = { returnNode: "node", }, suggestiveRules, - backedBy: "user", }; const featureFlags: FeatureFlags = { diff --git a/apps/roam/src/components/settings/utils/zodSchema.ts b/apps/roam/src/components/settings/utils/zodSchema.ts index 4df25e6c7..dcae8449f 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.ts @@ -140,10 +140,6 @@ export const DiscourseNodeSchema = z.object({ .optional() .transform((val) => val ?? defaultNodeIndex()), suggestiveRules: SuggestiveRulesSchema.default({}), - backedBy: z - .enum(["user", "default", "relation"]) - .nullable() - .transform((val) => val ?? "user"), }); export const TripleSchema = z.tuple([z.string(), z.string(), z.string()]);