diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..ef4ea1256 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -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"; diff --git a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts new file mode 100644 index 000000000..9a784c61e --- /dev/null +++ b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts @@ -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; + showOpenDialog: (options: { + title: string; + properties: string[]; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; +}; + +type ElectronLike = { + dialog?: ElectronDialog; + remote?: { + dialog?: ElectronDialog; + }; +}; + +type FsPromisesLike = { + readFile: (path: string, encoding: string) => Promise; + writeFile: (path: string, data: string, encoding: string) => Promise; +}; + +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 => { + 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 }; +}; diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts new file mode 100644 index 000000000..09f96f7e7 --- /dev/null +++ b/apps/obsidian/src/utils/specValidation.ts @@ -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 = 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 = 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 = 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 = z + .object({ name: z.string(), content: z.string() }) + .passthrough(); + +export const dgSchemaFileSchema: z.ZodType = 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); +};