diff --git a/src/content/components/BlockCommunication.ts b/src/content/components/BlockCommunication.ts index 675c83d..bcc444f 100644 --- a/src/content/components/BlockCommunication.ts +++ b/src/content/components/BlockCommunication.ts @@ -17,8 +17,8 @@ export class BlockCommunication extends BaseComponent { protected init() { Promise.all([ - BlockCommunication.#preferences.resolveReplaceLinks(), - BlockCommunication.#preferences.resolveReplaceLinkText(), + BlockCommunication.#preferences.replaceLinks.get(), + BlockCommunication.#preferences.replaceLinkText.get(), ]).then(([replaceLinks, replaceLinkText]) => { this.#onReplaceLinkSettingResolved( replaceLinks, diff --git a/src/content/components/FullscreenViewer.ts b/src/content/components/FullscreenViewer.ts index b5183d1..616acdf 100644 --- a/src/content/components/FullscreenViewer.ts +++ b/src/content/components/FullscreenViewer.ts @@ -54,7 +54,7 @@ export class FullscreenViewer extends BaseComponent { this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation()); FullscreenViewer.#preferences - .resolveFullscreenViewerPreviewSize() + .fullscreenViewerSize.get() .then(this.#onSizeResolved.bind(this)) .then(this.#watchForSizeSelectionChanges.bind(this)); } @@ -202,7 +202,7 @@ export class FullscreenViewer extends BaseComponent { } lastActiveSize = targetSize; - void FullscreenViewer.#preferences.setFullscreenViewerPreviewSize(targetSize); + void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize); }); } diff --git a/src/content/components/ImageShowFullscreenButton.ts b/src/content/components/ImageShowFullscreenButton.ts index 7e58985..e50ce09 100644 --- a/src/content/components/ImageShowFullscreenButton.ts +++ b/src/content/components/ImageShowFullscreenButton.ts @@ -26,7 +26,7 @@ export class ImageShowFullscreenButton extends BaseComponent { this.on('click', this.#onButtonClicked.bind(this)); if (ImageShowFullscreenButton.#preferences) { - ImageShowFullscreenButton.#preferences.resolveFullscreenViewerEnabled() + ImageShowFullscreenButton.#preferences.fullscreenViewer.get() .then(isEnabled => { this.#isFullscreenButtonEnabled = isEnabled; this.#updateFullscreenButtonVisibility(); diff --git a/src/content/components/MaintenancePopup.ts b/src/content/components/MaintenancePopup.ts index dbcc01c..1f3c886 100644 --- a/src/content/components/MaintenancePopup.ts +++ b/src/content/components/MaintenancePopup.ts @@ -214,7 +214,7 @@ export class MaintenancePopup extends BaseComponent { let maybeTagsAndAliasesAfterUpdate; - const shouldAutoRemove = await MaintenancePopup.#preferences.resolveStripBlacklistedTags(); + const shouldAutoRemove = await MaintenancePopup.#preferences.stripBlacklistedTags.get(); try { maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags( @@ -359,13 +359,11 @@ export class MaintenancePopup extends BaseComponent { lastActiveProfileId = settings.activeProfile; - this.#preferences - .resolveActiveProfileAsObject() + this.#preferences.activeProfile.asObject() .then(callback); }); - this.#preferences - .resolveActiveProfileAsObject() + this.#preferences.activeProfile.asObject() .then(profileOrNull => { if (profileOrNull) { lastActiveProfileId = profileOrNull.id; diff --git a/src/content/components/TagDropdownWrapper.ts b/src/content/components/TagDropdownWrapper.ts index f50b6b2..0a64ec3 100644 --- a/src/content/components/TagDropdownWrapper.ts +++ b/src/content/components/TagDropdownWrapper.ts @@ -179,7 +179,7 @@ export class TagDropdownWrapper extends BaseComponent { }); await profile.save(); - await TagDropdownWrapper.#preferences.setActiveProfileId(profile.id); + await TagDropdownWrapper.#preferences.activeProfile.set(profile.id); } async #onToggleInExistingClicked() { @@ -219,7 +219,7 @@ export class TagDropdownWrapper extends BaseComponent { lastActiveProfile = settings.activeProfile ?? null; this.#preferences - .resolveActiveProfileAsObject() + .activeProfile.asObject() .then(onActiveProfileChange); }); @@ -232,7 +232,7 @@ export class TagDropdownWrapper extends BaseComponent { }); this.#preferences - .resolveActiveProfileAsObject() + .activeProfile.asObject() .then(activeProfile => { lastActiveProfile = activeProfile?.id ?? null; onActiveProfileChange(activeProfile); diff --git a/src/content/components/TagsListBlock.ts b/src/content/components/TagsListBlock.ts index 082b21c..1f2557e 100644 --- a/src/content/components/TagsListBlock.ts +++ b/src/content/components/TagsListBlock.ts @@ -44,7 +44,7 @@ export class TagsListBlock extends BaseComponent { } init() { - this.#preferences.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this)); + this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this)); this.#preferences.subscribe(settings => { this.#onTagSeparationChange(Boolean(settings.groupSeparation)) }); @@ -103,7 +103,7 @@ export class TagsListBlock extends BaseComponent { #onToggleGroupingClicked(event: Event) { event.preventDefault(); - void this.#preferences.setGroupSeparation(!this.#shouldDisplaySeparation); + void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation); } #handleTagGroupChanges(tagGroup: TagGroup) { diff --git a/src/lib/extension/base/CacheablePreferences.ts b/src/lib/extension/base/CacheablePreferences.ts index d5f6f35..315a6f3 100644 --- a/src/lib/extension/base/CacheablePreferences.ts +++ b/src/lib/extension/base/CacheablePreferences.ts @@ -1,11 +1,103 @@ import ConfigurationController from "$lib/extension/ConfigurationController"; -export default class CacheablePreferences { +/** + * Initialization options for the preference field helper class. + */ +type PreferenceFieldOptions = { + /** + * Field name which will be read or updated. + */ + field: FieldKey; + /** + * Default value for this field. + */ + defaultValue: ValueType; +} + +/** + * Helper class for a field. Contains all information needed to read or set the values into the preferences while + * retaining proper types for the values. + */ +export class PreferenceField< + /** + * Mapping of keys to fields. Usually this is the same type used for defining the structure of the storage itself. + * Is automatically captured when preferences class instance is passed into the constructor. + */ + Fields extends Record = Record, + /** + * Field key for resolving which value will be resolved from getter or which value type should be passed into the + * setter method. + */ + Key extends keyof Fields = keyof Fields +> { + /** + * Instance of the preferences class to read/update values on. + * @private + */ + readonly #preferences: CacheablePreferences; + /** + * Key of a field we want to read or write with the helper class. + * @private + */ + readonly #fieldKey: Key; + /** + * Stored default value for a field. + * @private + */ + readonly #defaultValue: Fields[Key]; + + /** + * @param preferencesInstance Instance of preferences to work with. + * @param options Initialization options for this field. + */ + constructor(preferencesInstance: CacheablePreferences, options: PreferenceFieldOptions) { + this.#preferences = preferencesInstance; + this.#fieldKey = options.field; + this.#defaultValue = options.defaultValue; + } + + /** + * Read the field value from the preferences. + */ + get() { + return this.#preferences.readRaw(this.#fieldKey, this.#defaultValue); + } + + /** + * Update the preference field with provided value. + * @param value Value to update the field with. + */ + set(value: Fields[Key]) { + return this.#preferences.writeRaw(this.#fieldKey, value); + } +} + +/** + * Helper type for preference classes to enforce having field objects inside the preferences instance. It should be + * applied on child classes of {@link CacheablePreferences}. + */ +export type WithFields> = { + readonly [FieldKey in keyof FieldsType]: PreferenceField; +} + +/** + * Base class for any preferences instances. It contains methods for reading or updating any arbitrary values inside + * extension storage. It also tries to save the value resolved from the storage into special internal cache after the + * first call. + * + * Should be usually paired with implementation of {@link WithFields} helper type as interface for much more usable + * API. + */ +export default abstract class CacheablePreferences { #controller: ConfigurationController; #cachedValues: Map = new Map(); #disposables: Function[] = []; - constructor(settingsNamespace: string) { + /** + * @param settingsNamespace Name of the field inside the extension storage where these preferences stored. + * @protected + */ + protected constructor(settingsNamespace: string) { this.#controller = new ConfigurationController(settingsNamespace); this.#disposables.push( @@ -21,13 +113,13 @@ export default class CacheablePreferences { } /** - * @template SettingType - * @param {string} settingName - * @param {SettingType} defaultValue - * @return {Promise} - * @protected + * Read the value from the preferences by the field. This function doesn't handle default values, so you generally + * should avoid using this method and accessing the special fields instead. + * @param settingName Name of the field to read. + * @param defaultValue Default value to return if value is not set. + * @return Value of the field or default value if it is not set. */ - protected async _resolveSetting(settingName: Key, defaultValue: Fields[Key]): Promise { + public async readRaw(settingName: Key, defaultValue: Fields[Key]): Promise { if (this.#cachedValues.has(settingName)) { return this.#cachedValues.get(settingName); } @@ -40,12 +132,14 @@ export default class CacheablePreferences { } /** + * Write the value into specific field of the storage. You should generally avoid calling this function directly and + * instead rely on special field helpers inside your preferences class. * @param settingName Name of the setting to write. * @param value Value to pass. * @param force Ignore the cache and force the update. * @protected */ - async _writeSetting(settingName: Key, value: Fields[Key], force: boolean = false): Promise { + async writeRaw(settingName: Key, value: Fields[Key], force: boolean = false): Promise { if ( !force && this.#cachedValues.has(settingName) @@ -62,8 +156,9 @@ export default class CacheablePreferences { /** * Subscribe to the changes made to the storage. - * @param {function(Object): void} callback Callback which will receive list of settings. - * @return {function(): void} Unsubscribe function. + * @param callback Callback which will receive list of settings on every update. This function will not be called + * on initialization. + * @return Unsubscribe function to call in order to disable the watching. */ subscribe(callback: (settings: Partial) => void): () => void { const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record) => void); @@ -73,6 +168,9 @@ export default class CacheablePreferences { return unsubscribeCallback; } + /** + * Completely disable all subscriptions. + */ dispose() { for (let disposeCallback of this.#disposables) { disposeCallback(); diff --git a/src/lib/extension/preferences/MiscPreferences.ts b/src/lib/extension/preferences/MiscPreferences.ts index 68dffbf..0c14051 100644 --- a/src/lib/extension/preferences/MiscPreferences.ts +++ b/src/lib/extension/preferences/MiscPreferences.ts @@ -1,4 +1,7 @@ -import CacheablePreferences from "$lib/extension/base/CacheablePreferences"; +import CacheablePreferences, { + PreferenceField, + type WithFields +} from "$lib/extension/base/CacheablePreferences"; export type FullscreenViewerSize = keyof App.ImageURIs; @@ -7,24 +10,18 @@ interface MiscPreferencesFields { fullscreenViewerSize: FullscreenViewerSize; } -export default class MiscPreferences extends CacheablePreferences { +export default class MiscPreferences extends CacheablePreferences implements WithFields { constructor() { super("misc"); } - async resolveFullscreenViewerEnabled() { - return this._resolveSetting("fullscreenViewer", true); - } + readonly fullscreenViewer = new PreferenceField(this, { + field: "fullscreenViewer", + defaultValue: true, + }); - async resolveFullscreenViewerPreviewSize() { - return this._resolveSetting('fullscreenViewerSize', 'large'); - } - - async setFullscreenViewerEnabled(isEnabled: boolean) { - return this._writeSetting("fullscreenViewer", isEnabled); - } - - async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) { - return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize); - } + readonly fullscreenViewerSize = new PreferenceField(this, { + field: "fullscreenViewerSize", + defaultValue: "large", + }); } diff --git a/src/lib/extension/preferences/TaggingProfilesPreferences.ts b/src/lib/extension/preferences/TaggingProfilesPreferences.ts index f255ba1..c32eae7 100644 --- a/src/lib/extension/preferences/TaggingProfilesPreferences.ts +++ b/src/lib/extension/preferences/TaggingProfilesPreferences.ts @@ -1,48 +1,40 @@ import TaggingProfile from "$entities/TaggingProfile"; -import CacheablePreferences from "$lib/extension/base/CacheablePreferences"; +import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences"; interface TaggingProfilePreferencesFields { activeProfile: string | null; stripBlacklistedTags: boolean; } -export default class TaggingProfilesPreferences extends CacheablePreferences { +class ActiveProfilePreference extends PreferenceField { + constructor(preferencesInstance: CacheablePreferences) { + super(preferencesInstance, { + field: "activeProfile", + defaultValue: null, + }); + } + + async asObject(): Promise { + const activeProfileId = await this.get(); + + if (!activeProfileId) { + return null; + } + + return (await TaggingProfile.readAll()) + .find(profile => profile.id === activeProfileId) || null; + } +} + +export default class TaggingProfilesPreferences extends CacheablePreferences implements WithFields { constructor() { super("maintenance"); } - /** - * Set the active maintenance profile. - */ - async resolveActiveProfileId() { - return this._resolveSetting("activeProfile", null); - } + readonly activeProfile = new ActiveProfilePreference(this); - /** - * Get the active maintenance profile if it is set. - */ - async resolveActiveProfileAsObject(): Promise { - const resolvedProfileId = await this.resolveActiveProfileId(); - - return (await TaggingProfile.readAll()) - .find(profile => profile.id === resolvedProfileId) || null; - } - - async resolveStripBlacklistedTags() { - return this._resolveSetting('stripBlacklistedTags', false); - } - - /** - * Set the active maintenance profile. - * - * @param profileId ID of the profile to set as active. If `null`, the active profile will be considered - * unset. - */ - async setActiveProfileId(profileId: string | null): Promise { - await this._writeSetting("activeProfile", profileId); - } - - async setStripBlacklistedTags(isEnabled: boolean) { - await this._writeSetting('stripBlacklistedTags', isEnabled); - } + readonly stripBlacklistedTags = new PreferenceField(this, { + field: "stripBlacklistedTags", + defaultValue: false, + }); } diff --git a/src/lib/extension/preferences/TagsPreferences.ts b/src/lib/extension/preferences/TagsPreferences.ts index 4922991..06596bb 100644 --- a/src/lib/extension/preferences/TagsPreferences.ts +++ b/src/lib/extension/preferences/TagsPreferences.ts @@ -1,4 +1,4 @@ -import CacheablePreferences from "$lib/extension/base/CacheablePreferences"; +import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences"; interface TagsPreferencesFields { groupSeparation: boolean; @@ -6,32 +6,23 @@ interface TagsPreferencesFields { replaceLinkText: boolean; } -export default class TagsPreferences extends CacheablePreferences { +export default class TagsPreferences extends CacheablePreferences implements WithFields { constructor() { super("tag"); } - async resolveGroupSeparation() { - return this._resolveSetting("groupSeparation", true); - } + readonly groupSeparation = new PreferenceField(this, { + field: "groupSeparation", + defaultValue: true, + }); - async resolveReplaceLinks() { - return this._resolveSetting("replaceLinks", false); - } + readonly replaceLinks = new PreferenceField(this, { + field: "replaceLinks", + defaultValue: false, + }); - async resolveReplaceLinkText() { - return this._resolveSetting("replaceLinkText", true); - } - - async setGroupSeparation(value: boolean) { - return this._writeSetting("groupSeparation", Boolean(value)); - } - - async setReplaceLinks(value: boolean) { - return this._writeSetting("replaceLinks", Boolean(value)); - } - - async setReplaceLinkText(value: boolean) { - return this._writeSetting("replaceLinkText", Boolean(value)); - } + readonly replaceLinkText = new PreferenceField(this, { + field: "replaceLinkText", + defaultValue: true, + }); } diff --git a/src/stores/entities/tagging-profiles.ts b/src/stores/entities/tagging-profiles.ts index 7c38751..177268e 100644 --- a/src/stores/entities/tagging-profiles.ts +++ b/src/stores/entities/tagging-profiles.ts @@ -24,7 +24,7 @@ Promise.allSettled([ TaggingProfile.readAll().then(profiles => { taggingProfiles.set(profiles); }), - preferences.resolveActiveProfileId().then(activeProfileId => { + preferences.activeProfile.get().then(activeProfileId => { activeTaggingProfile.set(activeProfileId); }) ]).then(() => { @@ -40,7 +40,7 @@ Promise.allSettled([ activeTaggingProfile.subscribe(profileId => { lastActiveProfileId = profileId; - void preferences.setActiveProfileId(profileId); + void preferences.activeProfile.set(profileId); }); // Watch the existence of the active profile on every change. diff --git a/src/stores/preferences/misc.ts b/src/stores/preferences/misc.ts index 00aeb8c..121939a 100644 --- a/src/stores/preferences/misc.ts +++ b/src/stores/preferences/misc.ts @@ -6,10 +6,10 @@ export const fullScreenViewerEnabled = writable(true); const preferences = new MiscPreferences(); Promise.allSettled([ - preferences.resolveFullscreenViewerEnabled().then(v => fullScreenViewerEnabled.set(v)) + preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v)) ]).then(() => { fullScreenViewerEnabled.subscribe(value => { - void preferences.setFullscreenViewerEnabled(value); + void preferences.fullscreenViewer.set(value); }); preferences.subscribe(settings => { diff --git a/src/stores/preferences/profiles.ts b/src/stores/preferences/profiles.ts index 2e915dc..f7a65fc 100644 --- a/src/stores/preferences/profiles.ts +++ b/src/stores/preferences/profiles.ts @@ -7,12 +7,12 @@ const preferences = new TaggingProfilesPreferences(); Promise .all([ - preferences.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true)) + preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true)) ]) .then(() => { preferences.subscribe(settings => { stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true); }); - stripBlacklistedTagsEnabled.subscribe(v => preferences.setStripBlacklistedTags(v)); + stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v)); }); diff --git a/src/stores/preferences/tags.ts b/src/stores/preferences/tags.ts index 190758d..84b1239 100644 --- a/src/stores/preferences/tags.ts +++ b/src/stores/preferences/tags.ts @@ -9,21 +9,21 @@ export const shouldReplaceTextOfTagLinks = writable(true); Promise .allSettled([ - preferences.resolveGroupSeparation().then(value => shouldSeparateTagGroups.set(value)), - preferences.resolveReplaceLinks().then(value => shouldReplaceLinksOnForumPosts.set(value)), - preferences.resolveReplaceLinkText().then(value => shouldReplaceTextOfTagLinks.set(value)), + preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)), + preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)), + preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)), ]) .then(() => { shouldSeparateTagGroups.subscribe(value => { - void preferences.setGroupSeparation(value); + void preferences.groupSeparation.set(value); }); shouldReplaceLinksOnForumPosts.subscribe(value => { - void preferences.setReplaceLinks(value); + void preferences.replaceLinks.set(value); }); shouldReplaceTextOfTagLinks.subscribe(value => { - void preferences.setReplaceLinkText(value); + void preferences.replaceLinkText.set(value); }); preferences.subscribe(settings => {