diff --git a/node/src/hit-counter.ts b/node/src/hit-counter.ts index a148ccc..c6a5d5c 100644 --- a/node/src/hit-counter.ts +++ b/node/src/hit-counter.ts @@ -3,6 +3,7 @@ import sharp, { FormatEnum, Metadata, Sharp } from 'sharp'; import * as fs from 'fs'; import * as https from 'https'; +// Define the image format map const imageFormatMap: Record = { jpg: 'jpeg', jpeg: 'jpeg', @@ -12,6 +13,7 @@ const imageFormatMap: Record = { gif: 'gif' }; +// Define the MIME type map const mimeTypeMap: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', @@ -21,22 +23,27 @@ const mimeTypeMap: Record = { gif: 'image/gif' }; +// Database result for hits interface HitResult { hits: number; } +// RGB color type interface RGBColor { r: number; g: number; b: number; } +// Allows use of base class and subclass as a valid type export interface Type extends Function { new (...args: any[]): T; } +// Hit counter renderer interface interface HitCounterRenderer { render(): Promise; } +// Hit counter configuration interface HitCounterConfig { siteId: number; // path to the database file @@ -63,6 +70,7 @@ interface HitCounterConfig { drawFrame: boolean; // Whether to draw a frame around the counter } +// Default configuration for the hit counter const defaultHitCounterConfig: HitCounterConfig = { siteId: 1, counterDB: './hits.db', @@ -85,12 +93,29 @@ const defaultHitCounterConfig: HitCounterConfig = { drawFrame: true, }; + +/** + * Extract the file extension from a file path. + * + * @param {String} file + * + * @returns {String} + * @throws {Error} if the file extension is invalid + */ function fileExt(file: string): string { const ext = file.split('.').pop(); if (!ext) throw new Error('Invalid file extension'); return ext.toLowerCase(); } +/** + * Convert a file extension to a Sharp image format. + * + * @param {String} ext + * + * @returns {String} + * @throws {Error} if the file extension is not supported + */ function extToFormat(ext: string): keyof FormatEnum { const format = imageFormatMap[ext]; if (!format) throw new Error('Unsupported file extension'); @@ -106,8 +131,10 @@ function extToFormat(ext: string): keyof FormatEnum { * https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url * * @param {String|URL} path + * The path to check. * * @returns {Boolean} + * True if the path is a web URI, false otherwise. */ function isWebURI(path: string | URL): boolean { try { @@ -118,30 +145,67 @@ function isWebURI(path: string | URL): boolean { } } +/** + * Base class for hit counter renderers. + * + * @class BaseHitCounterRenderer + * @implements {HitCounterRenderer} + */ class BaseHitCounterRenderer implements HitCounterRenderer { protected counter: HitCounter; protected config: HitCounterConfig; + /** + * Creates an instance of BaseHitCounterRenderer. + * + * @param {HitCounter} counter + * The hit counter instance. + * @param {HitCounterConfig} config + * The hit counter configuration. + */ constructor(counter: HitCounter, config: HitCounterConfig) { this.counter = counter; this.config = config; } + /** + * Render the hit counter. + */ async render(): Promise { throw new Error('Render not implemented'); } } +/** + * Image hit counter renderer. + * + * @class ImageHitCounterRenderer + * @extends {BaseHitCounterRenderer} + */ class ImageHitCounterRenderer extends BaseHitCounterRenderer { private imageFormat: keyof FormatEnum; private baseImage: Buffer; private baseImageMetadata: Metadata; + /** + * Creates an instance of ImageHitCounterRenderer. + * + * @param {HitCounter} counter + * The hit counter instance. + * @param {HitCounterConfig} config + * The hit counter configuration. + */ constructor(counter: HitCounter, config: HitCounterConfig) { super(counter, config); this.imageFormat = extToFormat(fileExt(this.config.imageFile)); } + /** + * Download the background image. + * + * @returns {Promise} + * The downloaded image. + */ private async downloadImage(): Promise { return new Promise((resolve, reject) => { let buf = Buffer.alloc(0); @@ -152,6 +216,15 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { }); } + /** + * Load or cache the background image. + * + * @param {Boolean} [overwrite=false] + * Whether to overwrite the cached background image. + * + * @returns {Promise} + * The loaded or cached background image. + */ private async loadOrCacheBG(overwrite = false): Promise { if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) { return sharp(this.config.backgroundCacheFile); @@ -166,6 +239,12 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { return image; } + /** + * Get the base image. + * + * @returns {Promise} + * The base image. + */ private async getBaseImage(): Promise { if (typeof this.baseImage !== 'undefined') { return this.baseImage; @@ -181,6 +260,17 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { return this.baseImage; } + /** + * Create an SVG overlay. + * + * @param {Number} width + * The width of the overlay. + * @param {Number} height + * The height of the overlay. + * + * @returns {Buffer} + * The SVG overlay. + */ private createSvgOverlay(width: number, height: number): Buffer { const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config; const frame = drawFrame @@ -198,15 +288,34 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { return Buffer.from(svg); } - private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer { + /** + * Create an SVG number overlay. + * + * @param {Number} hits + * The number of hits. + * @param {Number} width + * The width of the image. + * @param {Number} height + * The height of the image. + * + * @returns {Buffer} + * The SVG number overlay. + */ + private createSVGNumberOverlay(hits: number, width: number, height: number): Buffer { const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config; const svg = ` - + ${hits} `; return Buffer.from(svg); } + /** + * Render the hit counter. + * + * @returns {Promise} + * The rendered hit counter. + */ async render(): Promise { const hits = await this.counter.getHits(); return await sharp(await this.getBaseImage()) @@ -222,6 +331,11 @@ class ImageHitCounterRenderer extends BaseHitCounterRenderer { } } +/** + * Hit counter class. + * + * @class HitCounter + */ class HitCounter { private db: DB; private renderer: BaseHitCounterRenderer; @@ -229,6 +343,14 @@ class HitCounter { private mimeType: string; private config: HitCounterConfig; + /** + * Creates an instance of HitCounter. + * + * @param {Type} renderer + * The hit counter renderer. + * @param {HitCounterConfig} [config=defaultHitCounterConfig] + * The hit counter configuration. + */ constructor(renderer: Type, config: HitCounterConfig = defaultHitCounterConfig) { config = { ...defaultHitCounterConfig, ...config }; this.config = config; @@ -238,6 +360,15 @@ class HitCounter { this.preparedStatements = this.prepareStatements(); } + /** + * Initialize the database connection. + * + * @param {String} filePath + * The path to the database file. + * + * @returns {DB} + * The database instance. + */ private initDB(filePath: string): DB { const db = new Database(filePath, {fileMustExist: false}); db.pragma('locking_mode = EXCLUSIVE'); @@ -246,6 +377,12 @@ class HitCounter { return db; } + /** + * Prepare the SQL statements. + * + * @returns {Record} + * The prepared statements. + */ private prepareStatements(): Record { this.db.exec(` CREATE TABLE IF NOT EXISTS hit_counter ( @@ -266,27 +403,54 @@ class HitCounter { }; } + /** + * Get the number of hits. + * + * @returns {Promise} + * The number of hits + */ async getHits(): Promise { const result = this.preparedStatements.get.get(this.config.siteId) as HitResult; return result.hits; } + /** + * Increment the hit counter. + */ async increment(): Promise { this.preparedStatements.increment.run(this.config.siteId); } + /** + * Reset the hit counter + */ async reset(): Promise { this.preparedStatements.reset.run(this.config.siteId); } + /** + * Render the hit counter. + * + * @returns {Promise} + * The rendered hit counter. + */ async render(): Promise { return this.renderer.render(); } + /** + * Get the MIME type of the hit counter image. + * + * @returns {String} + * The MIME type. + */ getMimeType(): string { return this.mimeType; } + /** + * Destructor. + */ finalize(): void { this.db.close(); }