Skip to content

Commit 4112fc8

Browse files
committed
chore: move notes migration to main process
1 parent d333ff4 commit 4112fc8

9 files changed

Lines changed: 197 additions & 69 deletions

File tree

apps/desktop/src/db/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export interface AppSettingsData {
193193
followed: boolean; // Whether user followed recommendation
194194
};
195195
};
196+
dataMigrations?: {
197+
notesLexical?: number;
198+
};
196199
}
197200

198201
// Notes table

apps/desktop/src/main/core/app-manager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { OnboardingService } from "../../services/onboarding-service";
1212
import type { RecordingManager } from "../managers/recording-manager";
1313
import type { RecordingState } from "../../types/recording";
1414
import type { SettingsService } from "../../services/settings-service";
15+
import { runDataMigrations } from "../migrations/data-migrations";
1516

1617
export class AppManager {
1718
private windowManager!: WindowManager;
@@ -131,6 +132,7 @@ export class AppManager {
131132

132133
private async initializeDatabase(): Promise<void> {
133134
await initializeDatabase();
135+
await runDataMigrations();
134136
logger.db.info(
135137
"Database initialized and migrations completed successfully",
136138
);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as Y from "yjs";
2+
import { logger } from "../logger";
3+
import { getAppSettings, updateAppSettings } from "../../db/app-settings";
4+
import {
5+
getUniqueNoteIds,
6+
getYjsUpdatesByNoteId,
7+
replaceYjsUpdates,
8+
} from "../../db/notes";
9+
import {
10+
isLexicalEditorStateJsonString,
11+
serializePlainTextToLexicalEditorStateJson,
12+
} from "../../services/notes/lexical-editor-state";
13+
14+
const NOTES_LEXICAL_MIGRATION_VERSION = 1;
15+
16+
async function migrateNotesToLexicalEditorState(): Promise<{
17+
notesChecked: number;
18+
notesMigrated: number;
19+
}> {
20+
const noteIds = await getUniqueNoteIds();
21+
let notesMigrated = 0;
22+
23+
for (const noteId of noteIds) {
24+
const updates = await getYjsUpdatesByNoteId(noteId);
25+
if (updates.length === 0) continue;
26+
27+
const ydoc = new Y.Doc();
28+
for (const update of updates) {
29+
const updateArray = new Uint8Array(update.updateData as Buffer);
30+
Y.applyUpdate(ydoc, updateArray);
31+
}
32+
33+
const yText = ydoc.getText("content");
34+
const storedContent = yText.toString();
35+
36+
if (!storedContent) continue;
37+
if (isLexicalEditorStateJsonString(storedContent)) continue;
38+
39+
const migratedJson =
40+
serializePlainTextToLexicalEditorStateJson(storedContent);
41+
42+
ydoc.transact(() => {
43+
yText.delete(0, yText.length);
44+
yText.insert(0, migratedJson);
45+
}, "notes-lexical-migration");
46+
47+
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
48+
await replaceYjsUpdates(noteId, stateUpdate);
49+
notesMigrated++;
50+
}
51+
52+
return {
53+
notesChecked: noteIds.length,
54+
notesMigrated,
55+
};
56+
}
57+
58+
export async function runDataMigrations(): Promise<void> {
59+
try {
60+
const settings = await getAppSettings();
61+
const currentVersion = settings.dataMigrations?.notesLexical ?? 0;
62+
63+
if (currentVersion >= NOTES_LEXICAL_MIGRATION_VERSION) {
64+
return;
65+
}
66+
67+
const startTime = Date.now();
68+
logger.db.info("Running data migrations", {
69+
notesLexicalFrom: currentVersion,
70+
notesLexicalTo: NOTES_LEXICAL_MIGRATION_VERSION,
71+
});
72+
73+
const { notesChecked, notesMigrated } =
74+
await migrateNotesToLexicalEditorState();
75+
76+
await updateAppSettings({
77+
dataMigrations: {
78+
...(settings.dataMigrations ?? {}),
79+
notesLexical: NOTES_LEXICAL_MIGRATION_VERSION,
80+
},
81+
});
82+
83+
logger.db.info("Data migrations complete", {
84+
notesChecked,
85+
notesMigrated,
86+
durationMs: Date.now() - startTime,
87+
});
88+
} catch (error) {
89+
logger.db.error("Data migrations failed", error);
90+
}
91+
}

apps/desktop/src/main/preload.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ const api: ElectronAPI = {
110110
}),
111111
},
112112

113-
// External link handling
113+
// External link handling.
114+
// Prefer proxying through main (shell.openExternal) instead of `window.open()` from
115+
// the renderer, because Chromium can block popups unless the call is made
116+
// synchronously in direct response to a user gesture (click/keypress).
114117
openExternal: (url: string) => ipcRenderer.invoke("open-external", url),
115118

116119
// Notes API - Yjs synchronization only

apps/desktop/src/renderer/main/components/create-note-button.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export function CreateNoteButton() {
4747
});
4848
createNoteMutation.mutate({
4949
title: t("settings.notes.defaultTitleWithDate", { date: dateStr }),
50-
initialContent: "",
5150
});
5251
}, [createNoteMutation, i18n.language, t]);
5352

