Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/** @import { OpenedConnection } from "resource://gre/modules/Sqlite.sys.mjs" */
import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
/**
* @param {string} url
* The canonical URL of a tab note to look up
*/
const GET_NOTE_BY_URL = `
SELECT
id,
canonical_url,
created,
note_text
FROM tabnotes
WHERE
canonical_url = :url
`;
/**
* @param {string} url
* The canonical URL to associate the new tab note
* @param {string} note
* The sanitized text for a tab note
*/
const CREATE_NOTE = `
INSERT INTO tabnotes
(canonical_url, created, note_text)
VALUES
(:url, unixepoch("now"), :note)
RETURNING
id, canonical_url, created, note_text
`;
/**
* @param {string} url
* The canonical URL for the existing tab note
* @param {string} note
* The sanitized text for a tab note
*/
const UPDATE_NOTE = `
UPDATE
tabnotes
SET
note_text = :note
WHERE
canonical_url = :url
RETURNING
id, canonical_url, created, note_text
`;
/**
* @param {string} url
* The canonical URL of a tab note to delete
*/
const DELETE_NOTE = `
DELETE FROM
tabnotes
WHERE
canonical_url = :url
RETURNING
id, canonical_url, created, note_text
`;
const GET_NOTE_COUNT = `
SELECT COUNT(*) FROM tabnotes
`;
/**
* Provides the CRUD interface for tab notes.
*/
export class TabNotesStorage {
DATABASE_FILE_NAME = Object.freeze("tabnotes.sqlite");
TELEMETRY_SOURCE = Object.freeze({
TAB_CONTEXT_MENU: "context_menu",
TAB_HOVER_PREVIEW_PANEL: "hover_menu",
});
/**
* In-flight or completed init Promise. Concurrent callers to `init()` share
* this Promise so that only one `Sqlite.openConnection()` is ever started.
*
* @type {Promise<void>|undefined}
*/
#initPromise;
/**
* Single instance of SQLite connection to tabnotes.sqlite. Do not use this
* directly because it could be undefined; instead, `await this.#connection`
* to ensure that you always get an initialized connection.
*
* @type {OpenedConnection|undefined}
*/
#databaseConnection;
/**
* Reference to the shutdown blocker function, used to remove the blocker
* when deinit() is called.
*
* @see {Sqlite.shutdown}
* @type {Function|undefined}
*/
#shutdownBlocker;
/** @type {Promise<OpenedConnection>} */
get #connection() {
return this.init().then(() => this.#databaseConnection);
}
/**
* @param {object} [options={}]
* @param {string} [options.basePath=PathUtils.profileDir]
* Base file path to a folder where the database file should live.
* Defaults to the current profile's root directory.
* @returns {Promise<void>}
* After this Promise completes, TabNotes API functions will work.
*/
init(options) {
if (this.#initPromise) {
return this.#initPromise;
}
const basePath = options?.basePath ?? PathUtils.profileDir;
this.dbPath = PathUtils.join(basePath, this.DATABASE_FILE_NAME);
this.#initPromise = Sqlite.openConnection({
path: this.dbPath,
}).then(async connection => {
this.#databaseConnection = connection;
this.#shutdownBlocker = () => this.deinit();
Sqlite.shutdown.addBlocker(
"Closing tabnotes database",
this.#shutdownBlocker
);
await connection.execute("PRAGMA journal_mode = WAL");
await connection.execute("PRAGMA wal_autocheckpoint = 16");
let currentVersion = await connection.getSchemaVersion();
if (currentVersion == 1) {
// tabnotes schema is up to date
return;
}
if (currentVersion == 0) {
// version 0: create `tabnotes` table
await connection.executeTransaction(async () => {
await connection.execute(`
CREATE TABLE IF NOT EXISTS "tabnotes" (
id INTEGER PRIMARY KEY,
canonical_url TEXT NOT NULL,
created INTEGER NOT NULL,
note_text TEXT NOT NULL
);`);
await connection.setSchemaVersion(1);
});
}
});
return this.#initPromise;
}
/**
* @returns {Promise<void>}
*/
deinit() {
if (this.#shutdownBlocker) {
Sqlite.shutdown.removeBlocker(this.#shutdownBlocker);
this.#shutdownBlocker = null;
}
if (this.#databaseConnection) {
return this.#databaseConnection.close().then(() => {
this.#databaseConnection = null;
this.#initPromise = undefined;
});
}
return Promise.resolve();
}
/**
* @param {MozTabbrowserTab} tab
* @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<TabNoteRecord|undefined>}
*/
async get(tab) {
if (!this.isEligible(tab)) {
return undefined;
}
const connection = await this.#connection;
const results = await 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<TabNoteRecord>}
* 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;
}
const connection = await this.#connection;
return connection.executeTransaction(async () => {
if (!existingNote) {
const insertResult = await 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 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<boolean>}
* True if there was a note and it was deleted; false otherwise
*/
async delete(tab, options = {}) {
const connection = await this.#connection;
/** @type {mozIStorageRow[]} */
const deleteResult = await 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<boolean>}
* True if a note is associated with this URL; false otherwise
*/
async has(tab) {
const record = await this.get(tab);
return record !== undefined;
}
/**
* @returns {Promise<number>}
* The number of stored tab notes. Returns 0 if there was a problem
* retrieving the real count.
*/
async count() {
try {
const connection = await this.#connection;
/** @type {mozIStorageRow[]} */
const countResult = await connection.executeCached(GET_NOTE_COUNT);
if (countResult?.length == 1) {
return countResult[0].getDouble(0);
}
} catch {}
return 0;
}
/**
* Clear all notes for all URLs.
*
* @returns {Promise<void>}
*/
async reset() {
const connection = await this.#connection;
return 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();