import localForage from 'localforage'; import NotasRepository from "./storage"; import { encryptSensitiveSettings, decryptSensitiveSettings, stripEncryptedFields } from "~/utils/settings"; import type { FolderCreateType, FolderType, FolderUpdateType, NoteCreateType, NoteSortType, NoteType, NoteUpdateType, TagCreateType, TagType, TagUpdateType, NoteTagCreateType, NoteTagType, } from "./storage.type"; class LocalForageRepository implements NotasRepository { // in-memory cache for full-table reads — any new code path that writes to the // notes/folders/tags/noteTags localForage keys directly (bypassing the create*/update*/delete* // methods below) must also invalidate the matching cache field here. private notesCache: NoteType[] | null = null; private foldersCache: FolderType[] | null = null; private tagsCache: TagType[] | null = null; private noteTagsCache: NoteTagType[] | null = null; // common async get(key: string): Promise { return await localForage.getItem(key); } async set(key: string, value: any): Promise { return await localForage.setItem(key, value); } // logic async getAllFolders(): Promise { if (this.foldersCache) return [...this.foldersCache]; const folders = JSON.parse(await localForage.getItem('folders') || '[]'); this.foldersCache = folders; return [...folders] as FolderType[]; } async getFolders(): Promise { const folders = await this.getAllFolders(); return folders.filter((folder: any) => !folder.deletedAt) as FolderType[]; } async getDeletedFolders(): Promise { const folders = await this.getAllFolders(); return folders.filter((folder: any) => folder.deletedAt) as FolderType[]; } async getFolderDetail(folderId: string): Promise { return JSON.parse(await localForage.getItem(`folders-${folderId}`) || '{}') as FolderType; } async createFolder(data: FolderCreateType): Promise { const folders = await this.getAllFolders(); const newFolder = [data, ...folders]; await localForage.setItem('folders', JSON.stringify(newFolder)); await localForage.setItem(`folders-${data.id}`, JSON.stringify(data)); this.foldersCache = null; return data as FolderType; } async updateFolder(folderId: string, data: FolderUpdateType): Promise { const folders = await this.getAllFolders(); const folderIndex = folders.findIndex((folder: any) => folder.id === folderId); if (folderIndex < 0) { // folder not found in array — fetch individually and prepend const existing = await this.getFolderDetail(folderId); const merged = { ...existing, ...data } as FolderType; folders.unshift(merged); await localForage.setItem('folders', JSON.stringify(folders)); await localForage.setItem(`folders-${folderId}`, JSON.stringify(merged)); this.foldersCache = null; return merged; } folders[folderIndex] = { ...folders[folderIndex], ...data }; await localForage.setItem('folders', JSON.stringify(folders)); await localForage.setItem(`folders-${folderId}`, JSON.stringify(folders[folderIndex])); this.foldersCache = null; return folders[folderIndex] as FolderType; } async deleteFolder(folderId: string): Promise { const folders = await this.getAllFolders(); const newFolders = folders.filter((folder: any) => folder.id !== folderId); await localForage.setItem('folders', JSON.stringify(newFolders)); // await localForage.removeItem(`folders-${folderId}`); this.foldersCache = null; return true; } async getActiveFolder(): Promise { return await localForage.getItem('activeFolder') || null; } async setActiveFolder(folderId: string): Promise { await localForage.setItem('activeFolder', folderId); return folderId; } async getAllNotes(): Promise { if (this.notesCache) return [...this.notesCache]; const notes = JSON.parse(await localForage.getItem(`notes`) || '[]'); this.notesCache = notes; return [...notes] as NoteType[]; } async getNotes(folderId: string): Promise { const notes = await this.getAllNotes() return notes.filter((note: NoteType) => (folderId ? note.folderId === folderId : true) && !note.deletedAt) as NoteType[]; } async getNotesWithContent(folderId: string): Promise { const notes = await this.getNotes(folderId); const notesWithContent = await Promise.all(notes.map(async (note: NoteType) => { const content = (await this.getNoteDetail(note.id)).content; return { ...note, content } as NoteType; })); return notesWithContent; } async getNoteDetail(noteId: string): Promise { return JSON.parse(await localForage.getItem(`notes-${noteId}`) || '{}') as NoteType; } async getDeletedNotes(): Promise { const notes = await this.getAllNotes(); return notes.filter((note: NoteType) => note.deletedAt && !note.deleteCompletelyAt) as NoteType[]; } async createNote(data: NoteCreateType): Promise { const notes = JSON.parse(await localForage.getItem(`notes`) || '[]'); const newNote = [data, ...notes]; await localForage.setItem('notes', JSON.stringify(newNote)); await localForage.setItem(`notes-${data.id}`, JSON.stringify(data)); this.notesCache = null; return data as NoteType; } async updateNote(noteId: string, data: NoteUpdateType): Promise { const notes = JSON.parse(await localForage.getItem(`notes`) || '[]'); const noteIndex = notes.findIndex((note: any) => note.id === noteId); if (noteIndex < 0) { // note not found in array — fetch individually and prepend const existing = await this.getNoteDetail(noteId); const merged = { ...existing, ...data } as NoteType; notes.unshift(merged); await localForage.setItem('notes', JSON.stringify(notes)); await localForage.setItem(`notes-${noteId}`, JSON.stringify(merged)); this.notesCache = null; return merged; } notes[noteIndex] = { ...notes[noteIndex], ...data }; await localForage.setItem('notes', JSON.stringify(notes)); await localForage.setItem(`notes-${noteId}`, JSON.stringify(notes[noteIndex])); this.notesCache = null; return notes[noteIndex]; } async deleteNote(noteId: string): Promise { const notes = await this.getAllNotes(); const newNotes = notes.filter((note: any) => note.id !== noteId); await localForage.setItem('notes', JSON.stringify(newNotes)); // await localForage.removeItem(`notes-${noteId}`); this.notesCache = null; return true; } async getNoteHistory(noteId: string): Promise { return JSON.parse(await localForage.getItem(`notes-history-${noteId}`) || '[]'); } private historyDayKey(unixSeconds: number): string { const d = new Date(unixSeconds * 1000); return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; } async addNoteHistorySnapshot(noteId: string, snapshot: any, maxDays: number = 3, maxPerDay: number = 5): Promise { let history = await this.getNoteHistory(noteId); history.unshift(snapshot); const dayKey = this.historyDayKey.bind(this); const newDayKey = dayKey(snapshot.snapshotAt); const sameDay = history.filter((item: any) => dayKey(item.snapshotAt) === newDayKey); if (sameDay.length > maxPerDay) { const dropIds = new Set(sameDay.slice(-(sameDay.length - maxPerDay)).map((item: any) => item.id)); history = history.filter((item: any) => !dropIds.has(item.id)); } const days = Array.from(new Set(history.map((item: any) => dayKey(item.snapshotAt)))); if (days.length > maxDays) { const keepDays = new Set(days.slice(0, maxDays)); history = history.filter((item: any) => keepDays.has(dayKey(item.snapshotAt))); } await localForage.setItem(`notes-history-${noteId}`, JSON.stringify(history)); return history; } async deleteNoteHistorySnapshot(noteId: string, snapshotId: string): Promise { const history = await this.getNoteHistory(noteId); const filtered = history.filter((item: any) => item.id !== snapshotId); await localForage.setItem(`notes-history-${noteId}`, JSON.stringify(filtered)); return filtered; } async getActiveNote(): Promise { return await localForage.getItem('activeNote') || null; } async setActiveNote(noteId: string): Promise { await localForage.setItem('activeNote', noteId); return noteId; } async getSortNote(): Promise { return await localForage.getItem('sortNote') || 'createdAt'; } async setSortNote(sort: NoteSortType): Promise { await localForage.setItem('sortNote', sort); return sort; } // tags async getAllTags(): Promise { if (this.tagsCache) return [...this.tagsCache]; const tags = JSON.parse(await localForage.getItem('tags') || '[]'); this.tagsCache = tags; return [...tags] as TagType[]; } async getTags(): Promise { const tags = await this.getAllTags(); return tags.filter((tag: any) => !tag.deletedAt) as TagType[]; } async getTagDetail(tagId: string): Promise { return JSON.parse(await localForage.getItem(`tags-${tagId}`) || '{}') as TagType; } async createTag(data: TagCreateType): Promise { const tags = await this.getAllTags(); const newTags = [data, ...tags]; await localForage.setItem('tags', JSON.stringify(newTags)); await localForage.setItem(`tags-${data.id}`, JSON.stringify(data)); this.tagsCache = null; return data as TagType; } async updateTag(tagId: string, data: TagUpdateType): Promise { const tags = await this.getAllTags(); const tagIndex = tags.findIndex((tag: any) => tag.id === tagId); if (tagIndex < 0) { // tag not found in array — fetch from individual key and prepend const existing = await this.getTagDetail(tagId); const merged = { ...existing, ...data } as TagType; tags.unshift(merged); await localForage.setItem('tags', JSON.stringify(tags)); await localForage.setItem(`tags-${tagId}`, JSON.stringify(merged)); this.tagsCache = null; return merged; } tags[tagIndex] = { ...tags[tagIndex], ...data }; await localForage.setItem('tags', JSON.stringify(tags)); await localForage.setItem(`tags-${tagId}`, JSON.stringify(tags[tagIndex])); this.tagsCache = null; return tags[tagIndex] as TagType; } // note tags (map note <-> tag) async getAllNoteTags(): Promise { if (this.noteTagsCache) return [...this.noteTagsCache]; const noteTags = JSON.parse(await localForage.getItem('noteTags') || '[]'); this.noteTagsCache = noteTags; return [...noteTags] as NoteTagType[]; } async getNoteTagsByNote(noteId: string): Promise { const noteTags = await this.getAllNoteTags(); return noteTags.filter((noteTag: any) => noteTag.noteId === noteId && !noteTag.deletedAt) as NoteTagType[]; } async getNoteTagsByTag(tagId: string): Promise { const noteTags = await this.getAllNoteTags(); return noteTags.filter((noteTag: any) => noteTag.tagId === tagId && !noteTag.deletedAt) as NoteTagType[]; } async createNoteTag(data: NoteTagCreateType): Promise { const noteTags = await this.getAllNoteTags(); const newNoteTags = [data, ...noteTags]; await localForage.setItem('noteTags', JSON.stringify(newNoteTags)); await localForage.setItem(`noteTags-${data.id}`, JSON.stringify(data)); this.noteTagsCache = null; return data as NoteTagType; } async updateNoteTag(noteTagId: string, data: Partial): Promise { const noteTags = await this.getAllNoteTags(); const noteTagIndex = noteTags.findIndex((noteTag: any) => noteTag.id === noteTagId); if (noteTagIndex < 0) { // noteTag not found in array — fetch from individual key and prepend const existing = JSON.parse(await localForage.getItem(`noteTags-${noteTagId}`) || '{}') as NoteTagType; const merged = { ...existing, ...data } as NoteTagType; noteTags.unshift(merged); await localForage.setItem('noteTags', JSON.stringify(noteTags)); await localForage.setItem(`noteTags-${noteTagId}`, JSON.stringify(merged)); this.noteTagsCache = null; return merged; } noteTags[noteTagIndex] = { ...noteTags[noteTagIndex], ...data }; await localForage.setItem('noteTags', JSON.stringify(noteTags)); await localForage.setItem(`noteTags-${noteTagId}`, JSON.stringify(noteTags[noteTagIndex])); this.noteTagsCache = null; return noteTags[noteTagIndex] as NoteTagType; } // password | settings // Random, device-local key (never exported or synced) used only to keep secrets like S3/sync // credentials out of plaintext in IndexedDB. Deliberately independent of the note-lock // password so it isn't invalidated by a password change and also protects users who haven't // set one — see getSettings/setSettings below. private async getOrCreateSettingsEncryptionKey(): Promise { const existing = await localForage.getItem('settingsEncryptionKey') as string | null; if (existing) return existing; const bytes = crypto.getRandomValues(new Uint8Array(32)); const key = Array.prototype.map.call(bytes, (x: number) => ('00' + x.toString(16)).slice(-2)).join(''); await localForage.setItem('settingsEncryptionKey', key); return key; } async getSettings(): Promise { const raw = JSON.parse(await localForage.getItem('settings') || '{}'); if (!raw.encryptedFields?.length) return raw; const key = await this.getOrCreateSettingsEncryptionKey(); try { const decrypted = await decryptSensitiveSettings(raw, raw.encryptedFields, key); delete decrypted.encryptedFields; return decrypted; } catch (err) { // decryptSensitiveSettings already isolates per-field failures — this only guards // against something unexpected blowing up the whole call, so the rest of settings // (general, sync.adapter, sync.frequency, ...) still loads instead of silently // reverting to defaults. console.warn('Failed to decrypt settings, returning with encrypted fields stripped', err); const stripped = stripEncryptedFields(raw, raw.encryptedFields); delete stripped.encryptedFields; return stripped; } } async setSettings(data: any): Promise { const key = await this.getOrCreateSettingsEncryptionKey(); const { settings: encrypted, encryptedFields } = await encryptSensitiveSettings(data, key); await localForage.setItem('settings', JSON.stringify({ ...encrypted, encryptedFields })); return data; } async checkPasswordExist(): Promise { return !!await localForage.getItem('password'); } async getPassword(): Promise { return await localForage.getItem('password') || ''; } async setPassword(password: string): Promise { // const hash = await this.hashPassword(password); await localForage.setItem('password', password); return password; } async getPasswordChangeBackup(): Promise { return JSON.parse(await localForage.getItem('passwordChangeBackup') || 'null'); } async savePasswordChangeBackup(data: any): Promise { await localForage.setItem('passwordChangeBackup', JSON.stringify(data)); return data; } async clearPasswordChangeBackup(): Promise { await localForage.removeItem('passwordChangeBackup'); return true; } // sync async getLastPull(): Promise { return parseInt(await localForage.getItem('lastPull') || '0'); } async updateLastPull(now: number): Promise { await localForage.setItem('lastPull', now + ""); return true; } async getActionObject(): Promise { return JSON.parse(await localForage.getItem('actionObject') || '{}'); } async saveActionObject(data: any): Promise { await localForage.setItem('actionObject', JSON.stringify(data)); return data; } // import async getImportData(): Promise { return JSON.parse(await localForage.getItem('opennotasImport') || '{}'); } async setImportData(data: any): Promise { await localForage.setItem('opennotasImport', JSON.stringify(data)); return data; } // encrypt aes notes async generateAESKey(): Promise { return generateKey(); } async saveAESKey(key: CryptoKey): Promise { const keyString = await exportKey(key); await localForage.setItem('aesEncryptNoteKey', keyString); return keyString; } async getAESKey(): Promise { const keyString: string = await localForage.getItem('aesEncryptNoteKey') || ""; const key = keyString ? await importKey(keyString) : null; return key; } // utils async checkIsFirstInit(): Promise { return !await localForage.getItem('firstInit') && !await localForage.getItem('notes') && !await localForage.getItem('folders'); } // dangerus async cleanData(): Promise { await localForage.clear(); this.notesCache = null; this.foldersCache = null; this.tagsCache = null; this.noteTagsCache = null; return true; } } export default LocalForageRepository;