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();