* @returns {boolean} */ isEligible(tab) { if (tab?.canonicalUrl && URL.canParse(tab.canonicalUrl)) { return true; } return false; } /** * Retrieve a note for a tab, if it exists. * * @param {MozTabbrowserTab} tab * The tab to check for a note * @returns {Promise} */ async get(tab) { if (!this.isEligible(tab)) { return undefined; } const results = await this.#connection.executeCached(GET_NOTE_BY_URL, { url: tab.canonicalUrl, }); if (!results?.length) { return undefined; } const [result] = results; const record = this.#mapDbRowToRecord(result); return record; } /** * Set a note for a tab. * * @param {MozTabbrowserTab} tab * The tab that the note should be associated with * @param {string} note * The note itself * @param {object} [options] * @param {TabNoteTelemetrySource} [options.telemetrySource] * The UI surface that requested to set a note. * @returns {Promise} * The actual note that was set after sanitization * @throws {RangeError} * if `tab` is not eligible for a tab note or `note` is empty */ async set(tab, note, options = {}) { if (!this.isEligible(tab)) { throw new RangeError("Tab notes must be associated to an eligible tab"); } if (!note) { throw new RangeError("Tab note text must be provided"); } let existingNote = await this.get(tab); let sanitized = this.#sanitizeInput(note); if (existingNote && existingNote.text == sanitized) { return existingNote; } return this.#connection.executeTransaction(async () => { if (!existingNote) { const insertResult = await this.#connection.executeCached(CREATE_NOTE, { url: tab.canonicalUrl, note: sanitized, }); const insertedRecord = this.#mapDbRowToRecord(insertResult[0]); tab.dispatchEvent( new CustomEvent("TabNote:Created", { bubbles: true, detail: { note: insertedRecord, telemetrySource: options.telemetrySource, }, }) ); return insertedRecord; } const updateResult = await this.#connection.executeCached(UPDATE_NOTE, { url: tab.canonicalUrl, note: sanitized, }); const updatedRecord = this.#mapDbRowToRecord(updateResult[0]); tab.dispatchEvent( new CustomEvent("TabNote:Edited", { bubbles: true, detail: { note: updatedRecord, telemetrySource: options.telemetrySource, }, }) ); return updatedRecord; }); } /** * Delete a note for a tab. * * @param {MozTabbrowserTab} tab * The tab that has a note * @param {object} [options] * @param {TabNoteTelemetrySource} [options.telemetrySource] * The UI surface that requested to delete a note. * @returns {Promise} * True if there was a note and it was deleted; false otherwise */ async delete(tab, options = {}) { /** @type {mozIStorageRow[]} */ const deleteResult = await this.#connection.executeCached(DELETE_NOTE, { url: tab.canonicalUrl, }); if (deleteResult?.length > 0) { const deletedRecord = this.#mapDbRowToRecord(deleteResult[0]); tab.dispatchEvent( new CustomEvent("TabNote:Removed", { bubbles: true, detail: { note: deletedRecord, telemetrySource: options.telemetrySource, }, }) ); return true; } return false; } /** * Check if a tab has a note. * * @param {MozTabbrowserTab} tab * The tab to check for a tab note * @returns {Promise} * True if a note is associated with this URL; false otherwise */ async has(tab) { const record = await this.get(tab); return record !== undefined; } /** * Clear all notes for all URLs. * * @returns {void} */ reset() { this.#connection.execute(` DELETE FROM "tabnotes"`); } /** * Given user-supplied note text, returns sanitized note text. * * @param {string} value * @returns {string} */ #sanitizeInput(value) { return value.slice(0, 1000); } /** * @param {mozIStorageRow} row * Row returned with the following data shape: * [id: number, canonical_url: string, created: number, note_text: string] * @returns {TabNoteRecord} */ #mapDbRowToRecord(row) { return { id: row.getDouble(0), canonicalUrl: row.getString(1), created: Temporal.Instant.fromEpochMilliseconds(row.getDouble(2) * 1000), text: row.getString(3), }; } } // Singleton object accessible from all windows export const TabNotes = new TabNotesStorage(); PK