From 7b8379705b3ab4f48b3b127c94968b28485bc385 Mon Sep 17 00:00:00 2001 From: Erin Date: Tue, 19 Dec 2023 10:50:53 -0500 Subject: [PATCH] Clean up raw sub config types; add note type APIs (#57) --- src/classes/SubredditConfig.test.ts | 6 + src/classes/SubredditConfig.ts | 82 +++++++++++++ src/classes/ToolboxClient.ts | 10 ++ src/helpers/config.test.ts | 3 + src/helpers/config.ts | 52 ++++++++ src/subConfig.ts | 81 ------------- src/types/RawSubredditConfig.ts | 180 ++++++++++++++++++++++++++++ src/types/RawUsernotes.ts | 15 ++- src/types/Usernote.ts | 24 +++- 9 files changed, 366 insertions(+), 87 deletions(-) create mode 100644 src/classes/SubredditConfig.test.ts create mode 100644 src/classes/SubredditConfig.ts create mode 100644 src/helpers/config.test.ts create mode 100644 src/helpers/config.ts delete mode 100644 src/subConfig.ts create mode 100644 src/types/RawSubredditConfig.ts diff --git a/src/classes/SubredditConfig.test.ts b/src/classes/SubredditConfig.test.ts new file mode 100644 index 0000000..ee7a4a1 --- /dev/null +++ b/src/classes/SubredditConfig.test.ts @@ -0,0 +1,6 @@ +import test from 'ava'; + +test.todo('getAllNoteTypes'); +test.todo('getNoteType'); +test.todo('toJSON'); +test.todo('toString'); diff --git a/src/classes/SubredditConfig.ts b/src/classes/SubredditConfig.ts new file mode 100644 index 0000000..20837c0 --- /dev/null +++ b/src/classes/SubredditConfig.ts @@ -0,0 +1,82 @@ +import { + DEFAULT_USERNOTE_TYPES, + migrateConfigToLatestSchema, +} from '../helpers/config'; +import {RawSubredditConfig, RawUsernoteType} from '../types/RawSubredditConfig'; + +// type imports for doc references +import type {Usernote} from '../types/Usernote'; + +/** + * A class that interfaces with the raw contents of a subreddit's `toolbox` + * wiki page, automatically handling schema checks and providing methods to read + * and modify subreddit configuration. + */ +export class SubredditConfig { + private data: RawSubredditConfig; + + constructor (jsonString: string) { + this.data = migrateConfigToLatestSchema(JSON.parse(jsonString)); + } + + /** Returns all usernote types. */ + getAllNoteTypes (): RawUsernoteType[] { + // If the config doesn't specify any note types, make a copy of the + // default set and add them to the config so the unambiguous form will + // be written back + if (!this.data.usernoteColors || !this.data.usernoteColors.length) { + const defaultTypes = DEFAULT_USERNOTE_TYPES.map(noteType => ({ + ...noteType, + })); + this.data.usernoteColors = defaultTypes; + } + + return this.data.usernoteColors; + } + + /** + * Returns the usernote type matching the given key. Useful for looking up + * display information for a usernote from {@linkcode Usernote.noteType}. + * + * @example Get the color and text of a note type from the key: + * ```ts + * const toolbox = new ToolboxClient(reddit); + * const subreddit = 'mildlyinteresting'; + * + * // Acquire a note somehow + * const usernotes = toolbox.getUsernotes(subreddit); + * const note = usernotes.get('eritbh')[0]; + * + * // Look up information about the type of this note + * const subConfig = toolbox.getConfig(subreddit); + * const {color, text} = subConfig.getNoteType(note.noteType); + * ``` + */ + getNoteType (key: string) { + const noteTypes = this.getAllNoteTypes(); + return noteTypes.find(noteType => noteType.key === key); + } + + /** + * Serializes the subreddit config data for writing back to the wiki. **This + * method returns an object; you probably want {@linkcode toString} + * instead.** + * @returns Object which can be serialized to JSON and written as the + * contents of the `toolbox` wiki page + */ + toJSON () { + return this.data; + } + + /** + * Stringifies the subreddit config data for writing back to the wiki. + * @param indent Passed as the third argument of `JSON.stringify`. Useful + * for debugging; however, because wiki space is limited, never provide this + * parameter when actually saving config to the wiki. + * @returns JSON string which can be saved as the contents of the `toolbox` + * wiki page + */ + toString (indent?: string | number) { + return JSON.stringify(this.data, null, indent); + } +} diff --git a/src/classes/ToolboxClient.ts b/src/classes/ToolboxClient.ts index 6827155..23132bc 100644 --- a/src/classes/ToolboxClient.ts +++ b/src/classes/ToolboxClient.ts @@ -1,10 +1,14 @@ import {RedditAPIClient} from '@devvit/public-api'; import {Usernote, UsernoteInit} from '../types/Usernote'; +import {SubredditConfig} from './SubredditConfig'; import {Usernotes} from './Usernotes'; /** The name of the wiki page where Toolbox stores usernotes. */ const TB_USERNOTES_PAGE = 'usernotes'; +/** The name of the wiki page where Toolbox stores subreddit configuration. */ +const TB_CONFIG_PAGE = 'toolbox'; + /** * A client class for interfacing with Toolbox functionality and stored data * from within the Devvit platform. Wraps the Reddit API client provided in @@ -135,4 +139,10 @@ export class ToolboxClient { notes.add(note as Usernote); await this.writeUsernotes(subreddit, notes, reason); } + + /** */ + async getConfig (subreddit: string) { + const page = await this.reddit.getWikiPage(subreddit, TB_CONFIG_PAGE); + return new SubredditConfig(page.content); + } } diff --git a/src/helpers/config.test.ts b/src/helpers/config.test.ts new file mode 100644 index 0000000..e7f16e5 --- /dev/null +++ b/src/helpers/config.test.ts @@ -0,0 +1,3 @@ +import test from 'ava'; + +test.todo('migrateConfigToLatestSchema'); diff --git a/src/helpers/config.ts b/src/helpers/config.ts new file mode 100644 index 0000000..74223d6 --- /dev/null +++ b/src/helpers/config.ts @@ -0,0 +1,52 @@ +import {RawSubredditConfig, RawUsernoteType} from '../types/RawSubredditConfig'; + +/** + * The latest subreddit config schema version that this library can handle. If a + * config page reports a schema version higher than this number, it can't be + * processed with this version of the library. + */ +export const LATEST_KNOWN_CONFIG_SCHEMA = 1; + +/** + * The earliest subreddit config schema version that this library can handle. If + * a config page reports a schema version lower than this number, it can't be + * processed with this version of the library. + */ +export const EARLIEST_KNOWN_CONFIG_SCHEMA = 1; + +/** Default usernote types used if subreddit config doesn't specify its own. */ +export const DEFAULT_USERNOTE_TYPES: readonly RawUsernoteType[] = [ + {key: 'gooduser', color: 'green', text: 'Good Contributor'}, + {key: 'spamwatch', color: 'fuchsia', text: 'Spam Watch'}, + {key: 'spamwarn', color: 'purple', text: 'Spam Warning'}, + {key: 'abusewarn', color: 'orange', text: 'Abuse Warning'}, + {key: 'ban', color: 'red', text: 'Ban'}, + {key: 'permban', color: 'darkred', text: 'Permanent Ban'}, + {key: 'botban', color: 'black', text: 'Bot Ban'}, +]; + +/** + * Checks the schema version of raw subreddit config data and attempts to update + * it to the latest known schema version if it's out of date. Throws an error if + * the data's current schema version is too old or new to handle. + * @param data The subreddit config data object read from the wiki, as an object + * (i.e. you should parse the page contents as JSON to pass into this function) + * @returns Data object updated to latest schema version + */ +export function migrateConfigToLatestSchema (data: any): RawSubredditConfig { + if (data.ver < EARLIEST_KNOWN_CONFIG_SCHEMA) { + throw new TypeError( + `Unknown schema version ${data.ver} (earliest known version is ${EARLIEST_KNOWN_CONFIG_SCHEMA})`, + ); + } + if (data.ver > LATEST_KNOWN_CONFIG_SCHEMA) { + throw new TypeError( + `Unknown schema version ${data.ver} (latest known version is ${LATEST_KNOWN_CONFIG_SCHEMA})`, + ); + } + + // In the future, if we ever do a schema bump to this page, migration steps + // will go here. See also migrateUsernotesToLatestSchema() + + return data as RawSubredditConfig; +} diff --git a/src/subConfig.ts b/src/subConfig.ts deleted file mode 100644 index cb84ff6..0000000 --- a/src/subConfig.ts +++ /dev/null @@ -1,81 +0,0 @@ -// this is old and broken but i will fix it up soon i promise - -export const LATEST_KNOWN_CONFIG_SCHEMA = 1; -export const EARLIEST_KNOWN_CONFIG_SCHEMA = 1; - -export interface RawSubredditConfig { - /** The schema version of the data */ - ver: number; - /** Settings for domain tags */ - domainTags: DomainTag[]; - /** Default settings for banning users via the mod button */ - banMacros: { - /** The default mod-only ban note */ - banNote: string; - /** The default message sent to banned users */ - banMessage: string; - }; - /** Settings for removal reasons */ - removalReasons: { - header: string; - footer: string; - pmsubject: string; - logreason: unknown; - logsub: unknown; - logtitle: unknown; - bantitle: unknown; - getfrom: unknown; - reasons: RemovalReason[]; - }; - modMacros: ModMacro[]; - usernoteColors: UsernoteType[]; -} - -export interface DomainTag { - /** The domain to tag, e.g. "example.com" */ - name: string; - /** A CSS color value */ - color: string; -} - -export interface RemovalReason { - title: string; - text: string; - flairText: string; - cssClass: string; -} - -export interface ModMacro { - title: string; - text: string; - distinguish: boolean; - ban: boolean; - mute: boolean; - remove: boolean; - appprove: boolean; - lockthread: boolean; - sticky: boolean; - archivemodmail: boolean; - highlightmodmail: boolean; -} - -/** A single usernote type */ -export interface UsernoteType { - /** Key that this type is identified by, should never change once created */ - key: string; - /** Color for this note type, as any valid CSS color string */ - color: string; - /** Displayed text of the note type */ - text: string; -} - -/** Default usernote types to use if subreddit config doesn't specify any */ -export const DEFAULT_USERNOTE_TYPES: readonly UsernoteType[] = [ - {key: 'gooduser', color: 'green', text: 'Good Contributor'}, - {key: 'spamwatch', color: 'fuchsia', text: 'Spam Watch'}, - {key: 'spamwarn', color: 'purple', text: 'Spam Warning'}, - {key: 'abusewarn', color: 'orange', text: 'Abuse Warning'}, - {key: 'ban', color: 'red', text: 'Ban'}, - {key: 'permban', color: 'darkred', text: 'Permanent Ban'}, - {key: 'botban', color: 'black', text: 'Bot Ban'}, -]; diff --git a/src/types/RawSubredditConfig.ts b/src/types/RawSubredditConfig.ts new file mode 100644 index 0000000..19981dd --- /dev/null +++ b/src/types/RawSubredditConfig.ts @@ -0,0 +1,180 @@ +// type imports for doc links +import type { + LATEST_KNOWN_CONFIG_SCHEMA, + migrateConfigToLatestSchema, +} from '../helpers/config'; + +/** + * Raw data stored as JSON on the `toolbox` wiki page. + * + * Note that while the library supports upgrading older schemas to the current + * one (via {@linkcode migrateConfigToLatestSchema}), this type will only + * describe the latest known schema version. If you are manually reading data + * from the wiki without passing it through the migration function, and you read + * a `ver` value different than {@linkcode LATEST_KNOWN_CONFIG_SCHEMA}, this + * type will not describe that data. + */ +export interface RawSubredditConfig { + /** The version number of the config schema this data conforms to */ + ver: 1; + /** Settings for individual domain tags */ + domainTags: RawDomainTag[]; + /** Default settings for banning users via the mod button */ + banMacros: { + /** The default mod-only ban note */ + banNote: string; + /** The default message sent to banned users */ + banMessage: string; + }; + /** Settings for removal reasons */ + removalReasons: { + /** Header text for removal messages (may include tokens) */ + header: string; + /** Footer text for removal messages (may include tokens) */ + footer: string; + /** Subject for removal messages in PM/modmail (may include tokens) */ + pmsubject: string; + /** + * Reason string for logging sub use (may include tokens) + * @deprecated please don't make me support logsubs + */ + logreason: string; + /** + * Target subreddit for logging sub use, or an empty string for none + * @deprecated please don't make me support logsubs + */ + logsub: string; + /** + * Title string for logging sub use (may include tokens) + * @deprecated please don't make me support logsubs + */ + logtitle: string; + /** + * Unimplemented - Toolbox itself does nothing with this key + * @deprecated + */ + bantitle: unknown; + /** + * Name of another subreddit to fetch removal reasons from, instead of + * using the reasons defined in this config, or an empty string for none + */ + getfrom: string; + /** + * How subreddit settings for sending removal messages are enforced to + * moderators. This property impacts how the `type*` and `autoArchive` + * properties are interpreted. + * - `suggest` - Subreddit settings should be the default every time a + * reason is being left, but can be changed by moderators in the UI on + * a case-by-case basis + * - `leave` - Subreddit settings are ignored and whatever settings the + * user has configured in Toolbox personal settings are always used + * - `force` - Subreddit settings are used for all reasons and + * moderators cannot change them when leaving a removal reason + */ + removalOption: 'suggest' | 'leave' | 'force'; + /** + * How removal reason messages are sent by default. + * - `reply` - Message is sent as a comment reply to the actioned item + * - `pm` - Message is sent as a private message + * - `both` - Message is sent both as a reply and as a PM + * - `none` - No message is sent (only useful for logsub users) + */ + typeReply: 'reply' | 'pm' | 'both' | 'none'; + /** If true, messages sent as replies will be stickied where possible */ + typeStickied: boolean; + /** + * If true, removal messages sent as replies will be made using the fake + * subreddit_ModTeam account + */ + typeCommentAsSubreddit: boolean; + /** + * If true, using a removal reason on a submission will also lock the + * comments of that submission + */ + typeLockThread: boolean; + /** If true, removal messages sent as replies will be locked */ + typeLockComment: boolean; + /** + * If true, removal messages sent as PMs will be sent through modmail; + * if false, they will be sent through the acting mod's personal PMs + */ + typeAsSub: boolean; + /** + * If true and `typeAsSub` is true, removal reason messages sent as + * modmail will be automatically archived + */ + autoArchive: boolean; + /** The individual removal reasons */ + reasons: RawRemovalReason[]; + }; + /** Settings for individual mod macros */ + modMacros: RawModMacro[]; + /** Settings for individual usernote types */ + usernoteColors: RawUsernoteType[]; +} + +export interface RawDomainTag { + /** The domain to tag, e.g. "example.com" */ + name: string; + /** A CSS color value */ + color: string; +} + +export interface RawRemovalReason { + /** Title of the removal reason, only seen by mods */ + title: string; + /** + * Text of the removal message to include in the removal message to the user + * (may include tokens) + */ + text: string; + /** + * Text of a flair to assign to submissions removed with this reason, or an + * empty string for none + */ + flairText: string; + /** + * CSS class of a flair to assign to submissions removed with this reason, + * or an empty string for none + */ + flairCSS: string; + /** If true, this reason applies to submissions */ + removePosts: boolean; + /** If true, this reason applies to comments */ + removeComments: boolean; +} + +export interface RawModMacro { + /** Title of the macro, only seen by mods */ + title: string; + /** Text of the macro, left as a reply to the user (may include tokens) */ + text: string; + /** If true, the reply comment will be distinguished */ + distinguish: boolean; + /** If true, the user will be permanently banned */ + ban: boolean; + /** If true, the user will be muted from modmail */ + mute: boolean; + /** If true, the item will be removed */ + remove: boolean; + /** If true, the item will be approved */ + appprove: boolean; + /** If true, the submission will be locked */ + lockthread: boolean; + /** If true, the reply comment will be stickied */ + sticky: boolean; + /** If true, the modmail thread will be archived */ + archivemodmail: boolean; + /** If true, the modmail thread will be highlighted */ + highlightmodmail: boolean; +} + +/** A single usernote type */ +export interface RawUsernoteType { + /** Key that this type is identified by, should never change once created */ + key: string; + /** Color for this note type, as any valid CSS color string */ + color: string; + /** Displayed text of the note type */ + text: string; +} diff --git a/src/types/RawUsernotes.ts b/src/types/RawUsernotes.ts index d321724..86a0f96 100644 --- a/src/types/RawUsernotes.ts +++ b/src/types/RawUsernotes.ts @@ -1,3 +1,9 @@ +// type imports for doc links +import type { + LATEST_KNOWN_USERNOTES_SCHEMA, + migrateUsernotesToLatestSchema, +} from '../helpers/usernotes'; + /** Raw data for a single usernote */ export interface RawUsernotesNote { /** Timestamp (seconds since epoch) */ @@ -39,10 +45,11 @@ export interface RawUsernotesConstants { * Raw data stored as JSON on the `usernotes` wiki page. * * Note that while the library supports upgrading older schemas to the current - * one (via {@linkcode migrateUsernotesToLatestSchema}), this type only applies - * to schema version 6. If you are manually reading data from the wiki without - * passing it through the migration function, and you read a different `ver` - * value, this type will not describe that data. + * one (via {@linkcode migrateUsernotesToLatestSchema}), this type will only + * describe the latest known schema version. If you are manually reading data + * from the wiki without passing it through the migration function, and you read + * a `ver` value different than {@linkcode LATEST_KNOWN_USERNOTES_SCHEMA}, this + * type will not describe that data. */ export interface RawUsernotes { /** The version number of the usernotes schema this data conforms to */ diff --git a/src/types/Usernote.ts b/src/types/Usernote.ts index 4087877..4a428bf 100644 --- a/src/types/Usernote.ts +++ b/src/types/Usernote.ts @@ -1,3 +1,7 @@ +// type imports for doc references +import type {SubredditConfig} from '../classes/SubredditConfig'; +import type {ToolboxClient} from '../classes/ToolboxClient'; + /** Details about a newly created usernote */ export interface UsernoteInit { /** The name of the user this note is attached to */ @@ -12,8 +16,24 @@ export interface UsernoteInit { */ moderatorUsername?: string; /** - * The `key` of the note type of this note, used to look up details about - * the note type from the subreddit's Toolbox config + * The key of the note type of this note. To get information about + * corresponding note type (its label text and color information), fetch the + * subreddit's configuration with {@linkcode ToolboxClient.getConfig} and + * then pass this value to {@linkcode SubredditConfig.getNoteType}. + * + * @example Get the color and text of a note type from the key: + * ```ts + * const toolbox = new ToolboxClient(reddit); + * const subreddit = 'mildlyinteresting'; + * + * // Acquire a note somehow + * const usernotes = toolbox.getUsernotes(subreddit); + * const note = usernotes.get('eritbh')[0]; + * + * // Look up information about the type of this note + * const subConfig = toolbox.getConfig(subreddit); + * const {color, text} = subConfig.getNoteType(note.noteType); + * ``` */ noteType?: string; /** Permalink to the item the note was left in response to */