Skip to content
Open
25 changes: 25 additions & 0 deletions apps/obsidian/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,29 @@ export type ImportFolderMetadata = {
userName?: string;
};

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

export type DiscourseSchemaFile = {
version: number;
exportedAt: string;
pluginVersion: string;
vaultName: string;
/** Obsidian appId of the exporting vault; lets importers rebuild the source RID. */
vaultId: string;
nodeTypes: DiscourseNode[];
relationTypes: DiscourseRelationType[];
discourseRelations: DiscourseRelation[];
templates: DiscourseSchemaTemplate[];
};

export type SchemaSelection = {
nodeTypeIds: string[];
relationTypeIds: string[];
discourseRelationIds: string[];
templateNames: string[];
};

export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view";
121 changes: 121 additions & 0 deletions apps/obsidian/src/utils/nativeJsonFileDialogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
type SaveDialogResult = {
canceled: boolean;
filePath?: string;
};

type OpenDialogResult = {
canceled: boolean;
filePaths: string[];
};

type ElectronDialog = {
showSaveDialog: (options: {
title: string;
defaultPath: string;
filters: Array<{ name: string; extensions: string[] }>;
}) => Promise<SaveDialogResult>;
showOpenDialog: (options: {
title: string;
properties: string[];
filters: Array<{ name: string; extensions: string[] }>;
}) => Promise<OpenDialogResult>;
};

type ElectronLike = {
dialog?: ElectronDialog;
remote?: {
dialog?: ElectronDialog;
};
};

type FsPromisesLike = {
readFile: (path: string, encoding: string) => Promise<string>;
writeFile: (path: string, data: string, encoding: string) => Promise<void>;
};

type ElectronWindow = Window & {
require: (name: string) => unknown;
};

export class NativeFileDialogCancelledError extends Error {
constructor() {
super("File dialog cancelled");
this.name = "NativeFileDialogCancelledError";
}
}

const getElectronWindow = (): ElectronWindow => {
if (typeof window === "undefined" || !("require" in window)) {
throw new Error(
"Schema export/import requires Obsidian desktop (Electron).",
);
}
return window as ElectronWindow;
};

const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => {
const fsPromises = electronWindow.require("fs/promises");
if (
typeof fsPromises !== "object" ||
fsPromises === null ||
!("readFile" in fsPromises) ||
!("writeFile" in fsPromises)
) {
throw new Error("Unable to access filesystem read/write APIs.");
}
return fsPromises as FsPromisesLike;
};

const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => {
const electron = electronWindow.require("electron") as ElectronLike;
const dialog = electron.dialog ?? electron.remote?.dialog;
if (!dialog?.showSaveDialog || !dialog.showOpenDialog) {
throw new Error("Unable to access Electron file dialogs.");
}
return dialog;
};

export const saveJsonToUserLocation = async ({
title,
fileName,
content,
}: {
title: string;
fileName: string;
content: string;
}): Promise<string> => {
const electronWindow = getElectronWindow();
const dialog = getElectronDialog(electronWindow);
const result = await dialog.showSaveDialog({
title,
defaultPath: fileName,
filters: [{ name: "JSON files", extensions: ["json"] }],
});
if (result.canceled || !result.filePath) {
throw new NativeFileDialogCancelledError();
}
const fsPromises = getFsPromises(electronWindow);
await fsPromises.writeFile(result.filePath, content, "utf8");
return result.filePath;
};

export const openJsonFromUserLocation = async ({
title,
}: {
title: string;
}): Promise<{ content: string; sourcePath: string }> => {
const electronWindow = getElectronWindow();
const dialog = getElectronDialog(electronWindow);
const result = await dialog.showOpenDialog({
title,
properties: ["openFile"],
filters: [{ name: "JSON files", extensions: ["json"] }],
});
if (result.canceled || !result.filePaths[0]) {
throw new NativeFileDialogCancelledError();
}
const fsPromises = getFsPromises(electronWindow);
const sourcePath = result.filePaths[0];
const content = await fsPromises.readFile(sourcePath, "utf8");
return { content, sourcePath };
};
98 changes: 98 additions & 0 deletions apps/obsidian/src/utils/specValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { z } from "zod";
import type {
DiscourseNode,
DiscourseRelation,
DiscourseRelationType,
DiscourseSchemaFile,
DiscourseSchemaTemplate,
} from "~/types";
import { TLDRAW_COLOR_NAMES } from "~/utils/tldrawColors";

export const DG_SCHEMA_EXPORT_VERSION = 1;

const discourseNodeSchema: z.ZodType<DiscourseNode> = 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(),
})
.passthrough();

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

const discourseRelationTypeSchema: z.ZodType<DiscourseRelationType> = z
.object({
id: z.string(),
label: z.string(),
complement: z.string(),
color: z.enum(TLDRAW_COLOR_NAMES),
created: z.number(),
modified: z.number(),
importedFromRid: z.string().optional(),
status: relationImportStatusSchema.optional(),
authorId: z.number().optional(),
})
.passthrough();

const discourseRelationSchema: z.ZodType<DiscourseRelation> = 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(),
})
.passthrough();

const templateExportSchema: z.ZodType<DiscourseSchemaTemplate> = z
.object({ name: z.string(), content: z.string() })
.passthrough();

export const dgSchemaFileSchema: z.ZodType<DiscourseSchemaFile> = z
.object({
version: z.literal(DG_SCHEMA_EXPORT_VERSION),
exportedAt: z.string(),
pluginVersion: z.string(),
vaultName: z.string(),
vaultId: z.string(),
nodeTypes: z.array(discourseNodeSchema),
relationTypes: z.array(discourseRelationTypeSchema),
discourseRelations: z.array(discourseRelationSchema),
templates: z.array(templateExportSchema),
})
.passthrough();

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);
};