- Manifest V3 with sidePanel, tabs, webNavigation, unlimitedStorage permissions - Background service worker: sidePanel setup + SPA navigation detection - Shared modules: URL key derivation (buildNoteKey) and IndexedDB storage - Side panel: per-tab markdown editor with debounced autosave, in-panel find - Cross-panel consistency via BroadcastChannel with focus-aware refresh - Notes-list page: day-grouped entries, click-to-focus/navigate behavior - Dark theme by default
177 lines
5.1 KiB
JavaScript
177 lines
5.1 KiB
JavaScript
// IndexedDB storage layer for Side Notes.
|
|
//
|
|
// Schema:
|
|
// Object store: "notes"
|
|
// Key path: id (auto-generated)
|
|
// Indexes: "byKey" (non-unique, on derived URL key)
|
|
// "byUpdated" (on updatedAt)
|
|
//
|
|
// Retrieval always filters `deletedAt == null`, so at most one live note
|
|
// per URL key is visible even if soft-deleted records coexist.
|
|
|
|
const DB_NAME = 'side-notes';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'notes';
|
|
|
|
/** @returns {Promise<IDBDatabase>} */
|
|
function openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
const store = db.createObjectStore(STORE_NAME, {
|
|
keyPath: 'id',
|
|
autoIncrement: true,
|
|
});
|
|
store.createIndex('byKey', 'key', { unique: false });
|
|
store.createIndex('byUpdated', 'updatedAt');
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a unique id. crypto.randomUUID is available in service workers
|
|
* and extension pages in Chromium-based browsers.
|
|
*/
|
|
function generateId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
/**
|
|
* Retrieve the live (non-deleted) note for a given URL key.
|
|
* @param {string} key
|
|
* @returns {Promise<object|null>}
|
|
*/
|
|
export async function getNoteByKey(key) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
const index = tx.objectStore(STORE_NAME).index('byKey');
|
|
const req = index.openCursor(IDBKeyRange.only(key), 'prev');
|
|
req.onsuccess = () => {
|
|
const cursor = req.result;
|
|
if (!cursor) return resolve(null);
|
|
// Skip soft-deleted records; return the first live one (most recent
|
|
// because we iterate in reverse insertion order via 'prev').
|
|
if (cursor.value.deletedAt == null) {
|
|
resolve(cursor.value);
|
|
} else {
|
|
cursor.continue();
|
|
}
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist (insert or update) a note for a given key.
|
|
*
|
|
* The save strategy: look up the existing live note for this key; if one
|
|
* exists, overwrite it in-place. Otherwise insert a new record.
|
|
*
|
|
* @param {string} key
|
|
* @param {string} content — markdown body
|
|
* @param {{ pageTitle?: string, favIconUrl?: string }} pageMeta
|
|
* @returns {Promise<object>} the saved record
|
|
*/
|
|
export async function saveNote(key, content, pageMeta = {}) {
|
|
const db = await openDB();
|
|
const now = new Date().toISOString();
|
|
|
|
// Find the existing live record for this key.
|
|
const existing = await getNoteByKey(key);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
|
|
let req;
|
|
if (existing) {
|
|
// Update in-place.
|
|
const updated = {
|
|
...existing,
|
|
key,
|
|
content,
|
|
pageTitle: pageMeta.pageTitle ?? existing.pageTitle ?? '',
|
|
favIconUrl: pageMeta.favIconUrl ?? existing.favIconUrl ?? '',
|
|
updatedAt: now,
|
|
};
|
|
req = store.put(updated);
|
|
} else {
|
|
// Insert new.
|
|
const record = {
|
|
id: generateId(),
|
|
key,
|
|
content,
|
|
pageTitle: pageMeta.pageTitle ?? '',
|
|
favIconUrl: pageMeta.favIconUrl ?? '',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
deletedAt: null,
|
|
};
|
|
req = store.add(record);
|
|
}
|
|
|
|
req.onsuccess = () => {
|
|
// Return the saved record.
|
|
const getReq = store.get(req.result);
|
|
getReq.onsuccess = () => resolve(getReq.result);
|
|
getReq.onerror = () => reject(getReq.error);
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List all live notes ordered by updatedAt descending.
|
|
* @returns {Promise<object[]>}
|
|
*/
|
|
export async function listNotes() {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
const index = tx.objectStore(STORE_NAME).index('byUpdated');
|
|
const req = index.openCursor(null, 'prev');
|
|
const results = [];
|
|
req.onsuccess = () => {
|
|
const cursor = req.result;
|
|
if (cursor) {
|
|
if (cursor.value.deletedAt == null) {
|
|
results.push(cursor.value);
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
resolve(results);
|
|
}
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Soft-delete a note by id (sets deletedAt). No-op if already deleted.
|
|
* @param {string|number} id
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function markDeleted(id) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const getReq = store.get(id);
|
|
getReq.onsuccess = () => {
|
|
const record = getReq.result;
|
|
if (!record || record.deletedAt != null) {
|
|
resolve();
|
|
return;
|
|
}
|
|
record.deletedAt = new Date().toISOString();
|
|
store.put(record).onsuccess = () => resolve();
|
|
};
|
|
getReq.onerror = () => reject(getReq.error);
|
|
});
|
|
}
|