diff --git a/src/main/app.ts b/src/main/app.ts index 95474936..550d4335 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -37,19 +37,21 @@ export function appIpcs( p.join(config.content.dataDirectory, APP_STATE_PATH), APP_STATE_SCHEMAS, { - version: 1, - sidebar: { - scroll: 0, - sort: NoteSort.Alphanumeric, - width: DEFAULT_SIDEBAR_WIDTH, + defaultContent: { + version: 1, + sidebar: { + scroll: 0, + sort: NoteSort.Alphanumeric, + width: DEFAULT_SIDEBAR_WIDTH, + }, + editor: { + isEditing: false, + scroll: 0, + tabs: [], + tabsScroll: 0, + }, + focused: [], }, - editor: { - isEditing: false, - scroll: 0, - tabs: [], - tabsScroll: 0, - }, - focused: [], } ); }); diff --git a/src/main/config.ts b/src/main/config.ts index 81fd9b90..66e78adb 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -56,10 +56,12 @@ export async function getConfig(): Promise> { getConfigPath(), CONFIG_SCHEMAS, { - version: 2, - windowHeight: DEFAULT_WINDOW_HEIGHT, - windowWidth: DEFAULT_WINDOW_WIDTH, - logDirectory: app.getPath("logs"), + defaultContent: { + version: 2, + windowHeight: DEFAULT_WINDOW_HEIGHT, + windowWidth: DEFAULT_WINDOW_WIDTH, + logDirectory: app.getPath("logs"), + }, } ); diff --git a/src/main/json.ts b/src/main/json.ts index 3bac946c..18fbfa9c 100644 --- a/src/main/json.ts +++ b/src/main/json.ts @@ -1,66 +1,101 @@ -import { cloneDeep, last } from "lodash"; +import { cloneDeep, isEqual, last } from "lodash"; import * as fsp from "fs/promises"; import * as fs from "fs"; import { ZodSchema } from "zod"; import { DeepPartial } from "tsdef"; import { deepUpdate } from "../shared/deepUpdate"; -export interface JsonFile { - content: Readonly; - update(partial: DeepPartial): Promise; +export interface Versioned { + version: number; } -export async function loadJsonFile( +export interface JsonOptions { + defaultContent?: T; +} + +export async function writeJson( filePath: string, schemas: Record, - defaultContent: Content, - opts: { prettyPrint?: boolean } = { prettyPrint: true } -): Promise> { + content: Content +): Promise { + const sortedSchemas = sortSchemas(schemas); + const [version, schema] = last(sortedSchemas)!; + + if (content.version == null) { + content.version = version; + } + + await schema.parseAsync(content); + + const serialized = JSON.stringify(content, null, 2); + await fsp.writeFile(filePath, serialized, { encoding: "utf-8" }); +} + +export async function loadJson( + filePath: string, + schemas: Record, + opts?: JsonOptions +): Promise { let originalContent: Content | undefined; if (fs.existsSync(filePath)) { const raw = await fsp.readFile(filePath, { encoding: "utf-8" }); originalContent = JSON.parse(raw); } - // Apply default content if no content found, or if it had no versioning. + if (originalContent == null) { + originalContent = opts?.defaultContent; + + if (originalContent == null) { + throw new Error( + `No json was found for ${filePath} and no default was provided.` + ); + } + } + if ( - originalContent == null || !originalContent.hasOwnProperty("version") || typeof originalContent.version !== "number" ) { - originalContent = defaultContent; + throw new Error(`No version found in json for ${filePath}`); } - const { content, latestSchema, wasUpdated } = await runSchemas( - schemas, - originalContent - ); + const { content, wasUpdated } = await runSchemas(schemas, originalContent); // Save changes to file if any were made while migrating to latest. if (wasUpdated) { - let jsonContent; - if (opts.prettyPrint) { - jsonContent = JSON.stringify(content, null, 2); - } else { - jsonContent = JSON.stringify(content); - } - + const jsonContent = JSON.stringify(content, null, 2); await fsp.writeFile(filePath, jsonContent, { encoding: "utf-8" }); } + return content; +} + +export interface JsonFile { + content: Readonly; + update(partial: DeepPartial): Promise; +} + +export async function loadJsonFile( + filePath: string, + schemas: Record, + opts?: JsonOptions +): Promise> { + const content = await loadJson(filePath, schemas, opts); + const latestSchema = schemas[content.version]; + const update = async (partial: DeepPartial) => { const updated = deepUpdate(content, partial); // Validate against latest schema when saving to ensure we have valid content. const validated = await latestSchema.parseAsync(updated); - let jsonString; - if (opts.prettyPrint) { - jsonString = JSON.stringify(updated, null, 2); - } else { - jsonString = JSON.stringify(updated); + // Don't write to file if no changes were made. + if (isEqual(content, validated)) { + return; } + const jsonString = JSON.stringify(updated, null, 2); + fileHandler.content = validated; await fsp.writeFile(filePath, jsonString, { encoding: "utf-8" }); }; @@ -72,17 +107,11 @@ export async function loadJsonFile( return fileHandler; } -export async function runSchemas( +export async function runSchemas( schemas: Record, content: Content ): Promise<{ content: Content; latestSchema: ZodSchema; wasUpdated: boolean }> { - const schemaArray = Object.entries(schemas) - .map<[number, ZodSchema]>(([version, schema]) => [ - Number.parseInt(version, 10), - schema, - ]) - .sort(([a], [b]) => (a > b ? 1 : -1)); - + const schemaArray = sortSchemas(schemas); if (schemaArray.length === 0) { throw new Error(`Expected at least 1 schema in order to validate content.`); } @@ -110,3 +139,14 @@ export async function runSchemas( wasUpdated: validatedContent.version > content.version, }; } + +export function sortSchemas( + schemas: Record +): [number, ZodSchema][] { + return Object.entries(schemas) + .map<[number, ZodSchema]>(([version, schema]) => [ + Number.parseInt(version, 10), + schema, + ]) + .sort(([a], [b]) => (a > b ? 1 : -1)); +} diff --git a/src/main/notes.ts b/src/main/notes.ts index 0a0d73e7..55a40a8c 100644 --- a/src/main/notes.ts +++ b/src/main/notes.ts @@ -1,20 +1,15 @@ -import { - createNote, - getNoteById, - Note, - NOTE_SCHEMA, -} from "../shared/domain/note"; +import { createNote, getNoteById, Note } from "../shared/domain/note"; import { UUID_REGEX } from "../shared/domain"; import { Config } from "../shared/domain/config"; -import { keyBy, partition } from "lodash"; +import { keyBy, omit, partition } from "lodash"; import { shell } from "electron"; -import { parseJSON } from "date-fns"; import { IpcMainTS } from "../shared/ipc"; import * as fs from "fs"; import * as fsp from "fs/promises"; -import { JsonFile } from "./json"; +import { JsonFile, loadJson, writeJson } from "./json"; import * as p from "path"; import { Logger } from "../shared/logger"; +import { NOTE_SCHEMAS } from "./schemas/notes"; export const NOTES_DIRECTORY = "notes"; export const METADATA_FILE_NAME = "metadata.json"; @@ -23,74 +18,122 @@ export const MARKDOWN_FILE_NAME = "index.md"; export function noteIpcs( ipc: IpcMainTS, config: JsonFile, - log: Logger + log: Logger, + // Only use this for testing. + _notes: Note[] = [] ): void { - let initialized = false; - let notes: Note[] = []; - const dataDirectory = config.content.dataDirectory!; + const { dataDirectory } = config.content; + if (dataDirectory == null) { + return; + } + + let notes: Note[] = _notes; ipc.on("init", async () => { - const noteDirPath = p.join(dataDirectory, NOTES_DIRECTORY); - if (!fs.existsSync(noteDirPath)) { - await fsp.mkdir(noteDirPath); + const noteDirectory = p.join(dataDirectory, NOTES_DIRECTORY); + if (!fs.existsSync(noteDirectory)) { + await fsp.mkdir(noteDirectory); } - }); - async function getNotes(): Promise { - if (initialized) { - return; + const entries = await fsp.readdir(noteDirectory, { withFileTypes: true }); + const everyNote: Note[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || !UUID_REGEX.test(entry.name)) { + continue; + } + + const path = p.join(noteDirectory, entry.name, METADATA_FILE_NAME); + const json = await loadJson(path, NOTE_SCHEMAS); + + // The order notes are loaded in is not guaranteed so we store them in a flat + // array until we've loaded every last one before we start to rebuild their + // family trees. + everyNote.push(json); } - notes = await loadNotes(dataDirectory); - initialized = true; - } + const lookup = keyBy(everyNote, "id"); + const [roots, children] = partition(everyNote, (n) => n.parent == null); + for (const child of children) { + const parent = lookup[child.parent!]; + if (parent == null) { + log.warn( + `WARNING: Note ${child.id} is an orphan. No parent ${child.parent} found. Did you mean to delete it?` + ); + + continue; + } + + parent.children ??= []; + parent.children.push(child); + } + + notes = roots; + }); ipc.handle("notes.getAll", async () => { - await getNotes(); return notes; }); - ipc.handle("notes.create", async (_, name, parent) => { - await getNotes(); - + ipc.handle("notes.create", async (_, name, parentId) => { const note = createNote({ name, - parent, + parent: parentId, }); - await saveToFileSystem(dataDirectory, note); + const dirPath = p.join(dataDirectory, NOTES_DIRECTORY, note.id); + if (!fs.existsSync(dirPath)) { + await fsp.mkdir(dirPath); + } - // TODO: Clean this up when we refactor to a repo. + const metadataPath = p.join( + dataDirectory, + NOTES_DIRECTORY, + note.id, + METADATA_FILE_NAME + ); + const markdownPath = p.join( + dataDirectory, + NOTES_DIRECTORY, + note.id, + MARKDOWN_FILE_NAME + ); + + await writeJson(metadataPath, NOTE_SCHEMAS, note); + + const s = await fsp.open(markdownPath, "w"); + await s.close(); + + // TODO: Clean this up when we refactor // Bust the cache - notes = []; - initialized = false; - await getNotes(); + if (parentId == null) { + notes.push(note); + } else { + const parentNote = getNoteById(notes, parentId); + parentNote.children ??= []; + parentNote.children.push(parentNote); + } return note; }); ipc.handle("notes.updateMetadata", async (_, id, props) => { - await getNotes(); - await assertNoteExists(dataDirectory, id); - - const metadataPath = buildNotePath(dataDirectory, id, "metadata"); - const rawContent = await fsp.readFile(metadataPath, { encoding: "utf-8" }); - const meta = JSON.parse(rawContent); + const note = getNoteById(notes, id); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { name, parent, sort, ...others } = props; if (name != null) { - meta.name = name; + note.name = name; } // Allow unsetting parent if ("parent" in props) { - meta.parent = parent; + note.parent = parent; } // Allow unsetting sort if ("sort" in props) { - meta.sort = props.sort; + note.sort = props.sort; } // Sanity check to ensure no extra props were passed @@ -100,54 +143,75 @@ export function noteIpcs( ); } - meta.dateUpdated = new Date(); - delete meta.children; + note.dateUpdated = new Date(); + const meta = omit(note, "children"); + const path = p.join(dataDirectory, NOTES_DIRECTORY, id, METADATA_FILE_NAME); + writeJson(path, NOTE_SCHEMAS, meta); - await saveToFileSystem(dataDirectory, meta); - return getNoteById(notes, meta.id); + return note; }); ipc.handle("notes.loadContent", async (_, id) => { - await getNotes(); - await assertNoteExists(dataDirectory, id); - - const content = await loadMarkdown(dataDirectory, id); - return content; + const markdownPath = p.join( + dataDirectory, + NOTES_DIRECTORY, + id, + MARKDOWN_FILE_NAME + ); + return (await fsp.readFile(markdownPath, { encoding: "utf-8" })) ?? ""; }); ipc.handle("notes.saveContent", async (_, id, content) => { - await getNotes(); - await assertNoteExists(dataDirectory, id); - await saveMarkdown(dataDirectory, id, content); + const markdownPath = p.join( + dataDirectory, + NOTES_DIRECTORY, + id, + MARKDOWN_FILE_NAME + ); + await fsp.writeFile(markdownPath, content, { encoding: "utf-8" }); + + const note = getNoteById(notes, id); + note.dateUpdated = new Date(); + const metaDataPath = p.join( + dataDirectory, + NOTES_DIRECTORY, + id, + METADATA_FILE_NAME + ); + writeJson(metaDataPath, NOTE_SCHEMAS, note); }); ipc.handle("notes.delete", async (_, id) => { - await getNotes(); const note = getNoteById(notes, id); const recursive = async (n: Note) => { - const notePath = buildNotePath(dataDirectory, n.id); - await fsp.rm(notePath, { recursive: true }); + const notePath = p.join(dataDirectory, NOTES_DIRECTORY, n.id); + // N.B. fsp.rm doesn't exist in 14.10 but typings include it. + await fsp.rmdir(notePath, { recursive: true }); for (const child of n.children ?? []) { await recursive(child); } }; - recursive(note); - - // TODO: Clean this up when we refactor to a repo. - // Bust the cache - notes = []; - initialized = false; - await getNotes(); + await recursive(note); + + if (note.parent == null) { + notes = notes.filter((n) => n.id !== note.id); + } else { + const parentNote = getNoteById(notes, note.parent); + if (parentNote != null && parentNote.children != null) { + parentNote.children = parentNote.children.filter( + (c) => c.id !== note.id + ); + } + } }); ipc.handle("notes.moveToTrash", async (_, id) => { - await getNotes(); const note = getNoteById(notes, id); const recursive = async (n: Note) => { - const notePath = buildNotePath(dataDirectory, n.id); + const notePath = p.join(dataDirectory, NOTES_DIRECTORY, n.id); await shell.trashItem(notePath); for (const child of n.children ?? []) { @@ -155,131 +219,17 @@ export function noteIpcs( } }; - recursive(note); - - // TODO: Clean this up when we refactor to a repo. - // Bust the cache - notes = []; - initialized = false; - await getNotes(); - }); -} - -export async function loadNotes(dataDirectory: string): Promise { - const noteDirPath = p.join(dataDirectory, NOTES_DIRECTORY); - if (!fs.existsSync(noteDirPath)) { - await fsp.mkdir(noteDirPath); - - // No directory means no notes to return... - return []; - } - - const entries = await fsp.readdir(noteDirPath, { withFileTypes: true }); - const notes: Note[] = []; + await recursive(note); - for (const entry of entries) { - if (!entry.isDirectory() || !UUID_REGEX.test(entry.name)) { - continue; - } - - const metadataPath = buildNotePath(dataDirectory, entry.name, "metadata"); - const rawContent = await fsp.readFile(metadataPath, { encoding: "utf-8" }); - const meta = JSON.parse(rawContent); - - const { dateCreated, dateUpdated, ...remainder } = meta; - - const note = createNote({ - dateCreated: parseJSON(dateCreated), - dateUpdated: dateUpdated != null ? parseJSON(dateUpdated) : undefined, - ...remainder, - }); - await NOTE_SCHEMA.parseAsync(note); - - // We don't add children until every note has been loaded because there's a - // chance children will be loaded before their parent. - notes.push(note); - } - - const lookup = keyBy(notes, "id"); - const [roots, children] = partition(notes, (n) => n.parent == null); - for (const child of children) { - const parent = lookup[child.parent!]; - if (parent == null) { - console.warn( - `WARNING: Note ${child.id} is an orphan. No parent ${child.parent} found. Did you mean to delete it?` - ); - - continue; + if (note.parent == null) { + notes = notes.filter((n) => n.id !== note.id); + } else { + const parentNote = getNoteById(notes, note.parent); + if (parentNote != null && parentNote.children != null) { + parentNote.children = parentNote.children.filter( + (c) => c.id !== note.id + ); + } } - - (parent.children ??= []).push(child); - } - - return roots; -} - -export async function assertNoteExists( - dataDirectory: string, - id: string -): Promise { - const fullPath = p.join(dataDirectory, NOTES_DIRECTORY, id); - if (!fs.existsSync(fullPath)) { - throw new Error(`Note ${id} was not found in the file system.`); - } -} - -export async function saveToFileSystem( - dataDirectory: string, - note: Note -): Promise { - const dirPath = p.join(dataDirectory, NOTES_DIRECTORY, note.id); - if (!fs.existsSync(dirPath)) { - await fsp.mkdir(dirPath); - } - - const metadataPath = buildNotePath(dataDirectory, note.id, "metadata"); - const markdownPath = buildNotePath(dataDirectory, note.id, "markdown"); - - await fsp.writeFile(metadataPath, JSON.stringify(note), { - encoding: "utf-8", }); - - const s = await fsp.open(markdownPath, "w"); - await s.close(); -} - -export async function loadMarkdown( - dataDirectory: string, - noteId: string -): Promise { - const markdownPath = buildNotePath(dataDirectory, noteId, "markdown"); - return (await fsp.readFile(markdownPath, { encoding: "utf-8" })) ?? ""; -} -export async function saveMarkdown( - dataDirectory: string, - noteId: string, - content: string -): Promise { - const markdownPath = buildNotePath(dataDirectory, noteId, "markdown"); - await fsp.writeFile(markdownPath, content, { encoding: "utf-8" }); -} - -export function buildNotePath( - dataDirectory: string, - noteId: string, - file?: "markdown" | "metadata" -): string { - if (file == null) { - return p.join(dataDirectory, NOTES_DIRECTORY, noteId); - } - - switch (file) { - case "markdown": - return p.join(dataDirectory, NOTES_DIRECTORY, noteId, MARKDOWN_FILE_NAME); - case "metadata": - return p.join(dataDirectory, NOTES_DIRECTORY, noteId, METADATA_FILE_NAME); - - default: - throw new Error(`Can't build path for ${file}`); - } } diff --git a/src/main/schemas/appState/1_initialDefinition.ts b/src/main/schemas/appState/1_initialDefinition.ts index e93e580c..1b6917fd 100644 --- a/src/main/schemas/appState/1_initialDefinition.ts +++ b/src/main/schemas/appState/1_initialDefinition.ts @@ -9,7 +9,7 @@ interface AppStateV1 { focused: Section[]; } -export interface Sidebar { +interface Sidebar { searchString?: string; hidden?: boolean; width: string; @@ -19,7 +19,7 @@ export interface Sidebar { sort: NoteSort; } -export interface Editor { +interface Editor { isEditing: boolean; scroll: number; tabs: EditorTab[]; @@ -27,7 +27,7 @@ export interface Editor { activeTabNoteId?: string; } -export interface EditorTab { +interface EditorTab { noteId: string; noteContent?: string; lastActive?: Date | string; diff --git a/src/main/schemas/notes/1_initialDefinition.ts b/src/main/schemas/notes/1_initialDefinition.ts new file mode 100644 index 00000000..57694b68 --- /dev/null +++ b/src/main/schemas/notes/1_initialDefinition.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { UUID_SCHEMA, DATE_OR_STRING_SCHEMA } from "../../../shared/domain"; +import { NoteSort } from "../../../shared/domain/note"; + +export interface NoteV1 { + id: string; + version: number; + name: string; + parent?: string; + sort?: NoteSort; +} + +export const noteSchemaV1 = z.object({ + version: z.literal(1), + id: UUID_SCHEMA, + // Name is not unique because it's difficult to enforce uniqueness when + // notes can change parents. There's no real harm in having duplicates. + name: z + .string() + .min(1, "Name must be at least 1 char long") + .max(64, "Name must be 64 chars or less."), + flags: z.number().optional(), + dateCreated: DATE_OR_STRING_SCHEMA, + dateUpdated: DATE_OR_STRING_SCHEMA.optional(), + sort: z.nativeEnum(NoteSort).optional(), +}); diff --git a/src/main/schemas/notes/index.ts b/src/main/schemas/notes/index.ts new file mode 100644 index 00000000..505c5030 --- /dev/null +++ b/src/main/schemas/notes/index.ts @@ -0,0 +1,5 @@ +import { noteSchemaV1 } from "./1_initialDefinition"; + +export const NOTE_SCHEMAS = { + 1: noteSchemaV1, +}; diff --git a/src/main/shortcuts.ts b/src/main/shortcuts.ts index 098bbfbf..79806008 100644 --- a/src/main/shortcuts.ts +++ b/src/main/shortcuts.ts @@ -47,7 +47,7 @@ export function shortcutIpcs( const shortcutFile = await loadJsonFile( p.join(config.content.dataDirectory!, SHORTCUT_FILE_PATH), SHORTCUTS_SCHEMAS, - { version: 1, shortcuts: [] } + { defaultContent: { version: 1, shortcuts: [] } } ); const overrides = shortcutFile.content.shortcuts ?? []; diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 5b6e9d30..258d4121 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -12,7 +12,7 @@ import { sortNotes, DEFAULT_NOTE_SORTING_ALGORITHM, getParents, - NOTE_SCHEMA, + NOTE_NAME_SCHEMA, } from "../../shared/domain/note"; import { createPromisedInput, PromisedInput } from "../../shared/promisedInput"; import { promptError, promptConfirmAction } from "../utils/prompt"; @@ -27,7 +27,6 @@ import { search } from "fast-fuzzy"; import { filterOutStaleNoteIds } from "../../shared/ui/app"; import { SidebarNewNoteButton } from "./SidebarNewNoteButton"; import { Section } from "../../shared/ui/app"; -import { z } from "zod"; const EXPANDED_ICON = faChevronDown; const COLLAPSED_ICON = faChevronRight; @@ -400,7 +399,7 @@ export const createNote: Listener<"sidebar.createNote"> = async ( const input = createPromisedInput( { - schema: NOTE_SCHEMA.shape.name, + schema: NOTE_NAME_SCHEMA, parentId: parentId ?? undefined, }, setExplorerInput(ctx) @@ -482,7 +481,7 @@ export const renameNote: Listener<"sidebar.renameNote"> = async ( { id, value, - schema: NOTE_SCHEMA.shape.name, + schema: NOTE_NAME_SCHEMA, }, setExplorerInput(ctx) ); diff --git a/src/renderer/components/SidebarNewNoteButton.tsx b/src/renderer/components/SidebarNewNoteButton.tsx index b4b6b876..5453e481 100644 --- a/src/renderer/components/SidebarNewNoteButton.tsx +++ b/src/renderer/components/SidebarNewNoteButton.tsx @@ -16,7 +16,6 @@ export function SidebarNewNoteButton( props: PropsWithChildren ): JSX.Element { const onClick = (ev: React.MouseEvent) => { - log.info("HI"); // Stop prop otherwise we'll mess up switching focus ev.stopPropagation(); props.store.dispatch("sidebar.createNote", null); diff --git a/src/shared/domain/note.ts b/src/shared/domain/note.ts index f22e5330..6b4fed8d 100644 --- a/src/shared/domain/note.ts +++ b/src/shared/domain/note.ts @@ -1,10 +1,10 @@ -import { UUID_SCHEMA, Resource, uuid, DATE_OR_STRING_SCHEMA } from "."; +import { Resource, uuid } from "."; import { isBlank } from "../utils"; import { isEmpty, orderBy } from "lodash"; import { z } from "zod"; -import { parseJSON } from "date-fns"; export interface Note extends Resource { + version: number; name: string; parent?: string; children?: Note[]; @@ -105,19 +105,10 @@ export function createNote(props: Partial & { name: string }): Note { return note; } -export const NOTE_SCHEMA = z.object({ - id: UUID_SCHEMA, - // Name is not unique because it's difficult to enforce uniqueness when - // notes can change parents. There's no real harm in having duplicates. - name: z - .string() - .min(1, "Name must be at least 1 char long") - .max(64, "Name must be 64 chars or less."), - flags: z.number().optional(), - dateCreated: DATE_OR_STRING_SCHEMA, - dateUpdated: DATE_OR_STRING_SCHEMA.optional(), - sort: z.nativeEnum(NoteSort).optional(), -}); +export const NOTE_NAME_SCHEMA = z + .string() + .min(1, "Name must be at least 1 char long") + .max(64, "Name must be 64 chars or less."); /** * Recursively search for a note based on it's id. diff --git a/test/__factories__/config.ts b/test/__factories__/config.ts index e77c4927..292854da 100644 --- a/test/__factories__/config.ts +++ b/test/__factories__/config.ts @@ -2,18 +2,11 @@ import { DeepPartial } from "tsdef"; import { Config } from "../../src/shared/domain/config"; export function createConfig(partial?: DeepPartial): Config { - const defaults: Pick< - Config, - "windowHeight" | "windowWidth" | "dataDirectory" - > = { - windowHeight: 800, - windowWidth: 600, - dataDirectory: "/data", - }; - return { - windowHeight: partial?.windowHeight ?? defaults.windowHeight, - windowWidth: partial?.windowWidth ?? defaults.windowHeight, - dataDirectory: partial?.dataDirectory ?? defaults.dataDirectory, + version: partial?.version ?? 1, + logDirectory: partial?.logDirectory ?? "/logs", + windowHeight: partial?.windowHeight ?? 600, + windowWidth: partial?.windowWidth ?? 800, + dataDirectory: partial?.dataDirectory ?? "/data", }; } diff --git a/test/__factories__/jsonFile.ts b/test/__factories__/json.ts similarity index 100% rename from test/__factories__/jsonFile.ts rename to test/__factories__/json.ts diff --git a/test/__factories__/logger.ts b/test/__factories__/logger.ts new file mode 100644 index 00000000..8b75253c --- /dev/null +++ b/test/__factories__/logger.ts @@ -0,0 +1,10 @@ +import { Logger } from "../../src/shared/logger"; + +export function createLogger(props?: Partial): Logger { + return { + info: props?.info ?? jest.fn(), + debug: props?.debug ?? jest.fn(), + error: props?.error ?? jest.fn(), + warn: props?.warn ?? jest.fn(), + }; +} diff --git a/test/__mocks__/electron.ts b/test/__mocks__/electron.ts index 6d612783..e0d11410 100644 --- a/test/__mocks__/electron.ts +++ b/test/__mocks__/electron.ts @@ -16,3 +16,7 @@ export const app = { export const BrowserWindow = { getFocusedWindow: jest.fn(), }; + +export const shell = { + trashItem: jest.fn(), +}; diff --git a/test/main/app.spec.ts b/test/main/app.spec.ts index 914efc6e..952be352 100644 --- a/test/main/app.spec.ts +++ b/test/main/app.spec.ts @@ -1,13 +1,14 @@ import { createConfig } from "../__factories__/config"; import { createIpcMainTS } from "../__factories__/ipc"; import { appIpcs } from "../../src/main/app"; -import { createJsonFile } from "../__factories__/jsonFile"; +import { createJsonFile } from "../__factories__/json"; import { BrowserWindow } from "../__mocks__/electron"; +import { createLogger } from "../__factories__/logger"; test("app.inspectElement rounds floats", async () => { const ipc = createIpcMainTS(); const config = createJsonFile(createConfig()); - appIpcs(ipc, config); + appIpcs(ipc, config, createLogger()); const inspectElement = jest.fn(); BrowserWindow.getFocusedWindow.mockImplementationOnce(() => ({ diff --git a/test/main/json.spec.ts b/test/main/json.spec.ts index 5cec1746..c18b8ccb 100644 --- a/test/main/json.spec.ts +++ b/test/main/json.spec.ts @@ -47,9 +47,11 @@ const fooV2: z.Schema = z.preprocess( test("loadJsonFile throws if no migrations passed", async () => { await expect(async () => { - await loadJsonFile("fake-file-path.json", {}, null!, { - prettyPrint: false, - }); + await loadJsonFile( + "fake-file-path.json", + {}, + { defaultContent: { version: 1, foo: 1 } } + ); }).rejects.toThrow(/Expected at least 1 schema/); }); @@ -62,12 +64,14 @@ test("loadJsonFile loads default content if no file found", async () => { 1: fooV1, 2: fooV2, }, + { - version: 2, - foo: "cat", - bar: 42, - }, - { prettyPrint: false } + defaultContent: { + version: 2, + foo: "cat", + bar: 42, + }, + } ); expect(content).toEqual({ version: 2, @@ -104,11 +108,12 @@ test("loadJsonFile loads content and validates it", async () => { 2: schema2, }, { - version: 2, - foo: "cat", - bar: 42, - }, - { prettyPrint: false } + defaultContent: { + version: 2, + foo: "cat", + bar: 42, + }, + } ); expect(content).toEqual({ @@ -120,104 +125,3 @@ test("loadJsonFile loads content and validates it", async () => { expect(schema2.parseAsync).toBeCalled(); expect(fsp.writeFile).not.toBeCalled(); }); - -test("loadJsonFile update validates content before saving to file.", async () => { - (fs.existsSync as jest.Mock).mockReturnValueOnce(true); - (fsp.readFile as jest.Mock).mockResolvedValueOnce( - ` - { - "version": 2, - "foo": "dog", - "bar": 24 - } - ` - ); - - const schema2 = { - parseAsync: jest.fn().mockResolvedValue({ - version: 2, - foo: "horse", - bar: 24, - }), - } as unknown as ZodSchema; - - const fileHandler = await loadJsonFile( - "fake-file-path.json", - { - 1: fooV1, - 2: schema2, - }, - { - version: 2, - foo: "cat", - bar: 42, - }, - { prettyPrint: false } - ); - - expect(fsp.writeFile).not.toBeCalled(); - - await fileHandler.update({ - foo: "horse", - }); - - expect(fileHandler.content.foo).toBe("horse"); - expect(fsp.writeFile).toBeCalledWith( - "fake-file-path.json", - `{"version":2,"foo":"horse","bar":24}`, - { encoding: "utf-8" } - ); - expect(schema2.parseAsync).toBeCalledWith({ - version: 2, - foo: "horse", - bar: 24, - }); -}); - -test("loadJsonFile migrates when loading older content", async () => { - (fs.existsSync as jest.Mock).mockReturnValueOnce(true); - (fsp.readFile as jest.Mock).mockResolvedValueOnce( - `{"version": 1,"foo": "dog"}` - ); - - const schema1 = { - parseAsync: jest.fn().mockResolvedValue({ - version: 1, - foo: "dog", - }), - } as unknown as ZodSchema; - const schema2 = { - parseAsync: jest.fn().mockResolvedValue({ - version: 2, - foo: "dog", - bar: 24, - }), - } as unknown as ZodSchema; - - const fileHandler = await loadJsonFile( - "fake-file-path.json", - { - 1: schema1, - 2: schema2, - }, - { - version: 2, - foo: "cat", - bar: 42, - }, - { prettyPrint: false } - ); - - expect(fileHandler.content).toEqual({ - version: 2, - foo: "dog", - bar: 24, - }); - expect(schema1.parseAsync).toBeCalled(); - expect(schema2.parseAsync).toBeCalled(); - expect(fsp.writeFile).toBeCalledWith( - "fake-file-path.json", - `{"version":2,"foo":"dog","bar":24}`, - { encoding: "utf-8" } - ); -}); diff --git a/test/main/log.spec.ts b/test/main/log.spec.ts index 71178437..cdcefd30 100644 --- a/test/main/log.spec.ts +++ b/test/main/log.spec.ts @@ -3,7 +3,7 @@ import * as fsp from "fs/promises"; import * as p from "path"; import { subDays, subMonths, subWeeks } from "date-fns"; import { getLogFileName, getLogger } from "../../src/main/log"; -import { createJsonFile } from "../__factories__/jsonFile"; +import { createJsonFile } from "../__factories__/json"; import { Config } from "../../src/shared/domain/config"; jest.mock("fs/promises"); diff --git a/test/main/notes.spec.ts b/test/main/notes.spec.ts index e90ba7e8..914a1e60 100644 --- a/test/main/notes.spec.ts +++ b/test/main/notes.spec.ts @@ -1,23 +1,215 @@ -import { noteIpcs, NOTES_DIRECTORY } from "../../src/main/notes"; +import { + MARKDOWN_FILE_NAME, + METADATA_FILE_NAME, + noteIpcs, + NOTES_DIRECTORY, +} from "../../src/main/notes"; import { createConfig } from "../__factories__/config"; import { createIpcMainTS } from "../__factories__/ipc"; -import { createJsonFile } from "../__factories__/jsonFile"; +import { createJsonFile } from "../__factories__/json"; +import { createLogger } from "../__factories__/logger"; import * as fs from "fs"; import * as fsp from "fs/promises"; import * as path from "path"; +import { uuid } from "../../src/shared/domain"; +import { loadJson, writeJson } from "../../src/main/json"; +import { + createNote, + getNoteById, + NoteSort, +} from "../../src/shared/domain/note"; +import { NOTE_SCHEMAS } from "../../src/main/schemas/notes"; +import { shell } from "electron"; jest.mock("fs"); jest.mock("fs/promises"); +jest.mock("../../src/main/json"); -test("init note directory is created if missing in file system.", async () => { +test("init", async () => { + // Creates note directory if missing const ipc = createIpcMainTS(); const config = createJsonFile(createConfig({ dataDirectory: "foo" })); + noteIpcs(ipc, config, createLogger()); + (fs.existsSync as jest.Mock).mockReturnValueOnce(false); + (fsp.readdir as jest.Mock).mockReturnValueOnce([]); - noteIpcs(ipc, config); await ipc.trigger("init"); const noteDirPath = path.join("foo", NOTES_DIRECTORY); expect(fsp.mkdir).toHaveBeenCalledWith(noteDirPath); + + // Loads notes + (fsp.readdir as jest.Mock).mockReset(); + + (fs.existsSync as jest.Mock).mockReturnValueOnce(false); + (fsp.readdir as jest.Mock).mockReturnValueOnce([ + { name: uuid(), isDirectory: jest.fn().mockReturnValueOnce(true) }, + // Ignored + { + name: "not-a-note-dir", + isDirectory: jest.fn().mockReturnValueOnce(true), + }, + // Ignored + { name: "random-file", isDirectory: jest.fn().mockReturnValueOnce(false) }, + ]); + + const note = createNote({ + id: uuid(), + name: "foo", + }); + + (loadJson as jest.Mock).mockReturnValueOnce(note); + await ipc.trigger("init"); + const notes = await ipc.invoke("notes.getAll"); + expect(notes).toHaveLength(1); + expect(notes).toContainEqual(expect.objectContaining({ id: note.id })); +}); + +test("notes.create", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + noteIpcs(ipc, config, createLogger()); + + (fs.existsSync as jest.Mock).mockReturnValueOnce(false); + (fsp.open as jest.Mock).mockReturnValueOnce({ + close: jest.fn(), + }); + + const note = await ipc.invoke("notes.create", "foo"); + expect(fsp.mkdir).toHaveBeenCalledWith(`/data/notes/${note.id}`); + + // Creates metadata + expect(writeJson).toHaveBeenCalledWith( + `/data/notes/${note.id}/${METADATA_FILE_NAME}`, + NOTE_SCHEMAS, + expect.objectContaining({ + id: note.id, + name: "foo", + }) + ); + + // Creates markdown + expect(fsp.open).toHaveBeenLastCalledWith( + `/data/notes/${note.id}/${MARKDOWN_FILE_NAME}`, + "w" + ); +}); + +test("notes.updateMetadata", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + + let note = createNote({ name: "foo" }); + noteIpcs(ipc, config, createLogger(), [note]); + + expect(note.dateUpdated).toBe(undefined); + + note = await ipc.invoke("notes.updateMetadata", note.id, { + name: "bar", + sort: NoteSort.DateCreated, + }); + + expect(note.name).toBe("bar"); + expect(note.sort).toBe(NoteSort.DateCreated); + expect(note.dateUpdated).not.toBe(undefined); + + note = await ipc.invoke("notes.updateMetadata", note.id, { + sort: undefined, + }); + expect(note.sort).toBe(undefined); +}); + +test("notes.loadContent", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + noteIpcs(ipc, config, createLogger()); + + (fsp.readFile as jest.Mock).mockReturnValueOnce("foo"); + await ipc.invoke("notes.loadContent", "123"); + + expect(fsp.readFile).toHaveBeenCalledWith( + `/data/notes/123/${MARKDOWN_FILE_NAME}`, + { encoding: "utf-8" } + ); +}); + +test("notes.saveContent", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + + const note = createNote({ name: "foo" }); + expect(note.dateUpdated).toBe(undefined); + noteIpcs(ipc, config, createLogger(), [note]); + + await ipc.invoke("notes.saveContent", note.id, "Random content..."); + + expect(fsp.writeFile).toHaveBeenCalledWith( + `/data/notes/${note.id}/${MARKDOWN_FILE_NAME}`, + "Random content...", + { encoding: "utf-8" } + ); + + const everyNote = await ipc.invoke("notes.getAll"); + // Sets updated at date. + const updatedNote = getNoteById(everyNote, note.id); + + expect(updatedNote.dateUpdated).not.toBe(undefined); +}); + +test("notes.delete", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + + const note = createNote({ + name: "foo", + }); + const child1 = createNote({ name: "bar" }); + const child2 = createNote({ name: "baz" }); + note.children = [child1, child2]; + child1.parent = note.id; + child2.parent = note.id; + + noteIpcs(ipc, config, createLogger(), [note]); + await ipc.invoke("notes.delete", note.id); + + expect(fsp.rmdir).toHaveBeenCalledTimes(3); + expect(fsp.rmdir).toHaveBeenCalledWith(`/data/notes/${note.id}`, { + recursive: true, + }); + expect(fsp.rmdir).toHaveBeenCalledWith( + `/data/notes/${note.children![0].id}`, + { + recursive: true, + } + ); + expect(fsp.rmdir).toHaveBeenCalledWith( + `/data/notes/${note.children![1].id}`, + { + recursive: true, + } + ); +}); + +test("notes.moveToTrash", async () => { + const ipc = createIpcMainTS(); + const config = createJsonFile(createConfig()); + + const note = createNote({ + name: "foo", + }); + const child1 = createNote({ name: "bar" }); + const child2 = createNote({ name: "baz" }); + note.children = [child1, child2]; + child1.parent = note.id; + child2.parent = note.id; + noteIpcs(ipc, config, createLogger(), [note]); + + await ipc.invoke("notes.moveToTrash", note.id); + + expect(shell.trashItem).toBeCalledTimes(3); + expect(shell.trashItem).toBeCalledWith(`/data/notes/${note.id}`); + expect(shell.trashItem).toBeCalledWith(`/data/notes/${child1.id}`); + expect(shell.trashItem).toBeCalledWith(`/data/notes/${child2.id}`); }); diff --git a/test/main/schema/config.spec.ts b/test/main/schemas/config.spec.ts similarity index 100% rename from test/main/schema/config.spec.ts rename to test/main/schemas/config.spec.ts diff --git a/test/main/shortcuts.spec.ts b/test/main/shortcuts.spec.ts index d36b9954..92ab8ac0 100644 --- a/test/main/shortcuts.spec.ts +++ b/test/main/shortcuts.spec.ts @@ -1,10 +1,11 @@ import { createConfig } from "../__factories__/config"; import { createIpcMainTS } from "../__factories__/ipc"; -import { createJsonFile } from "../__factories__/jsonFile"; +import { createJsonFile } from "../__factories__/json"; import { shortcutIpcs } from "../../src/main/shortcuts"; import { loadJsonFile } from "../../src/main/json"; import { Section } from "../../src/shared/ui/app"; import { parseKeyCodes } from "../../src/shared/io/keyCode"; +import { createLogger } from "../__factories__/logger"; jest.mock("fs"); jest.mock("fs/promises"); @@ -55,7 +56,7 @@ test("shortcutIpcs init", async () => { update: jest.fn(), }); - shortcutIpcs(ipc, config); + shortcutIpcs(ipc, config, createLogger()); await ipc.trigger("init"); const shortcuts = await ipc.invoke("shortcuts.getAll");