Ctrl K
ComunidadGuías…

repositories/index.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<any> {
    return await localForage.getItem(key);
  }
  async set(key: string, value: any): Promise<any> {
    return await localForage.setItem(key, value);
  }

  // logic
  async getAllFolders(): Promise<FolderType[]> {
    if (this.foldersCache) return [...this.foldersCache];
    const folders = JSON.parse(await localForage.getItem('folders') || '[]');
    this.foldersCache = folders;
    return [...folders] as FolderType[];
  }
  async getFolders(): Promise<FolderType[]> {
    const folders = await this.getAllFolders();
    return folders.filter((folder: any) => !folder.deletedAt) as FolderType[];
  }
  async getDeletedFolders(): Promise<FolderType[]> {
    const folders = await this.getAllFolders();
    return folders.filter((folder: any) => folder.deletedAt) as FolderType[];
  }
  async getFolderDetail(folderId: string): Promise<FolderType> {
    return JSON.parse(await localForage.getItem(`folders-${folderId}`) || '{}') as FolderType;
  }
  async createFolder(data: FolderCreateType): Promise<FolderType> {
    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<FolderType> {
    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<boolean> {
    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<string | null> {
    return await localForage.getItem('activeFolder') || null;
  }
  async setActiveFolder(folderId: string): Promise<string> {
    await localForage.setItem('activeFolder', folderId);
    return folderId;
  }

  async getAllNotes(): Promise<NoteType[]> {
    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<NoteType[]> {
    const notes = await this.getAllNotes()
    return notes.filter((note: NoteType) => (folderId ? note.folderId === folderId : true) && !note.deletedAt) as NoteType[];
  }
  async getNotesWithContent(folderId: string): Promise<NoteType[]> {
    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<NoteType> {
    return JSON.parse(await localForage.getItem(`notes-${noteId}`) || '{}') as NoteType;
  }
  async getDeletedNotes(): Promise<NoteType[]> {
    const notes = await this.getAllNotes();
    return notes.filter((note: NoteType) => note.deletedAt && !note.deleteCompletelyAt) as NoteType[];
  }
  async createNote(data: NoteCreateType): Promise<NoteType> {
    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<NoteType> {
    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<boolean> {
    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<any[]> {
    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<any[]> {
    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<any[]> {
    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<string | null> {
    return await localForage.getItem('activeNote') || null;
  }
  async setActiveNote(noteId: string): Promise<string> {
    await localForage.setItem('activeNote', noteId);
    return noteId;
  }
  async getSortNote(): Promise<NoteSortType> {
    return await localForage.getItem('sortNote') || 'createdAt';
  }
  async setSortNote(sort: NoteSortType): Promise<NoteSortType> {
    await localForage.setItem('sortNote', sort);
    return sort;
  }

  // tags
  async getAllTags(): Promise<TagType[]> {
    if (this.tagsCache) return [...this.tagsCache];
    const tags = JSON.parse(await localForage.getItem('tags') || '[]');
    this.tagsCache = tags;
    return [...tags] as TagType[];
  }
  async getTags(): Promise<TagType[]> {
    const tags = await this.getAllTags();
    return tags.filter((tag: any) => !tag.deletedAt) as TagType[];
  }
  async getTagDetail(tagId: string): Promise<TagType> {
    return JSON.parse(await localForage.getItem(`tags-${tagId}`) || '{}') as TagType;
  }
  async createTag(data: TagCreateType): Promise<TagType> {
    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<TagType> {
    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<NoteTagType[]> {
    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<NoteTagType[]> {
    const noteTags = await this.getAllNoteTags();
    return noteTags.filter((noteTag: any) => noteTag.noteId === noteId && !noteTag.deletedAt) as NoteTagType[];
  }
  async getNoteTagsByTag(tagId: string): Promise<NoteTagType[]> {
    const noteTags = await this.getAllNoteTags();
    return noteTags.filter((noteTag: any) => noteTag.tagId === tagId && !noteTag.deletedAt) as NoteTagType[];
  }
  async createNoteTag(data: NoteTagCreateType): Promise<NoteTagType> {
    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<NoteTagType>): Promise<NoteTagType> {
    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<string> {
    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<any> {
    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<any> {
    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<boolean> {
    return !!await localForage.getItem('password');
  }
  async getPassword(): Promise<string> {
    return await localForage.getItem('password') || '';
  }
  async setPassword(password: string): Promise<string> {
    // const hash = await this.hashPassword(password);
    await localForage.setItem('password', password);
    return password;
  }
  async getPasswordChangeBackup(): Promise<any> {
    return JSON.parse(await localForage.getItem('passwordChangeBackup') || 'null');
  }
  async savePasswordChangeBackup(data: any): Promise<any> {
    await localForage.setItem('passwordChangeBackup', JSON.stringify(data));
    return data;
  }
  async clearPasswordChangeBackup(): Promise<boolean> {
    await localForage.removeItem('passwordChangeBackup');
    return true;
  }

  // sync
  async getLastPull(): Promise<number> {
    return parseInt(await localForage.getItem('lastPull') || '0');
  }
  async updateLastPull(now: number): Promise<boolean> {
    await localForage.setItem('lastPull', now + "");
    return true;
  }
  async getActionObject(): Promise<any> {
    return JSON.parse(await localForage.getItem('actionObject') || '{}');
  }
  async saveActionObject(data: any): Promise<any> {
    await localForage.setItem('actionObject', JSON.stringify(data));
    return data;
  }

  // import
  async getImportData(): Promise<any> {
    return JSON.parse(await localForage.getItem('opennotasImport') || '{}');
  }
  async setImportData(data: any): Promise<any> {
    await localForage.setItem('opennotasImport', JSON.stringify(data));
    return data;
  }

  // encrypt aes notes
  async generateAESKey(): Promise<CryptoKey> {
    return generateKey();
  }
  async saveAESKey(key: CryptoKey): Promise<string> {
    const keyString = await exportKey(key);
    await localForage.setItem('aesEncryptNoteKey', keyString);
    return keyString;
  }
  async getAESKey(): Promise<CryptoKey | null> {
    const keyString: string = await localForage.getItem('aesEncryptNoteKey') || "";
    const key = keyString ? await importKey(keyString) : null;
    return key;
  }

  // utils
  async checkIsFirstInit(): Promise<boolean> {
    return !await localForage.getItem('firstInit')
      && !await localForage.getItem('notes')
      && !await localForage.getItem('folders');
  }

  // dangerus
  async cleanData(): Promise<boolean> {
    await localForage.clear();
    this.notesCache = null;
    this.foldersCache = null;
    this.tagsCache = null;
    this.noteTagsCache = null;
    return true;
  }
}

export default LocalForageRepository;