Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/obsidian/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,20 @@ export type ImportFolderMetadata = {
userName?: string;
};

export type DiscourseSchemaTemplate = {
name: string;
content: string;
};

export type DiscourseSchemaFile = {
version: number;
exportedAt: string;
pluginVersion: string;
vaultName: string;
nodeTypes: DiscourseNode[];
relationTypes: DiscourseRelationType[];
discourseRelations: DiscourseRelation[];
templates: DiscourseSchemaTemplate[];
};

export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view";
83 changes: 83 additions & 0 deletions apps/obsidian/src/utils/specValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { z } from "zod";
import type { DiscourseSchemaFile } from "~/types";

export const DG_SCHEMA_EXPORT_VERSION = 1;

const discourseNodeSchema = z.object({
id: z.string(),
name: z.string(),
format: z.string(),
template: z.string().optional(),
description: z.string().optional(),
shortcut: z.string().optional(),
color: z.string().optional(),
tag: z.string().optional(),
keyImage: z.boolean().optional(),
folderPath: z.string().optional(),
created: z.number(),
modified: z.number(),
importedFromRid: z.string().optional(),
authorId: z.number().optional(),
});

const relationImportStatusSchema = z.enum(["provisional", "accepted"]);

const discourseRelationTypeSchema = z.object({
id: z.string(),
label: z.string(),
complement: z.string(),
color: z.string(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Schema validation accepts any color string but downstream types require one of 13 specific color names

The relation-type color is validated as any string (z.string() at apps/obsidian/src/utils/specValidation.ts:29), then force-cast to a type that only allows 13 specific color names, so an imported file with a bogus color like "rainbow" passes validation and silently violates the type contract.

Impact: Imported schema files with invalid color names pass validation, potentially causing unexpected rendering in the canvas UI.

Type mismatch between Zod schema and DiscourseRelationType.color

The DiscourseRelationType type at apps/obsidian/src/types.ts:27 defines color: TldrawColorName, which is a union of 13 specific string literals defined at apps/obsidian/src/utils/tldrawColors.ts:5-19 (e.g. "black", "blue", "red", etc.).

However, the Zod schema at line 29 uses z.string() which accepts any string. The parseDgSchemaFile function at line 82 then casts the result as DiscourseSchemaFile, hiding this mismatch from the type system.

Downstream code like COLOR_PALETTE[relationType.color] at apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts:135 or direct prop assignment at apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx:371 relies on this being a valid TldrawColorName. While some call sites have fallbacks (e.g. toTldrawColor()), the validation layer should catch this at parse time rather than allowing invalid data through.

The fix would be to use z.enum(TLDRAW_COLOR_NAMES) instead of z.string() for the color field, matching the actual type constraint.

Suggested change
color: z.string(),
color: z.enum(TLDRAW_COLOR_NAMES),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

created: z.number(),
modified: z.number(),
importedFromRid: z.string().optional(),
status: relationImportStatusSchema.optional(),
authorId: z.number().optional(),
});

const discourseRelationSchema = z.object({
id: z.string(),
sourceId: z.string(),
destinationId: z.string(),
relationshipTypeId: z.string(),
created: z.number(),
modified: z.number(),
importedFromRid: z.string().optional(),
status: relationImportStatusSchema.optional(),
authorId: z.number().optional(),
});

const templateExportSchema = z.object({
name: z.string(),
content: z.string(),
});

export const dgSchemaFileSchema = z.object({
version: z.literal(DG_SCHEMA_EXPORT_VERSION),
exportedAt: z.string(),
pluginVersion: z.string(),
vaultName: z.string(),
nodeTypes: z.array(discourseNodeSchema),
relationTypes: z.array(discourseRelationTypeSchema),
discourseRelations: z.array(discourseRelationSchema),
templates: z.array(templateExportSchema),
});
Comment on lines +54 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Zod default behavior silently strips extra properties from imported schema files

The Zod schemas use z.object({...}) which by default strips unknown properties. This means if a future version of the schema adds new fields (e.g., a description on DiscourseRelationType), importing a file from that newer version will silently drop those fields without any warning. This is a design choice worth being aware of — if round-trip fidelity matters (export → share → import), consider using .passthrough() on the top-level schema, or at least documenting that extra fields are intentionally dropped.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const normalizeToKebabCase = (value: string): string => {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
};

export const getDgSchemaFileName = (vaultName?: string): string => {
const normalizedVaultName = vaultName ? normalizeToKebabCase(vaultName) : "";
const safeVaultName =
normalizedVaultName.length > 0 ? normalizedVaultName : "vault";
return `dg-schema-${safeVaultName}.json`;
};

export const parseDgSchemaFile = (value: unknown): DiscourseSchemaFile => {
return dgSchemaFileSchema.parse(value) as DiscourseSchemaFile;
};