Greasy Fork is available in English.
IndexedDB storage layer and repositories for Brazen user scripts
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/587030/1876576/Brazen%20Framework%20-%20IndexedDB%20Storage.js
// ==UserScript==
// @name Brazen Framework - IndexedDB Storage
// @namespace brazenvoid
// @version 1.1.0
// @author brazenvoid
// @license GPL-3.0-only
// @description IndexedDB storage layer and repositories for Brazen user scripts
// ==/UserScript==
const IDB_SCHEMA_VERSION = 2
const IDB_STORE_META = 'meta'
const IDB_STORE_SETTINGS = 'settings'
const IDB_STORE_APIS = 'apis'
const IDB_STORE_TAG_TYPES = 'tagTypes'
const IDB_STORE_TAGS = 'tags'
const IDB_STORE_TAG_RULES = 'tagRules'
const IDB_STORE_TAG_RULE_SETS = 'tagRuleSets'
const IDB_STORE_BOOKMARKS = 'bookmarks'
const IDB_STORE_LEDGER = 'ledgerEntries'
const IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE = 'downloadResolutionQueue'
const IDB_STORE_DOWNLOAD_QUEUE = 'downloadQueue'
const IDB_STORE_DOWNLOAD_MANAGER_STATE = 'downloadManagerState'
const IDB_ALL_STORES = [
IDB_STORE_META,
IDB_STORE_SETTINGS,
IDB_STORE_APIS,
IDB_STORE_TAG_TYPES,
IDB_STORE_TAGS,
IDB_STORE_TAG_RULES,
IDB_STORE_TAG_RULE_SETS,
IDB_STORE_BOOKMARKS,
IDB_STORE_LEDGER,
IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
IDB_STORE_DOWNLOAD_QUEUE,
IDB_STORE_DOWNLOAD_MANAGER_STATE,
]
const IDB_ROW_STORES = [
IDB_STORE_TAGS,
IDB_STORE_TAG_RULES,
IDB_STORE_BOOKMARKS,
IDB_STORE_LEDGER,
IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
IDB_STORE_DOWNLOAD_QUEUE,
]
const IDB_SINGLETON_STORES = [
IDB_STORE_SETTINGS,
IDB_STORE_APIS,
IDB_STORE_TAG_TYPES,
IDB_STORE_TAG_RULE_SETS,
IDB_STORE_DOWNLOAD_MANAGER_STATE,
]
const IDB_PUT_MANY_CHUNK = 500
const LEGACY_SOURCE_LOCAL = 'local'
const LEGACY_SOURCE_GM = 'gm'
/** @type {{source: string, read: function(string, *): *, remove: function(string): void}[]} */
const LEGACY_STORAGE_BACKENDS = [
{
source: LEGACY_SOURCE_LOCAL,
read: readLegacyLocal,
remove: removeLegacyLocal,
},
{
source: LEGACY_SOURCE_GM,
read: readLegacyGm,
remove: removeLegacyGm,
},
]
/**
* @param {string} storeKey
* @param {*} [fallback]
* @return {*}
*/
function readLegacyLocal(storeKey, fallback = null)
{
let storedStore = localStorage.getItem(storeKey)
if (!storedStore || storedStore === '') {
return fallback
}
let parsed = JSON.parse(storedStore)
if (parsed && typeof parsed === 'object' && 'arrays' in parsed && 'objects' in parsed && 'properties' in parsed) {
return Utilities.objectFromJSON(storedStore)
}
return parsed
}
/**
* @param {string} storeKey
*/
function removeLegacyLocal(storeKey)
{
localStorage.removeItem(storeKey)
}
/**
* @param {string} storeKey
* @param {*} [fallback]
* @return {*}
*/
function readLegacyGm(storeKey, fallback = null)
{
try {
let value = GM_getValue(storeKey, fallback)
if (value === undefined) {
return fallback
}
return Utilities.reviveGmStoredValue(value)
} catch (error) {
console.log('Failed to read legacy GM storage key:', storeKey, error)
return fallback
}
}
/**
* @param {string} storeKey
*/
function removeLegacyGm(storeKey)
{
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(storeKey)
}
}
/**
* Whether localStorage or GM still holds pre-IDB Brazen settings/bookmarks/ledger keys.
* @param {string} prefix
* @param {string|null|undefined} legacyPrefix
* @return {boolean}
*/
function legacyStorageHasData(prefix, legacyPrefix)
{
for (let backend of LEGACY_STORAGE_BACKENDS) {
if (backend.read(prefix + 'settings', null)) {
return true
}
if (legacyPrefix && legacyPrefix !== prefix && backend.read(legacyPrefix + 'settings', null)) {
return true
}
}
if (readLegacyGm(prefix + 'bookmarks', null) || readLegacyGm(prefix + 'download-ledger', null)) {
return true
}
return false
}
/**
* @param {string} prefix
* @return {Object|null}
*/
function readLegacySettingsAggregate(prefix)
{
return readLegacyLocal(prefix + 'settings', null) ?? readLegacyGm(prefix + 'settings', null)
}
const TAG_RULESET_PROPERTY_BY_FIELD = {
'tag-blacklist': 'tagBlacklist',
'explored-tags-tracker': 'exploredTagsTracker',
'filename-tag-ignore-list': 'filenameTagIgnoreList',
'filename-tag-substitutions': 'filenameTagSubstitutions',
'favourite-tags': 'favouriteTags',
'disliked-tags': 'dislikedTags',
'adult-characters': 'highlightAdult',
'underage-characters': 'highlightUnderage',
'unknown-age-characters': 'highlightUnknownAge',
}
const TAG_RULE_GROUP_BY_FIELD = {
'tag-blacklist': 'blacklist',
'explored-tags-tracker': 'explored',
'filename-tag-ignore-list': 'filenameIgnore',
'filename-tag-substitutions': 'filenameSubstitutions',
'favourite-tags': 'highlightFavourite',
'disliked-tags': 'highlightDisliked',
'adult-characters': 'highlightAdult',
'underage-characters': 'highlightUnderage',
'unknown-age-characters': 'highlightUnknownAge',
}
const TAG_DOMAIN_FIELD_KEYS = new Set([
...Object.keys(TAG_RULESET_PROPERTY_BY_FIELD),
])
function dbNameFromScriptPrefix(scriptPrefix)
{
return String(scriptPrefix).replace(/-$/, '')
}
function fieldKeyToProperty(fieldKey)
{
return String(fieldKey).replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase())
}
function propertyToFieldKey(property)
{
return String(property).replace(/([A-Z])/g, '-$1').replace(/^-/, '').toLowerCase()
}
function yieldToBrowser()
{
return new Promise((resolve) => setTimeout(resolve, 0))
}
function promisifyRequest(request)
{
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
function waitTransaction(tx)
{
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'))
})
}
class BrazenIndexedDBStorage
{
constructor(scriptPrefix)
{
this._scriptPrefix = scriptPrefix
this._dbName = dbNameFromScriptPrefix(scriptPrefix)
this._db = null
this._available = typeof indexedDB !== 'undefined'
this._revisionId = null
}
get available()
{
return this._available
}
get dbName()
{
return this._dbName
}
get database()
{
return this._db
}
/**
* @return {Promise<IDBDatabase>}
*/
async open()
{
if (!this._available) {
throw new Error('IndexedDB is not available')
}
if (this._db) {
return this._db
}
this._db = await new Promise((resolve, reject) => {
let request = indexedDB.open(this._dbName, IDB_SCHEMA_VERSION)
request.onupgradeneeded = (event) => {
let db = event.target.result
BrazenIndexedDBStorage._upgradeSchema(db, event.oldVersion)
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
this._db.onversionchange = () => {
this._db.close()
this._db = null
}
return this._db
}
/**
* @param {IDBDatabase} db
* @param {number} oldVersion
* @private
*/
static _upgradeSchema(db, oldVersion)
{
if (!db.objectStoreNames.contains(IDB_STORE_META)) {
db.createObjectStore(IDB_STORE_META, {keyPath: 'id'})
}
if (!db.objectStoreNames.contains(IDB_STORE_SETTINGS)) {
db.createObjectStore(IDB_STORE_SETTINGS, {keyPath: 'id'})
}
if (!db.objectStoreNames.contains(IDB_STORE_APIS)) {
db.createObjectStore(IDB_STORE_APIS, {keyPath: 'id'})
}
if (!db.objectStoreNames.contains(IDB_STORE_TAG_TYPES)) {
db.createObjectStore(IDB_STORE_TAG_TYPES, {keyPath: 'id'})
}
if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULE_SETS)) {
db.createObjectStore(IDB_STORE_TAG_RULE_SETS, {keyPath: 'id'})
}
if (!db.objectStoreNames.contains(IDB_STORE_TAGS)) {
let tags = db.createObjectStore(IDB_STORE_TAGS, {keyPath: 'entryId'})
tags.createIndex('name', 'name', {unique: true})
tags.createIndex('typeEntryId_name', ['typeEntryId', 'name'], {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULES)) {
let rules = db.createObjectStore(IDB_STORE_TAG_RULES, {keyPath: 'entryId'})
rules.createIndex('group_sortOrder', ['group', 'sortOrder'], {unique: false})
rules.createIndex('group_entryId', ['group', 'entryId'], {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_BOOKMARKS)) {
let bookmarks = db.createObjectStore(IDB_STORE_BOOKMARKS, {keyPath: 'entryId'})
bookmarks.createIndex('sortOrder', 'sortOrder', {unique: false})
bookmarks.createIndex('label', 'label', {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_LEDGER)) {
let ledger = db.createObjectStore(IDB_STORE_LEDGER, {keyPath: 'entryId'})
ledger.createIndex('postId', 'postId', {unique: true})
ledger.createIndex('claimedAt', 'claimedAt', {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)) {
let resolutionQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, {keyPath: 'itemId'})
resolutionQueue.createIndex('status', 'status', {unique: false})
resolutionQueue.createIndex('addedAt', 'addedAt', {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_QUEUE)) {
let downloadQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_QUEUE, {keyPath: 'itemId'})
downloadQueue.createIndex('status', 'status', {unique: false})
downloadQueue.createIndex('addedAt', 'addedAt', {unique: false})
}
if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_MANAGER_STATE)) {
db.createObjectStore(IDB_STORE_DOWNLOAD_MANAGER_STATE, {keyPath: 'id'})
}
void oldVersion
}
/**
* @return {Promise<boolean>}
*/
async isHealthy()
{
try {
await this.open()
for (let storeName of IDB_ALL_STORES) {
if (!this._db.objectStoreNames.contains(storeName)) {
return false
}
}
let meta = await this.get(IDB_STORE_META, 'meta')
if (!meta || meta.setupComplete !== true) {
return false
}
for (let key of ['nextTagEntryId', 'nextTagRuleEntryId', 'nextBookmarkEntryId', 'nextLedgerEntryId']) {
if (typeof meta[key] !== 'number' || meta[key] < 1) {
return false
}
}
let settings = await this.get(IDB_STORE_SETTINGS, 'settings')
let tagRuleSets = await this.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')
if (!settings || !tagRuleSets) {
return false
}
return true
} catch (error) {
console.log('[BrazenIDB] health check failed:', error)
return false
}
}
/**
* @return {Promise<void>}
*/
async deleteDatabase()
{
if (this._db) {
this._db.close()
this._db = null
}
if (!this._available) {
return
}
await new Promise((resolve, reject) => {
let request = indexedDB.deleteDatabase(this._dbName)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
request.onblocked = () => resolve()
})
}
/**
* @param {string[]} storeNames
* @param {IDBTransactionMode} mode
* @return {IDBTransaction}
*/
transaction(storeNames, mode = 'readonly')
{
return this._db.transaction(storeNames, mode)
}
/**
* @param {string} store
* @param {*} pk
* @return {Promise<*>}
*/
async get(store, pk)
{
let tx = this.transaction([store], 'readonly')
return promisifyRequest(tx.objectStore(store).get(pk))
}
/**
* @param {string} store
* @return {Promise<*[]>}
*/
async getAll(store)
{
let tx = this.transaction([store], 'readonly')
return promisifyRequest(tx.objectStore(store).getAll())
}
/**
* @param {string} store
* @param {*} record
* @return {Promise<void>}
*/
async put(store, record)
{
let tx = this.transaction([store], 'readwrite')
tx.objectStore(store).put(record)
await waitTransaction(tx)
}
/**
* @param {string} store
* @param {*} pk
* @return {Promise<void>}
*/
async delete(store, pk)
{
let tx = this.transaction([store], 'readwrite')
tx.objectStore(store).delete(pk)
await waitTransaction(tx)
}
/**
* @param {string} store
* @return {Promise<void>}
*/
async clearStore(store)
{
let tx = this.transaction([store], 'readwrite')
tx.objectStore(store).clear()
await waitTransaction(tx)
}
/**
* @param {string} store
* @param {*[]} records
* @param {number} chunkSize
* @return {Promise<void>}
*/
async putMany(store, records, chunkSize = IDB_PUT_MANY_CHUNK)
{
if (!records.length) {
return
}
for (let index = 0; index < records.length; index += chunkSize) {
let chunk = records.slice(index, index + chunkSize)
let tx = this.transaction([store], 'readwrite')
let objectStore = tx.objectStore(store)
for (let record of chunk) {
objectStore.put(record)
}
await waitTransaction(tx)
await yieldToBrowser()
}
}
/**
* @param {string} store
* @param {IDBKeyRange|null} range
* @param {number} limit
* @return {Promise<{entries: *[], nextCursor: *}>}
*/
async cursorPage(store, range = null, limit = 50, startAfter = null)
{
let tx = this.transaction([store], 'readonly')
let objectStore = tx.objectStore(store)
let effectiveRange = range
if (startAfter !== null && startAfter !== undefined) {
effectiveRange = range ?
IDBKeyRange.bound(range.lower, range.upper, range.lowerOpen, range.upperOpen) :
IDBKeyRange.lowerBound(startAfter, true)
}
let request = objectStore.openCursor(effectiveRange)
let entries = []
let nextCursor = null
await new Promise((resolve, reject) => {
request.onsuccess = () => {
let cursor = request.result
if (!cursor || entries.length >= limit) {
if (cursor) {
nextCursor = cursor.key
}
resolve()
return
}
entries.push(cursor.value)
cursor.continue()
}
request.onerror = () => reject(request.error)
})
return {entries, nextCursor}
}
/**
* @param {string} store
* @param {number} chunkSize
* @return {Promise<*[]>}
*/
async getAllChunked(store, chunkSize = IDB_PUT_MANY_CHUNK)
{
let entries = []
let startAfter = null
while (true) {
let page = await this.cursorPage(store, null, chunkSize, startAfter)
entries.push(...page.entries)
if (!page.nextCursor) {
break
}
startAfter = page.nextCursor
await yieldToBrowser()
}
return entries
}
/**
* @return {Promise<number>}
*/
async getRevisionId()
{
let meta = await this.getMeta()
return meta?.revisionId ?? 0
}
/**
* @return {Promise<object>}
*/
async getMeta()
{
return (await this.get(IDB_STORE_META, 'meta')) ?? null
}
/**
* @param {Function} fn
* @return {Promise<*>}
*/
async guardedWrite(fn)
{
let revisionBefore = await this.getRevisionId()
let result = await fn()
let revisionAfter = await this.getRevisionId()
if (revisionBefore !== revisionAfter) {
this._revisionId = revisionAfter
}
return result
}
/**
* @return {Promise<object>}
*/
async createDefaultMeta()
{
return {
id: 'meta',
revisionId: Utilities.generateId(),
schemaVersion: IDB_SCHEMA_VERSION,
scriptPrefix: this._scriptPrefix,
setupComplete: false,
setupInProgress: false,
schemaMigrationState: 'idle',
compilePendingGroups: [],
nextTagEntryId: 1,
nextTagRuleEntryId: 1,
nextBookmarkEntryId: 1,
nextLedgerEntryId: 1,
migrationSources: [],
}
}
/**
* @return {Promise<object>}
*/
async createEmptySettingsDocument()
{
return {id: 'settings'}
}
/**
* @return {Promise<object>}
*/
async createEmptyApisDocument()
{
return {id: 'apis', entries: []}
}
/**
* @return {Promise<object>}
*/
async createEmptyTagTypesDocument()
{
return {id: 'tagTypes', entries: []}
}
/**
* @return {Promise<object>}
*/
async createEmptyTagRuleSetsDocument()
{
return {id: 'tagRuleSets'}
}
}
class MetaRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
*/
constructor(storage)
{
this._storage = storage
}
async get()
{
return this._storage.getMeta()
}
async put(meta)
{
await this._storage.put(IDB_STORE_META, meta)
}
async bumpRevision()
{
let meta = await this.get()
if (!meta) {
return 0
}
meta.revisionId = Utilities.generateId()
await this.put(meta)
this._storage._revisionId = meta.revisionId
return meta.revisionId
}
async waitForSetupComplete(timeoutMs = 120000)
{
let started = Date.now()
while (Date.now() - started < timeoutMs) {
let meta = await this.get()
if (meta?.setupComplete && !meta?.setupInProgress) {
return true
}
if (meta?.setupInProgress) {
await Utilities.sleep(200)
continue
}
return false
}
throw new Error('Timed out waiting for setup to complete')
}
async beginSetup()
{
let meta = await this.get() ?? await this._storage.createDefaultMeta()
if (meta.setupInProgress) {
await this.waitForSetupComplete()
return this.get()
}
meta.setupInProgress = true
meta.schemaMigrationState = 'idle'
await this.put(meta)
return meta
}
async completeSetup(sources = [])
{
let meta = await this.get() ?? await this._storage.createDefaultMeta()
meta.setupComplete = true
meta.setupInProgress = false
meta.migratedAt = Date.now()
meta.migrationSources = sources
await this.put(meta)
return meta
}
async markCompilePending(groups)
{
let meta = await this.get()
if (!meta) {
return
}
let pending = new Set(meta.compilePendingGroups ?? [])
for (let group of groups) {
pending.add(group)
}
meta.compilePendingGroups = [...pending]
await this.put(meta)
}
async clearCompilePending(groups)
{
let meta = await this.get()
if (!meta) {
return
}
let pending = new Set(meta.compilePendingGroups ?? [])
for (let group of groups) {
pending.delete(group)
}
meta.compilePendingGroups = [...pending]
await this.put(meta)
}
async allocateEntryId(counterKey)
{
let meta = await this.get()
if (!meta) {
throw new Error('Meta record missing')
}
let value = meta[counterKey] ?? 1
meta[counterKey] = value + 1
await this.put(meta)
return value
}
}
class SettingsRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
*/
constructor(storage)
{
this._storage = storage
}
async getDocument()
{
return (await this._storage.get(IDB_STORE_SETTINGS, 'settings')) ?? {id: 'settings'}
}
async putDocument(doc)
{
doc.id = 'settings'
await this._storage.put(IDB_STORE_SETTINGS, doc)
}
/**
* @param {string} fieldKey
* @return {Promise<{value: *, optimized: *, updatedAt: number}|null>}
*/
async getField(fieldKey)
{
let doc = await this.getDocument()
let property = fieldKeyToProperty(fieldKey)
return doc[property] ?? null
}
async getValue(fieldKey)
{
let field = await this.getField(fieldKey)
return field?.value ?? null
}
async getOptimized(fieldKey)
{
let field = await this.getField(fieldKey)
return field?.optimized ?? null
}
/**
* @param {string} fieldKey
* @param {*} value
* @param {*} optimized
* @return {Promise<void>}
*/
async putField(fieldKey, value, optimized = null)
{
let doc = await this.getDocument()
let property = fieldKeyToProperty(fieldKey)
doc[property] = {
value,
optimized,
updatedAt: Date.now(),
}
await this.putDocument(doc)
}
}
class BookmarkRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {function(string): void|null} onChange
*/
constructor(storage, metaRepo, onChange = null)
{
this._storage = storage
this._metaRepo = metaRepo
this._onChange = onChange
}
/**
* @private
*/
_notifyChange()
{
if (this._onChange) {
this._onChange('bookmarks')
}
}
async listAll()
{
let rows = await this._storage.getAll(IDB_STORE_BOOKMARKS)
return rows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
}
async get(entryId)
{
return this._storage.get(IDB_STORE_BOOKMARKS, entryId)
}
async add(entry)
{
let entryId = entry.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
let now = Date.now()
let row = {
entryId,
label: entry.label ?? '',
tags: entry.tags ?? '',
url: entry.url ?? '',
sortOrder: entry.sortOrder ?? 0,
createdAt: entry.createdAt ?? now,
updatedAt: now,
}
await this._storage.put(IDB_STORE_BOOKMARKS, row)
await this._metaRepo.bumpRevision()
this._notifyChange()
return row
}
async update(row)
{
row.updatedAt = Date.now()
await this._storage.put(IDB_STORE_BOOKMARKS, row)
await this._metaRepo.bumpRevision()
this._notifyChange()
return row
}
async remove(entryId)
{
await this._storage.delete(IDB_STORE_BOOKMARKS, entryId)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
async replaceAll(rows)
{
await this._storage.clearStore(IDB_STORE_BOOKMARKS)
let sortOrder = 0
let normalized = []
for (let row of rows) {
let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
normalized.push({
entryId,
label: row.label ?? '',
tags: row.tags ?? '',
url: row.url ?? '',
sortOrder: row.sortOrder ?? sortOrder++,
createdAt: row.createdAt ?? Date.now(),
updatedAt: row.updatedAt ?? Date.now(),
})
}
await this._storage.putMany(IDB_STORE_BOOKMARKS, normalized)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
}
class LedgerRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {function(string): void|null} onChange
*/
constructor(storage, metaRepo, onChange = null)
{
this._storage = storage
this._metaRepo = metaRepo
this._onChange = onChange
}
/**
* @private
*/
_notifyChange()
{
if (this._onChange) {
this._onChange('ledger')
}
}
async has(postId)
{
let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
return !!(await promisifyRequest(index.get(String(postId))))
}
async claim(postId)
{
let normalized = String(postId).trim()
if (!normalized) {
return false
}
if (await this.has(normalized)) {
return false
}
let entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
await this._storage.put(IDB_STORE_LEDGER, {
entryId,
postId: normalized,
claimedAt: Date.now(),
})
await this._metaRepo.bumpRevision()
await this._trimToMax()
this._notifyChange()
return true
}
async _trimToMax(maxEntries = 25000)
{
let rows = await this._storage.getAll(IDB_STORE_LEDGER)
if (rows.length <= maxEntries) {
return
}
rows.sort((left, right) => left.claimedAt - right.claimedAt)
let removeCount = rows.length - maxEntries
let toRemove = rows.slice(0, removeCount)
for (let row of toRemove) {
await this._storage.delete(IDB_STORE_LEDGER, row.entryId)
}
}
async mergeRows(rows)
{
for (let row of rows) {
let postId = String(row.postId ?? row.id ?? '').trim()
if (!postId) {
continue
}
let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
let existing = await promisifyRequest(tx.objectStore(IDB_STORE_LEDGER).index('postId').get(postId))
if (!existing || (row.claimedAt ?? 0) >= (existing.claimedAt ?? 0)) {
if (!row.entryId) {
row.entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
}
row.postId = postId
await this._storage.put(IDB_STORE_LEDGER, row)
}
}
await this._metaRepo.bumpRevision()
this._notifyChange()
}
async replaceAll(rows)
{
await this._storage.clearStore(IDB_STORE_LEDGER)
let normalized = []
for (let row of rows) {
let postId = String(row.postId ?? row.id ?? '').trim()
if (!postId) {
continue
}
let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextLedgerEntryId')
normalized.push({
entryId,
postId,
claimedAt: row.claimedAt ?? Date.now(),
})
}
await this._storage.putMany(IDB_STORE_LEDGER, normalized)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
}
/**
* @return {object}
*/
function createDefaultDownloadManagerState()
{
return {
id: 'state',
paused: true,
resolutionBlocked: false,
resolutionBlockedItemId: null,
processingTabId: null,
lastResolutionInitiationAt: 0,
lastDownloadInitiationAt: 0,
tagDiscoveryEnabled: false,
tagDiscoveryPanelTabId: null,
discoveryPanelTags: null,
pendingImmediateDownload: null,
completedResolutionCount: 0,
completedDownloadCount: 0,
}
}
class DownloadResolutionQueueRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {function(string): void|null} onChange
*/
constructor(storage, metaRepo, onChange = null)
{
this._storage = storage
this._metaRepo = metaRepo
this._onChange = onChange
}
/**
* @private
*/
_notifyChange()
{
if (this._onChange) {
this._onChange('downloadResolutionQueue')
}
}
async listAll()
{
let rows = await this._storage.getAll(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)
return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
}
async get(itemId)
{
return this._storage.get(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, itemId)
}
async put(row)
{
await this._storage.put(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, row)
await this._metaRepo.bumpRevision()
this._notifyChange()
return row
}
async remove(itemId)
{
await this._storage.delete(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, itemId)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
async clearAll()
{
await this._storage.clearStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
}
class DownloadQueueRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {function(string): void|null} onChange
*/
constructor(storage, metaRepo, onChange = null)
{
this._storage = storage
this._metaRepo = metaRepo
this._onChange = onChange
}
/**
* @private
*/
_notifyChange()
{
if (this._onChange) {
this._onChange('downloadQueue')
}
}
async listAll()
{
let rows = await this._storage.getAll(IDB_STORE_DOWNLOAD_QUEUE)
return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
}
async get(itemId)
{
return this._storage.get(IDB_STORE_DOWNLOAD_QUEUE, itemId)
}
async put(row)
{
await this._storage.put(IDB_STORE_DOWNLOAD_QUEUE, row)
await this._metaRepo.bumpRevision()
this._notifyChange()
return row
}
async remove(itemId)
{
await this._storage.delete(IDB_STORE_DOWNLOAD_QUEUE, itemId)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
async clearAll()
{
await this._storage.clearStore(IDB_STORE_DOWNLOAD_QUEUE)
await this._metaRepo.bumpRevision()
this._notifyChange()
}
}
class DownloadManagerStateRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {function(string): void|null} onChange
*/
constructor(storage, metaRepo, onChange = null)
{
this._storage = storage
this._metaRepo = metaRepo
this._onChange = onChange
}
/**
* @private
*/
_notifyChange()
{
if (this._onChange) {
this._onChange('downloadManagerState')
}
}
async get()
{
return (await this._storage.get(IDB_STORE_DOWNLOAD_MANAGER_STATE, 'state')) ?? createDefaultDownloadManagerState()
}
async put(state)
{
state.id = 'state'
await this._storage.put(IDB_STORE_DOWNLOAD_MANAGER_STATE, state)
await this._metaRepo.bumpRevision()
this._notifyChange()
return state
}
async reset()
{
return this.put(createDefaultDownloadManagerState())
}
}
class TagRuleRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
*/
constructor(storage, metaRepo)
{
this._storage = storage
this._metaRepo = metaRepo
}
async listByGroup(group)
{
let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_sortOrder')
let range = IDBKeyRange.bound([group, 0], [group, Number.MAX_SAFE_INTEGER])
return promisifyRequest(index.getAll(range))
}
/**
* @param {string} group
* @param {number|null} cursor
* @param {number} limit
* @return {Promise<{entries: *[], nextCursor: number|null}>}
*/
async listPaginated(group, cursor = null, limit = 50)
{
let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_entryId')
let lower = cursor === null ? [group, 0] : [group, cursor]
let range = IDBKeyRange.bound(lower, [group, Number.MAX_SAFE_INTEGER])
let request = index.openCursor(range)
let entries = []
let nextCursor = null
await new Promise((resolve, reject) => {
request.onsuccess = () => {
let row = request.result
if (!row || entries.length >= limit) {
if (row) {
nextCursor = row.value.entryId
}
resolve()
return
}
entries.push(row.value)
row.continue()
}
request.onerror = () => reject(request.error)
})
return {entries, nextCursor}
}
/**
* @param {string} group
* @param {string} tagName
* @return {Promise<boolean>}
*/
async hasSoleTagRule(group, tagName)
{
let rules = await this.listByGroup(group)
return rules.some((rule) => TagRuleRepository._isSoleTagRule(rule, tagName))
}
async addRule(group, rule)
{
let entryId = rule.entryId ?? await this._metaRepo.allocateEntryId('nextTagRuleEntryId')
let row = {
entryId,
group,
sortOrder: rule.sortOrder ?? 0,
rawLine: rule.rawLine ?? '',
mode: rule.mode ?? 'tags',
selectors: rule.selectors ?? null,
expression: rule.expression ?? null,
comment: rule.comment ?? '',
updatedAt: Date.now(),
}
await this._storage.put(IDB_STORE_TAG_RULES, row)
await this._metaRepo.markCompilePending([group])
await this._metaRepo.bumpRevision()
return row
}
async removeRule(entryId, group = null)
{
await this._storage.delete(IDB_STORE_TAG_RULES, entryId)
if (group) {
await this._metaRepo.markCompilePending([group])
}
await this._metaRepo.bumpRevision()
}
async removeAllInGroup(group)
{
let rules = await this.listByGroup(group)
for (let rule of rules) {
await this._storage.delete(IDB_STORE_TAG_RULES, rule.entryId)
}
if (rules.length) {
await this._metaRepo.markCompilePending([group])
await this._metaRepo.bumpRevision()
}
}
async removeSoleTagRules(group, tagName)
{
let rules = await this.listByGroup(group)
let removed = false
for (let rule of rules) {
if (TagRuleRepository._isSoleTagRule(rule, tagName)) {
await this.removeRule(rule.entryId, group)
removed = true
}
}
if (removed) {
await this._metaRepo.markCompilePending([group])
}
}
/**
* @param {*} rule
* @param {string} tagName
* @return {boolean}
* @private
*/
static _isSoleTagRule(rule, tagName)
{
let raw = String(rule.rawLine ?? '').split('//')[0].trim()
if (!raw || raw.includes('&') || raw.includes('|')) {
return false
}
return raw.trim() === tagName
}
}
class TagRepository
{
/**
* @param {BrazenIndexedDBStorage} storage
* @param {MetaRepository} metaRepo
* @param {TagRuleRepository} tagRuleRepo
*/
constructor(storage, metaRepo, tagRuleRepo)
{
this._storage = storage
this._metaRepo = metaRepo
this._tagRuleRepo = tagRuleRepo
}
async getByName(name)
{
let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
return promisifyRequest(tx.objectStore(IDB_STORE_TAGS).index('name').get(name))
}
async registerTag(name, typeEntryId = null)
{
let existing = await this.getByName(name)
if (existing) {
existing.meta.updatedAt = Date.now()
await this._storage.guardedWrite(async () => {
await this._storage.put(IDB_STORE_TAGS, existing)
await this._metaRepo.bumpRevision()
})
return existing
}
let entryId = await this._metaRepo.allocateEntryId('nextTagEntryId')
let now = Date.now()
let row = {
entryId,
name,
typeEntryId,
download: {
filenameIgnored: false,
substitution: null,
},
meta: {
createdAt: now,
updatedAt: now,
},
}
await this._storage.guardedWrite(async () => {
await this._storage.put(IDB_STORE_TAGS, row)
await this._metaRepo.bumpRevision()
})
return row
}
/**
* @param {{cursor?: number|null, limit?: number, typeEntryId?: number|null, namePrefix?: string|null}} options
* @return {Promise<{entries: *[], nextCursor?: number}>}
*/
async listTags(options = {})
{
let limit = options.limit ?? 50
let store = IDB_STORE_TAGS
if (options.namePrefix) {
let prefix = String(options.namePrefix)
let range = IDBKeyRange.bound(prefix, prefix + '\uffff')
let page = await this._storage.cursorPage(store, range, limit, options.cursor ?? null)
return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
}
if (options.typeEntryId !== null && options.typeEntryId !== undefined) {
let tx = this._storage.transaction([store], 'readonly')
let index = tx.objectStore(store).index('typeEntryId_name')
let range = IDBKeyRange.bound([options.typeEntryId, ''], [options.typeEntryId, '\uffff'])
let request = index.openCursor(range)
let entries = []
let nextCursor = null
await new Promise((resolve, reject) => {
request.onsuccess = () => {
let row = request.result
if (!row || entries.length >= limit) {
if (row) {
nextCursor = row.value.entryId
}
resolve()
return
}
entries.push(row.value)
row.continue()
}
request.onerror = () => reject(request.error)
})
return {entries, nextCursor: nextCursor ?? undefined}
}
let page = await this._storage.cursorPage(store, null, limit, options.cursor ?? null)
return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
}
async getTagRuleSetsDocument()
{
return (await this._storage.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')) ?? {id: 'tagRuleSets'}
}
async putTagRuleSetsDocument(doc)
{
doc.id = 'tagRuleSets'
await this._storage.put(IDB_STORE_TAG_RULE_SETS, doc)
}
async getCompiledField(fieldKey)
{
let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
if (!property) {
return null
}
let doc = await this.getTagRuleSetsDocument()
return doc[property] ?? null
}
/**
* @param {string} fieldKey
* @param {string[]} rawLines
* @param {*} optimized
* @return {Promise<void>}
*/
async putCompiledField(fieldKey, rawLines, optimized)
{
let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
if (!property) {
return
}
let doc = await this.getTagRuleSetsDocument()
doc[property] = {
rawLines,
optimized,
updatedAt: Date.now(),
}
await this.putTagRuleSetsDocument(doc)
}
/**
* @param {string} group
* @param {Function} optimizeFn
* @param {boolean} useSelectors
* @return {Promise<void>}
*/
async compileGroup(group, optimizeFn, useSelectors = false)
{
let rules = await this._tagRuleRepo.listByGroup(group)
let rawLines = rules.map((rule) => rule.rawLine).filter(Boolean)
let fieldKey = Object.entries(TAG_RULE_GROUP_BY_FIELD).find(([, g]) => g === group)?.[0]
if (!fieldKey) {
return
}
let optimized = optimizeFn(rawLines, useSelectors)
await this.putCompiledField(fieldKey, rawLines, optimized)
await this._metaRepo.clearCompilePending([group])
}
async compilePendingGroups(optimizeFn, useSelectorsByGroup = {})
{
let meta = await this._metaRepo.get()
let groups = meta?.compilePendingGroups ?? []
for (let group of groups) {
await this.compileGroup(group, optimizeFn, !!useSelectorsByGroup[group])
}
}
}
class BrazenStorageRepositories
{
/**
* @param {string} scriptPrefix
* @param {function(string): void|null} [onRepositoryChange]
*/
constructor(scriptPrefix, onRepositoryChange = null)
{
this.storage = new BrazenIndexedDBStorage(scriptPrefix)
this.meta = new MetaRepository(this.storage)
this.settings = new SettingsRepository(this.storage)
this.bookmarks = new BookmarkRepository(this.storage, this.meta, onRepositoryChange)
this.ledger = new LedgerRepository(this.storage, this.meta, onRepositoryChange)
this.downloadResolutionQueue = new DownloadResolutionQueueRepository(this.storage, this.meta, onRepositoryChange)
this.downloadQueue = new DownloadQueueRepository(this.storage, this.meta, onRepositoryChange)
this.downloadManagerState = new DownloadManagerStateRepository(this.storage, this.meta, onRepositoryChange)
this.tagRules = new TagRuleRepository(this.storage, this.meta)
this.tags = new TagRepository(this.storage, this.meta, this.tagRules)
}
}
/**
* Expand legacy OR rules into separate lines (no | in output).
* @param {string} line
* @return {string[]}
*/
function expandOrRuleLine(line)
{
let commentIndex = line.indexOf('//')
let rulePart = commentIndex >= 0 ? line.slice(0, commentIndex) : line
let comment = commentIndex >= 0 ? line.slice(commentIndex) : ''
let segments = rulePart.split('&')
let expanded = ['']
for (let segment of segments) {
let alternatives = segment.split('|').map((part) => part.trim()).filter(Boolean)
if (alternatives.length <= 1) {
expanded = expanded.map((prefix) => prefix ? prefix + '&' + segment.trim() : segment.trim())
continue
}
let next = []
for (let prefix of expanded) {
for (let alt of alternatives) {
next.push(prefix ? prefix + '&' + alt : alt)
}
}
expanded = next
}
return expanded.filter(Boolean).map((rule) => rule + comment)
}
/**
* Minimal ZIP writer (STORE method, no compression) for backup bundles.
*/
class BrazenZipWriter
{
constructor()
{
this._files = []
}
/**
* @param {string} name
* @param {string} content
*/
addFile(name, content)
{
this._files.push({name, content})
}
/**
* @return {Blob}
*/
build()
{
let parts = []
let centralDirectory = []
let offset = 0
for (let file of this._files) {
let nameBytes = new TextEncoder().encode(file.name)
let dataBytes = new TextEncoder().encode(file.content)
let header = new DataView(new ArrayBuffer(30))
header.setUint32(0, 0x04034b50, true)
header.setUint16(4, 20, true)
header.setUint16(6, 0, true)
header.setUint16(8, 0, true)
header.setUint16(10, 0, true)
header.setUint16(12, 0, true)
header.setUint32(14, 0, true)
header.setUint32(18, dataBytes.length, true)
header.setUint32(22, dataBytes.length, true)
header.setUint16(26, nameBytes.length, true)
header.setUint16(28, 0, true)
parts.push(new Uint8Array(header.buffer), nameBytes, dataBytes)
let cdHeader = new DataView(new ArrayBuffer(46))
cdHeader.setUint32(0, 0x02014b50, true)
cdHeader.setUint16(4, 20, true)
cdHeader.setUint16(6, 20, true)
cdHeader.setUint16(8, 0, true)
cdHeader.setUint16(10, 0, true)
cdHeader.setUint16(12, 0, true)
cdHeader.setUint16(14, 0, true)
cdHeader.setUint32(16, 0, true)
cdHeader.setUint32(20, dataBytes.length, true)
cdHeader.setUint32(24, dataBytes.length, true)
cdHeader.setUint16(28, nameBytes.length, true)
cdHeader.setUint16(30, 0, true)
cdHeader.setUint16(32, 0, true)
cdHeader.setUint16(34, 0, true)
cdHeader.setUint16(36, 0, true)
cdHeader.setUint32(38, 0, true)
cdHeader.setUint32(42, offset, true)
centralDirectory.push(new Uint8Array(cdHeader.buffer), nameBytes)
offset += 30 + nameBytes.length + dataBytes.length
}
let centralStart = offset
for (let part of centralDirectory) {
parts.push(part)
offset += part.length
}
let end = new DataView(new ArrayBuffer(22))
end.setUint32(0, 0x06054b50, true)
end.setUint16(4, 0, true)
end.setUint16(6, 0, true)
end.setUint16(8, this._files.length, true)
end.setUint16(10, this._files.length, true)
end.setUint32(12, centralDirectory.reduce((sum, part) => sum + part.length, 0), true)
end.setUint32(16, centralStart, true)
end.setUint16(20, 0, true)
parts.push(new Uint8Array(end.buffer))
return new Blob(parts, {type: 'application/zip'})
}
}
/**
* Minimal ZIP reader (STORE method only) for backup restore.
*/
class BrazenZipReader
{
/**
* @param {ArrayBuffer} buffer
* @return {Record<string, string>}
*/
static parse(buffer)
{
let view = new DataView(buffer)
let files = {}
let offset = 0
while (offset + 30 <= buffer.byteLength) {
let signature = view.getUint32(offset, true)
if (signature === 0x04034b50) {
let compression = view.getUint16(offset + 8, true)
let compressedSize = view.getUint32(offset + 18, true)
let nameLength = view.getUint16(offset + 26, true)
let extraLength = view.getUint16(offset + 28, true)
let nameStart = offset + 30
let name = new TextDecoder().decode(new Uint8Array(buffer, nameStart, nameLength))
let dataStart = nameStart + nameLength + extraLength
if (compression !== 0) {
throw new Error('Compressed zip entries are not supported: ' + name)
}
let dataBytes = new Uint8Array(buffer, dataStart, compressedSize)
files[name] = new TextDecoder().decode(dataBytes)
offset = dataStart + compressedSize
continue
}
if (signature === 0x02014b50 || signature === 0x06054b50) {
break
}
break
}
return files
}
}
class BrazenLegacyImporter
{
/**
* @param {BrazenStorageRepositories} repos
* @param {BrazenConfigurationManager} manager
*/
constructor(repos, manager)
{
this._repos = repos
this._manager = manager
this._summary = {}
}
/**
* @return {Promise<string[]>}
*/
async run()
{
let sources = []
let prefix = this._manager._scriptPrefix
let legacyPrefix = this._manager._legacyScriptPrefix
this._errors = []
await this._importSettingsRevision(prefix, legacyPrefix, sources)
await this._importSettings(prefix, legacyPrefix, sources)
await this._importBookmarks(prefix, legacyPrefix, sources)
await this._importLedger(prefix, legacyPrefix, sources)
await this._verifyAndPurgeLegacy(prefix, legacyPrefix, sources)
console.log('[BrazenIDB] legacy import summary:', this._summary, 'sources:', sources, 'errors:', this._errors)
if (this._summary.importedTotal > 0) {
alert('Brazen script — settings migrated to IndexedDB (' + this._summary.importedTotal + ' records).')
}
return sources
}
async _importSettingsRevision(prefix, legacyPrefix, sources)
{
for (let backend of LEGACY_STORAGE_BACKENDS) {
for (let activePrefix of [prefix, legacyPrefix]) {
if (!activePrefix) {
continue
}
let revision = backend.read(activePrefix + 'settings-id', null)
if (typeof revision === 'number' || (typeof revision === 'string' && revision.trim())) {
let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
meta.revisionId = String(revision)
await this._repos.meta.put(meta)
if (!sources.includes(backend.source)) {
sources.push(backend.source)
}
this._summary.settingsRevision = meta.revisionId
return
}
}
}
}
async _verifyAndPurgeLegacy(prefix, legacyPrefix, sources)
{
let settingsDoc = await this._repos.settings.getDocument()
let bookmarkCount = (await this._repos.bookmarks.listAll()).length
let ledgerCount = (await this._repos.storage.getAll(IDB_STORE_LEDGER)).length
let tagCount = (await this._repos.storage.getAll(IDB_STORE_TAGS)).length
let tagRuleCount = (await this._repos.storage.getAll(IDB_STORE_TAG_RULES)).length
let expectedSettings = this._summary.settings ?? 0
if (expectedSettings && Object.keys(settingsDoc).length <= 1) {
throw new Error('Legacy settings import verification failed')
}
if ((this._summary.bookmarksExpected ?? 0) > 0 && bookmarkCount === 0) {
throw new Error('Legacy bookmark import verification failed')
}
if ((this._summary.ledgerExpected ?? 0) > 0 && ledgerCount === 0) {
throw new Error('Legacy ledger import verification failed')
}
this._summary.importedTotal = (this._summary.settings ?? 0) + bookmarkCount + ledgerCount + tagCount + tagRuleCount
this._summary.migrationSources = [...sources]
for (let backend of LEGACY_STORAGE_BACKENDS) {
this._safeRemove(backend, prefix + 'settings')
this._safeRemove(backend, prefix + 'settings-id')
this._safeRemove(backend, prefix + 'bookmarks')
this._safeRemove(backend, prefix + 'download-ledger')
if (legacyPrefix && legacyPrefix !== prefix) {
this._safeRemove(backend, legacyPrefix + 'settings')
this._safeRemove(backend, legacyPrefix + 'settings-id')
this._safeRemove(backend, legacyPrefix + 'bookmarks')
}
}
let ledgerPrefixes = [prefix + 'download-ledger/e/']
if (legacyPrefix) {
ledgerPrefixes.push(legacyPrefix + 'download-ledger/e/')
}
if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') {
for (let storeKey of GM_listValues()) {
if (ledgerPrefixes.some((ledgerPrefix) => storeKey.startsWith(ledgerPrefix))) {
try {
GM_deleteValue(storeKey)
} catch (error) {
this._errors.push('ledger-delete:' + storeKey)
console.log('[BrazenIDB] failed to delete ledger key:', storeKey, error)
}
}
}
}
}
_safeRemove(backend, key)
{
if (!key) {
return
}
try {
if (backend.read(key, null) !== null) {
backend.remove(key)
}
} catch (error) {
console.log('[BrazenIDB] failed to purge legacy key:', key, error)
}
}
async _importSettings(prefix, legacyPrefix, sources)
{
let aggregate = null
for (let backend of LEGACY_STORAGE_BACKENDS) {
let current = this._coerceSettingsBlob(backend.read(prefix + 'settings', null))
if (current.settings) {
aggregate = {...(aggregate ?? {}), ...current.settings}
if (!sources.includes(backend.source)) {
sources.push(backend.source)
}
} else if (current.invalid) {
this._errors.push('settings-invalid:' + backend.source)
}
if (legacyPrefix && legacyPrefix !== prefix) {
let legacy = this._coerceSettingsBlob(backend.read(legacyPrefix + 'settings', null))
if (legacy.settings) {
aggregate = {...legacy.settings, ...(aggregate ?? {})}
if (!sources.includes(backend.source)) {
sources.push(backend.source)
}
} else if (legacy.invalid) {
this._errors.push('legacy-settings-invalid:' + backend.source)
}
}
}
if (!aggregate) {
this._summary.settings = 0
return
}
let doc = await this._repos.settings.getDocument()
let count = 0
for (let fieldKey in aggregate) {
if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
continue
}
let property = fieldKeyToProperty(fieldKey)
let value = aggregate[fieldKey]
doc[property] = {
value,
optimized: this._manager._computeFieldOptimized(fieldKey, value),
updatedAt: Date.now(),
}
count++
}
await this._repos.settings.putDocument(doc)
this._summary.settings = count
}
/**
* Normalize a driver settings blob. Empty objects, arrays, and whitespace strings are treated as absent (not errors).
* @param {*} blob
* @return {{settings: Object|null, invalid: boolean}}
* @private
*/
_coerceSettingsBlob(blob)
{
if (blob === null || blob === undefined) {
return {settings: null, invalid: false}
}
blob = Utilities.reviveGmStoredValue(blob)
if (typeof blob === 'string') {
let trimmed = blob.trim()
if (!trimmed.length) {
return {settings: null, invalid: false}
}
try {
blob = JSON.parse(trimmed)
} catch (error) {
return {settings: null, invalid: true}
}
}
if (blob && typeof blob === 'object' && 'arrays' in blob && 'objects' in blob && 'properties' in blob) {
try {
blob = Utilities.objectFromJSON(JSON.stringify(blob))
} catch (error) {
return {settings: null, invalid: true}
}
}
if (!blob || typeof blob !== 'object' || Array.isArray(blob) || !Object.keys(blob).length) {
return {settings: null, invalid: false}
}
return {settings: blob, invalid: false}
}
async _importBookmarks(prefix, legacyPrefix, sources)
{
let rows = []
let expected = 0
let keys = [prefix + 'bookmarks']
if (legacyPrefix) {
keys.push(legacyPrefix + 'bookmarks')
}
for (let key of keys) {
let stored = readLegacyGm(key, null)
let list = Utilities.coerceBookmarkArray(stored)
expected += list.length
for (let bookmark of list) {
let normalized = this._normalizeBookmarkRow(bookmark)
if (normalized) {
rows.push(normalized)
}
}
if (list.length && !sources.includes(LEGACY_SOURCE_GM)) {
sources.push(LEGACY_SOURCE_GM)
}
}
for (let backend of LEGACY_STORAGE_BACKENDS) {
for (let activePrefix of [prefix, legacyPrefix]) {
if (!activePrefix) {
continue
}
let settings = backend.read(activePrefix + 'settings', null)
let fromSettings = Utilities.coerceBookmarkArray(settings?.bookmarks)
expected += fromSettings.length
for (let bookmark of fromSettings) {
let normalized = this._normalizeBookmarkRow(bookmark)
if (normalized) {
rows.push(normalized)
}
}
if (fromSettings.length && !sources.includes(backend.source)) {
sources.push(backend.source)
}
}
}
if (rows.length) {
await this._repos.bookmarks.replaceAll(rows)
}
this._summary.bookmarks = rows.length
this._summary.bookmarksExpected = expected
}
_normalizeBookmarkRow(bookmark)
{
if (!bookmark || typeof bookmark !== 'object') {
return null
}
let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
if (!tags.length) {
return null
}
return this._bookmarkToRow(bookmark)
}
_bookmarkToRow(bookmark)
{
return {
entryId: null,
label: bookmark.label ?? '',
tags: bookmark.tags ?? '',
url: bookmark.url ?? '',
sortOrder: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
async _importLedger(prefix, legacyPrefix, sources)
{
let registryKeys = [prefix + 'download-ledger']
if (legacyPrefix && legacyPrefix !== prefix) {
registryKeys.push(legacyPrefix + 'download-ledger')
}
let ids = []
for (let registryKey of registryKeys) {
let registry = readLegacyGm(registryKey, null)
if (registry && typeof registry === 'object') {
ids.push(...(Array.isArray(registry.postIds) ? registry.postIds
: Array.isArray(registry.ids) ? registry.ids : []))
if (!sources.includes(LEGACY_SOURCE_GM)) {
sources.push(LEGACY_SOURCE_GM)
}
}
}
let rows = []
let ledgerPrefixes = registryKeys.map((key) => key + '/e/')
if (typeof GM_listValues === 'function') {
for (let storeKey of GM_listValues()) {
let matchedPrefix = ledgerPrefixes.find((ledgerPrefix) => storeKey.startsWith(ledgerPrefix))
if (!matchedPrefix) {
continue
}
let postId = decodeURIComponent(storeKey.slice(matchedPrefix.length))
let claimedAt = readLegacyGm(storeKey, null)
rows.push({
entryId: null,
postId,
claimedAt: typeof claimedAt === 'number' ? claimedAt : Date.now(),
})
}
} else {
for (let id of ids) {
rows.push({entryId: null, postId: String(id), claimedAt: Date.now()})
}
}
if (rows.length) {
await this._repos.ledger.replaceAll(rows)
if (!sources.includes(LEGACY_SOURCE_GM)) {
sources.push(LEGACY_SOURCE_GM)
}
}
this._summary.ledgerEntries = rows.length
this._summary.ledgerExpected = Math.max(ids.length, rows.length)
}
}