apps/desktop/src/renderer/main/components/editor/yjs-sync-plugin.tsx

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { useEffect, useRef, useMemo, useCallback } from "react";
22
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3-
import { $createParagraphNode, $createTextNode, $getRoot } from "lexical";
43
import type * as Y from "yjs";
5-
import { toast } from "sonner";
64
import { debounce } from "@/renderer/main/utils/debounce";
7-
import { useTranslation } from "react-i18next";
85

96
interface YjsSyncPluginProps {
107
yText: Y.Text;
@@ -15,11 +12,9 @@ export function YjsSyncPlugin({
1512
yText,
1613
onSyncStatusChange,
1714
}: YjsSyncPluginProps): null {
18-
const { t } = useTranslation();
1915
const [editor] = useLexicalComposerContext();
2016
const isUpdatingFromYjsRef = useRef(false);
2117
const isUpdatingFromLexicalRef = useRef(false);
22-
const isMigratingRef = useRef(false);
2318
const hasPendingRef = useRef(false);
2419
const pendingJsonRef = useRef<string | null>(null);
2520
const onSyncStatusChangeRef = useRef(onSyncStatusChange);
@@ -31,7 +26,7 @@ export function YjsSyncPlugin({
3126

3227
const writeJsonToYjs = useCallback(
3328
(jsonString: string) => {
34-
if (isUpdatingFromYjsRef.current || isMigratingRef.current) {
29+
if (isUpdatingFromYjsRef.current) {
3530
onSyncStatusChangeRef.current?.(false);
3631
return;
3732
}
@@ -73,63 +68,20 @@ export function YjsSyncPlugin({
7368
}
7469
};
7570

76-
const migrateLegacyText = (legacyText: string) => {
77-
isMigratingRef.current = true;
78-
isUpdatingFromYjsRef.current = true;
79-
80-
editor.update(() => {
81-
const root = $getRoot();
82-
root.clear();
83-
84-
const lines = legacyText.split(/\r?\n/);
85-
lines.forEach((line) => {
86-
const paragraph = $createParagraphNode();
87-
if (line.length > 0) {
88-
paragraph.append($createTextNode(line));
89-
}
90-
root.append(paragraph);
91-
});
92-
});
93-
94-
isUpdatingFromYjsRef.current = false;
95-
96-
const migratedJson = JSON.stringify(editor.getEditorState().toJSON());
97-
const yDoc = yText.doc;
98-
99-
if (yDoc) {
100-
isUpdatingFromLexicalRef.current = true;
101-
yDoc.transact(() => {
102-
const oldLength = yText.length;
103-
yText.delete(0, oldLength);
104-
yText.insert(0, migratedJson);
105-
}, "migration");
106-
isUpdatingFromLexicalRef.current = false;
107-
}
108-
109-
toast.success(t("settings.notes.toast.upgradedToRichNotes"));
110-
111-
isMigratingRef.current = false;
112-
};
113-
11471
// Set initial content from Yjs (stored as JSON)
11572
const storedContent = yText.toString();
11673
if (storedContent) {
11774
try {
11875
setEditorStateFromJson(storedContent);
11976
} catch (error) {
120-
// If parsing fails (e.g., old plain text content), migrate to Lexical JSON
121-
console.warn(
122-
"Failed to parse stored content as JSON, may be legacy format:",
123-
error,
124-
);
125-
migrateLegacyText(storedContent);
77+
console.warn("Failed to parse stored content as editor state:", error);
12678
}
12779
}
12880
onSyncStatusChangeRef.current?.(false);
12981

13082
// Observer for Yjs changes -> Lexical
13183
const yjsObserver = () => {
132-
if (isUpdatingFromLexicalRef.current || isMigratingRef.current) return;
84+
if (isUpdatingFromLexicalRef.current) return;
13385

13486
const newContent = yText.toString();
13587

@@ -153,7 +105,6 @@ export function YjsSyncPlugin({
153105
// Skip if triggered by Yjs sync or no actual changes
154106
if (
155107
isUpdatingFromYjsRef.current ||
156-
isMigratingRef.current ||
157108
(dirtyElements.size === 0 && dirtyLeaves.size === 0)
158109
) {
159110
return;

apps/desktop/src/services/notes-service.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { logger } from "../main/logger";
1717

1818
export interface NoteCreateOptions {
1919
title: string;
20-
initialContent?: string;
2120
icon?: string | null;
2221
}
2322

@@ -83,17 +82,6 @@ class NotesService {
8382
icon: options.icon,
8483
});
8584

86-
// Initialize yjs document with initial content if provided
87-
if (options.initialContent) {
88-
const ydoc = new Y.Doc();
89-
const text = ydoc.getText("content");
90-
text.insert(0, options.initialContent);
91-
92-
// Save initial content as a YJS update
93-
const initialUpdate = Y.encodeStateAsUpdate(ydoc);
94-
await saveYjsUpdateToDB(note.id, initialUpdate);
95-
}
96-
9785
return note;
9886
}
9987

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
type SerializedTextNode = {
2+
type: "text";
3+
version: 1;
4+
text: string;
5+
detail: 0;
6+
format: 0;
7+
mode: "normal";
8+
style: "";
9+
};
10+
11+
type SerializedParagraphNode = {
12+
type: "paragraph";
13+
version: 1;
14+
children: SerializedTextNode[];
15+
direction: null;
16+
format: "";
17+
indent: 0;
18+
};
19+
20+
type SerializedRootNode = {
21+
type: "root";
22+
version: 1;
23+
children: SerializedParagraphNode[];
24+
direction: null;
25+
format: "";
26+
indent: 0;
27+
};
28+
29+
type SerializedEditorState = {
30+
root: SerializedRootNode;
31+
};
32+
33+
export function isLexicalEditorStateJsonString(value: string): boolean {
34+
if (!value) return false;
35+
36+
try {
37+
const parsed = JSON.parse(value) as Partial<SerializedEditorState> | null;
38+
const root =
39+
parsed && typeof parsed === "object"
40+
? (parsed as Partial<SerializedEditorState>).root
41+
: undefined;
42+
43+
return !!(
44+
root &&
45+
typeof root === "object" &&
46+
root.type === "root" &&
47+
Array.isArray(root.children)
48+
);
49+
} catch {
50+
return false;
51+
}
52+
}
53+
54+
export function serializePlainTextToLexicalEditorStateJson(
55+
plainText: string,
56+
): string {
57+
const lines = plainText.split(/\r?\n/);
58+
59+
const paragraphs: SerializedParagraphNode[] = lines.map((line) => ({
60+
type: "paragraph",
61+
version: 1,
62+
direction: null,
63+
format: "",
64+
indent: 0,
65+
children:
66+
line.length > 0
67+
? [
68+
{
69+
type: "text",
70+
version: 1,
71+
text: line,
72+
detail: 0,
73+
format: 0,
74+
mode: "normal",
75+
style: "",
76+
},
77+
]
78+
: [],
79+
}));
80+
81+
const state: SerializedEditorState = {
82+
root: {
83+
type: "root",
84+
version: 1,
85+
direction: null,
86+
format: "",
87+
indent: 0,
88+
children: paragraphs,
89+
},
90+
};
91+
92+
return JSON.stringify(state);
93+
}

apps/desktop/src/trpc/routers/notes.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ const GetNotesSchema = z.object({
2020

2121
const CreateNoteSchema = z.object({
2222
title: z.string().min(1),
23-
initialContent: z.string().optional(),
2423
icon: z.string().nullish(),
2524
});
2625

@@ -62,7 +61,6 @@ export const notesRouter = createRouter({
6261
createNote: procedure.input(CreateNoteSchema).mutation(async ({ input }) => {
6362
const note = await notesService.createNote({
6463
title: input.title,
65-
initialContent: input.initialContent || "",
6664
icon: input.icon,
6765
});
6866

@@ -71,7 +69,7 @@ export const notesRouter = createRouter({
7169
ServiceManager.getInstance().getService("telemetryService");
7270
telemetryService.trackNoteCreated({
7371
note_id: note.id,
74-
has_initial_content: !!input.initialContent,
72+
has_initial_content: false,
7573
has_icon: !!input.icon,
7674
});
7775

0 commit comments

Comments
 (0)