tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TabNotes.sys.mjs (8211B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /** @import { OpenedConnection } from "resource://gre/modules/Sqlite.sys.mjs" */
      6 
      7 import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
      8 
      9 /**
     10 * @param {string} url
     11 *   The canonical URL of a tab note to look up
     12 */
     13 const GET_NOTE_BY_URL = `
     14 SELECT
     15  id,
     16  canonical_url,
     17  created,
     18  note_text
     19 FROM tabnotes
     20 WHERE
     21  canonical_url = :url
     22 `;
     23 
     24 /**
     25 * @param {string} url
     26 *   The canonical URL to associate the new tab note
     27 * @param {string} note
     28 *   The sanitized text for a tab note
     29 */
     30 const CREATE_NOTE = `
     31 INSERT INTO tabnotes
     32  (canonical_url, created, note_text)
     33 VALUES
     34  (:url, unixepoch("now"), :note)
     35 RETURNING
     36  id, canonical_url, created, note_text
     37 
     38 `;
     39 
     40 /**
     41 * @param {string} url
     42 *   The canonical URL for the existing tab note
     43 * @param {string} note
     44 *   The sanitized text for a tab note
     45 */
     46 const UPDATE_NOTE = `
     47 UPDATE
     48  tabnotes
     49 SET
     50  note_text = :note
     51 WHERE
     52  canonical_url = :url
     53 RETURNING
     54  id, canonical_url, created, note_text
     55 `;
     56 
     57 /**
     58 * @param {string} url
     59 *   The canonical URL of a tab note to delete
     60 */
     61 const DELETE_NOTE = `
     62 DELETE FROM
     63  tabnotes
     64 WHERE
     65  canonical_url = :url
     66 RETURNING
     67  id, canonical_url, created, note_text
     68 `;
     69 
     70 /**
     71 * Provides the CRUD interface for tab notes.
     72 */
     73 export class TabNotesStorage {
     74  DATABASE_FILE_NAME = Object.freeze("tabnotes.sqlite");
     75  TELEMETRY_SOURCE = Object.freeze({
     76    TAB_CONTEXT_MENU: "context_menu",
     77    TAB_HOVER_PREVIEW_PANEL: "hover_menu",
     78  });
     79 
     80  /** @type {OpenedConnection|undefined} */
     81  #connection;
     82 
     83  /**
     84   * @param {object} [options={}]
     85   * @param {string} [options.basePath=PathUtils.profileDir]
     86   *   Base file path to a folder where the database file should live.
     87   *   Defaults to the current profile's root directory.
     88   * @returns {Promise<void>}
     89   */
     90  init(options) {
     91    const basePath = options?.basePath ?? PathUtils.profileDir;
     92    this.dbPath = PathUtils.join(basePath, this.DATABASE_FILE_NAME);
     93    return Sqlite.openConnection({
     94      path: this.dbPath,
     95    }).then(async connection => {
     96      this.#connection = connection;
     97      await this.#connection.execute("PRAGMA journal_mode = WAL");
     98      await this.#connection.execute("PRAGMA wal_autocheckpoint = 16");
     99 
    100      let currentVersion = await this.#connection.getSchemaVersion();
    101 
    102      if (currentVersion == 1) {
    103        // tabnotes schema is up to date
    104        return;
    105      }
    106 
    107      if (currentVersion == 0) {
    108        // version 0: create `tabnotes` table
    109        await this.#connection.executeTransaction(async () => {
    110          await this.#connection.execute(`
    111          CREATE TABLE IF NOT EXISTS "tabnotes" (
    112            id            INTEGER PRIMARY KEY,
    113            canonical_url TEXT NOT NULL,
    114            created       INTEGER NOT NULL,
    115            note_text     TEXT NOT NULL
    116          );`);
    117          await this.#connection.setSchemaVersion(1);
    118        });
    119      }
    120    });
    121  }
    122 
    123  /**
    124   * @returns {Promise<void>}
    125   */
    126  deinit() {
    127    if (this.#connection) {
    128      return this.#connection.close().then(() => {
    129        this.#connection = null;
    130      });
    131    }
    132    return Promise.resolve();
    133  }
    134 
    135  /**
    136   * @param {MozTabbrowserTab} tab
    137   * @returns {boolean}
    138   */
    139  isEligible(tab) {
    140    if (tab?.canonicalUrl && URL.canParse(tab.canonicalUrl)) {
    141      return true;
    142    }
    143    return false;
    144  }
    145 
    146  /**
    147   * Retrieve a note for a tab, if it exists.
    148   *
    149   * @param {MozTabbrowserTab} tab
    150   *   The tab to check for a note
    151   * @returns {Promise<TabNoteRecord|undefined>}
    152   */
    153  async get(tab) {
    154    if (!this.isEligible(tab)) {
    155      return undefined;
    156    }
    157    const results = await this.#connection.executeCached(GET_NOTE_BY_URL, {
    158      url: tab.canonicalUrl,
    159    });
    160    if (!results?.length) {
    161      return undefined;
    162    }
    163    const [result] = results;
    164    const record = this.#mapDbRowToRecord(result);
    165    return record;
    166  }
    167 
    168  /**
    169   * Set a note for a tab.
    170   *
    171   * @param {MozTabbrowserTab} tab
    172   *   The tab that the note should be associated with
    173   * @param {string} note
    174   *   The note itself
    175   * @param {object} [options]
    176   * @param {TabNoteTelemetrySource} [options.telemetrySource]
    177   *   The UI surface that requested to set a note.
    178   * @returns {Promise<TabNoteRecord>}
    179   *   The actual note that was set after sanitization
    180   * @throws {RangeError}
    181   *   if `tab` is not eligible for a tab note or `note` is empty
    182   */
    183  async set(tab, note, options = {}) {
    184    if (!this.isEligible(tab)) {
    185      throw new RangeError("Tab notes must be associated to an eligible tab");
    186    }
    187    if (!note) {
    188      throw new RangeError("Tab note text must be provided");
    189    }
    190 
    191    let existingNote = await this.get(tab);
    192    let sanitized = this.#sanitizeInput(note);
    193 
    194    if (existingNote && existingNote.text == sanitized) {
    195      return existingNote;
    196    }
    197 
    198    return this.#connection.executeTransaction(async () => {
    199      if (!existingNote) {
    200        const insertResult = await this.#connection.executeCached(CREATE_NOTE, {
    201          url: tab.canonicalUrl,
    202          note: sanitized,
    203        });
    204 
    205        const insertedRecord = this.#mapDbRowToRecord(insertResult[0]);
    206        tab.dispatchEvent(
    207          new CustomEvent("TabNote:Created", {
    208            bubbles: true,
    209            detail: {
    210              note: insertedRecord,
    211              telemetrySource: options.telemetrySource,
    212            },
    213          })
    214        );
    215        return insertedRecord;
    216      }
    217 
    218      const updateResult = await this.#connection.executeCached(UPDATE_NOTE, {
    219        url: tab.canonicalUrl,
    220        note: sanitized,
    221      });
    222 
    223      const updatedRecord = this.#mapDbRowToRecord(updateResult[0]);
    224      tab.dispatchEvent(
    225        new CustomEvent("TabNote:Edited", {
    226          bubbles: true,
    227          detail: {
    228            note: updatedRecord,
    229            telemetrySource: options.telemetrySource,
    230          },
    231        })
    232      );
    233      return updatedRecord;
    234    });
    235  }
    236 
    237  /**
    238   * Delete a note for a tab.
    239   *
    240   * @param {MozTabbrowserTab} tab
    241   *   The tab that has a note
    242   * @param {object} [options]
    243   * @param {TabNoteTelemetrySource} [options.telemetrySource]
    244   *   The UI surface that requested to delete a note.
    245   * @returns {Promise<boolean>}
    246   *   True if there was a note and it was deleted; false otherwise
    247   */
    248  async delete(tab, options = {}) {
    249    /** @type {mozIStorageRow[]} */
    250    const deleteResult = await this.#connection.executeCached(DELETE_NOTE, {
    251      url: tab.canonicalUrl,
    252    });
    253 
    254    if (deleteResult?.length > 0) {
    255      const deletedRecord = this.#mapDbRowToRecord(deleteResult[0]);
    256      tab.dispatchEvent(
    257        new CustomEvent("TabNote:Removed", {
    258          bubbles: true,
    259          detail: {
    260            note: deletedRecord,
    261            telemetrySource: options.telemetrySource,
    262          },
    263        })
    264      );
    265      return true;
    266    }
    267 
    268    return false;
    269  }
    270 
    271  /**
    272   * Check if a tab has a note.
    273   *
    274   * @param {MozTabbrowserTab} tab
    275   *   The tab to check for a tab note
    276   * @returns {Promise<boolean>}
    277   *   True if a note is associated with this URL; false otherwise
    278   */
    279  async has(tab) {
    280    const record = await this.get(tab);
    281    return record !== undefined;
    282  }
    283 
    284  /**
    285   * Clear all notes for all URLs.
    286   *
    287   * @returns {void}
    288   */
    289  reset() {
    290    this.#connection.execute(`
    291      DELETE FROM "tabnotes"`);
    292  }
    293 
    294  /**
    295   * Given user-supplied note text, returns sanitized note text.
    296   *
    297   * @param {string} value
    298   * @returns {string}
    299   */
    300  #sanitizeInput(value) {
    301    return value.slice(0, 1000);
    302  }
    303 
    304  /**
    305   * @param {mozIStorageRow} row
    306   *   Row returned with the following data shape:
    307   *   [id: number, canonical_url: string, created: number, note_text: string]
    308   * @returns {TabNoteRecord}
    309   */
    310  #mapDbRowToRecord(row) {
    311    return {
    312      id: row.getDouble(0),
    313      canonicalUrl: row.getString(1),
    314      created: Temporal.Instant.fromEpochMilliseconds(row.getDouble(2) * 1000),
    315      text: row.getString(3),
    316    };
    317  }
    318 }
    319 
    320 // Singleton object accessible from all windows
    321 export const TabNotes = new TabNotesStorage();