Made it a bit nicer, fixed a bug with the initial hit count.
This commit is contained in:
parent
b6f83af72e
commit
0c744f0883
2 changed files with 172 additions and 266 deletions
|
@ -92,6 +92,8 @@ interface HitCounterConfig {
|
|||
```ts
|
||||
import { HitCounter, ImageHitCounterRenderer } from "./hit-counter";
|
||||
|
||||
|
||||
// default config
|
||||
defaultHitCounterConfig = {
|
||||
siteId: 1,
|
||||
counterDB: './hits.db',
|
||||
|
@ -115,7 +117,9 @@ defaultHitCounterConfig = {
|
|||
renderer: ImageHitCounterRenderer
|
||||
};
|
||||
|
||||
const hitCounter = new HitCounter(ImageHitCounterRenderer, defaultHitCounterConfig);
|
||||
// the default config will be used, but you can pass a custom config object as
|
||||
// the second argument to the HitCounter constructor
|
||||
const hitCounter = new HitCounter(ImageHitCounterRenderer);
|
||||
|
||||
// increment the hit counter
|
||||
hitCounter.increment(); // async function
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import { type Database as DB } from 'better-sqlite3';
|
||||
import { type Statement } from 'better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
// import sharp for images
|
||||
import sharp, { FormatEnum, AvailableFormatInfo, Sharp, Metadata, TextAlign } from 'sharp';
|
||||
import Database, { Database as DB, Statement } from 'better-sqlite3';
|
||||
import sharp, { FormatEnum, Metadata, Sharp } from 'sharp';
|
||||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
|
||||
// Map file extensions to sharp formats
|
||||
const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo } = {
|
||||
const imageFormatMap: Record<string, keyof FormatEnum> = {
|
||||
jpg: 'jpeg',
|
||||
jpeg: 'jpeg',
|
||||
png: 'png',
|
||||
|
@ -16,8 +12,7 @@ const imageFormatMap: { [key: string]: keyof FormatEnum | AvailableFormatInfo }
|
|||
gif: 'gif'
|
||||
};
|
||||
|
||||
// no need for third party library
|
||||
const mimeTypeMap: { [key: string]: string } = {
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
|
@ -38,214 +33,10 @@ interface RGBColor {
|
|||
|
||||
export interface Type<T> extends Function { new (...args: any[]): T; }
|
||||
|
||||
|
||||
interface HitCounterRenderer {
|
||||
render(): Promise<any>;
|
||||
}
|
||||
|
||||
function fileExt(file: string): string {
|
||||
const fileExt = file.split('.').pop();
|
||||
if (!fileExt) {
|
||||
throw new Error('Invalid file extension');
|
||||
}
|
||||
return fileExt.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
function extToFormat(ext: string): keyof FormatEnum | AvailableFormatInfo {
|
||||
const format = imageFormatMap[ext];
|
||||
if (!format) {
|
||||
throw new Error('Unsupported file extension');
|
||||
}
|
||||
return format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given `path` is a http(s) URL. This method
|
||||
* accepts a string value and a URL instance.
|
||||
*
|
||||
* inspiration:
|
||||
* Marcus Pöhls
|
||||
* https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url
|
||||
*
|
||||
* @param {String|URL} path
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isWebURI(path) {
|
||||
// `path` is neither a string nor a URL -> early return
|
||||
if (typeof path !== 'string' && !(path instanceof URL)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(path)
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class BaseHitCounterRenderer implements HitCounterRenderer {
|
||||
protected counter: HitCounter;
|
||||
protected config: HitCounterConfig;
|
||||
|
||||
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||
this.counter = counter;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async render(): Promise<any> {
|
||||
// render the counter
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
||||
private imageType: string;
|
||||
private imageFormat: keyof FormatEnum | AvailableFormatInfo;
|
||||
private baseImage: Buffer;
|
||||
private baseImageMetadata: Metadata;
|
||||
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||
super(counter, config);
|
||||
this.imageType = fileExt(this.config.imageFile);
|
||||
this.imageFormat = extToFormat(this.imageType);
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = Buffer.alloc(0);
|
||||
const request = https.get(this.config.backgroundImageUrl, (response) => {
|
||||
response.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve(buf);
|
||||
});
|
||||
});
|
||||
request.on('error', (err) => {
|
||||
fs.unlink(this.config.backgroundCacheFile, () => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async cachedBackgroundImage(overWrite: boolean = false): Promise<Sharp> {
|
||||
// cache the image
|
||||
const dest = this.config.backgroundCacheFile;
|
||||
if (fs.existsSync(dest)) {
|
||||
if (overWrite) {
|
||||
fs.unlinkSync(dest);
|
||||
} else {
|
||||
return sharp(dest);
|
||||
}
|
||||
}
|
||||
|
||||
// store the image in the cache
|
||||
const ext = fileExt(this.config.backgroundImageUrl);
|
||||
|
||||
const format = extToFormat(ext);
|
||||
if (isWebURI(this.config.backgroundImageUrl)) {
|
||||
const buf = await this.downloadImage();
|
||||
const image = sharp(buf).toFormat(format);
|
||||
await image.toFile(dest);
|
||||
return image;
|
||||
} else {
|
||||
if (fs.existsSync(this.config.backgroundImageUrl)) {
|
||||
const image = sharp(this.config.backgroundImageUrl).toFormat(format);
|
||||
await image.toFile(dest);
|
||||
return image;
|
||||
} else {
|
||||
throw new Error('Background image does not exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prepareBaseImage(): Promise<void> {
|
||||
if (typeof this.baseImage !== 'undefined') {
|
||||
return;
|
||||
}
|
||||
const bgImage = await this.cachedBackgroundImage();
|
||||
const bgInfo = await bgImage.metadata();
|
||||
const imgWidth = bgInfo.width;
|
||||
const imgHeight = bgInfo.height;
|
||||
const imgDensity = bgInfo.density;
|
||||
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined' || typeof imgDensity === 'undefined') {
|
||||
throw new Error('Invalid image dimensions');
|
||||
}
|
||||
|
||||
let svgFrame = '';
|
||||
if (this.config.drawFrame) {
|
||||
svgFrame = `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke-alignment="inner" stroke="rgb(${this.config.frameColorRGB.r},${this.config.frameColorRGB.g},${this.config.frameColorRGB.b})" stroke-width="${this.config.borderWidth}" />`;
|
||||
|
||||
}
|
||||
|
||||
const svgOverlay = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
|
||||
<text y="${this.config.textPositionY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}">
|
||||
<tspan x="${this.config.textPositionX}" dy="0" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${this.config.customText}</tspan>
|
||||
<tspan x="${this.config.textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${this.config.secondaryText}</tspan>
|
||||
</text>
|
||||
${svgFrame}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const overlay = Buffer.from(svgOverlay);
|
||||
|
||||
const composite = bgImage.composite([
|
||||
{
|
||||
input: overlay,
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
]);
|
||||
|
||||
const preparedImg = composite.toFormat(this.imageFormat).withMetadata();
|
||||
this.baseImageMetadata = await preparedImg.metadata();
|
||||
this.baseImage = await preparedImg.toBuffer();
|
||||
|
||||
}
|
||||
|
||||
async render(): Promise<Buffer> {
|
||||
const hits = await this.counter.getHits();
|
||||
|
||||
const numberX = this.config.numberPositionX;
|
||||
const numberY = this.config.numberPositionY;
|
||||
|
||||
await this.prepareBaseImage();
|
||||
|
||||
const imgWidth = this.baseImageMetadata.width;
|
||||
const imgHeight = this.baseImageMetadata.height;
|
||||
if (typeof imgWidth === 'undefined' || typeof imgHeight === 'undefined') {
|
||||
throw new Error('Invalid image dimensions');
|
||||
}
|
||||
|
||||
const svgNumber = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
|
||||
<text x="${numberX}" y="${numberY}" font-family="${this.config.fontFace}" font-size="${this.config.fontSize}" text-anchor="end" fill="rgb(${this.config.numberColorRGB.r},${this.config.numberColorRGB.g},${this.config.numberColorRGB.b})">${hits}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const numberOverlay = Buffer.from(svgNumber);
|
||||
|
||||
const composite = sharp(this.baseImage).composite([
|
||||
{
|
||||
input: numberOverlay,
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
]);
|
||||
|
||||
return composite.toBuffer();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface HitCounterConfig {
|
||||
siteId: number;
|
||||
// path to the database file
|
||||
|
@ -272,9 +63,6 @@ interface HitCounterConfig {
|
|||
drawFrame: boolean; // Whether to draw a frame around the counter
|
||||
}
|
||||
|
||||
|
||||
|
||||
// default configuration
|
||||
const defaultHitCounterConfig: HitCounterConfig = {
|
||||
siteId: 1,
|
||||
counterDB: './hits.db',
|
||||
|
@ -297,78 +85,198 @@ const defaultHitCounterConfig: HitCounterConfig = {
|
|||
drawFrame: true,
|
||||
};
|
||||
|
||||
function fileExt(file: string): string {
|
||||
const ext = file.split('.').pop();
|
||||
if (!ext) throw new Error('Invalid file extension');
|
||||
return ext.toLowerCase();
|
||||
}
|
||||
|
||||
function extToFormat(ext: string): keyof FormatEnum {
|
||||
const format = imageFormatMap[ext];
|
||||
if (!format) throw new Error('Unsupported file extension');
|
||||
return format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given `path` is a http(s) URL. This method
|
||||
* accepts a string value and a URL instance.
|
||||
*
|
||||
* inspiration:
|
||||
* Marcus Pöhls
|
||||
* https://futurestud.io/tutorials/node-js-check-if-a-path-is-a-file-url
|
||||
*
|
||||
* @param {String|URL} path
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isWebURI(path: string | URL): boolean {
|
||||
try {
|
||||
const url = new URL(path.toString());
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class BaseHitCounterRenderer implements HitCounterRenderer {
|
||||
protected counter: HitCounter;
|
||||
protected config: HitCounterConfig;
|
||||
|
||||
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||
this.counter = counter;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async render(): Promise<any> {
|
||||
throw new Error('Render not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
|
||||
private imageFormat: keyof FormatEnum;
|
||||
private baseImage: Buffer;
|
||||
private baseImageMetadata: Metadata;
|
||||
|
||||
constructor(counter: HitCounter, config: HitCounterConfig) {
|
||||
super(counter, config);
|
||||
this.imageFormat = extToFormat(fileExt(this.config.imageFile));
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = Buffer.alloc(0);
|
||||
https.get(this.config.backgroundImageUrl, (response) => {
|
||||
response.on('data', (chunk) => (buf = Buffer.concat([buf, chunk])));
|
||||
response.on('end', () => resolve(buf));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadOrCacheBG(overwrite = false): Promise<Sharp> {
|
||||
if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) {
|
||||
return sharp(this.config.backgroundCacheFile);
|
||||
}
|
||||
|
||||
const imageBuffer = isWebURI(this.config.backgroundImageUrl)
|
||||
? await this.downloadImage()
|
||||
: fs.readFileSync(this.config.backgroundImageUrl);
|
||||
|
||||
const image = sharp(imageBuffer).toFormat(this.imageFormat);
|
||||
await image.toFile(this.config.backgroundCacheFile);
|
||||
return image;
|
||||
}
|
||||
|
||||
private async getBaseImage(): Promise<Buffer> {
|
||||
if (typeof this.baseImage !== 'undefined') {
|
||||
return this.baseImage;
|
||||
}
|
||||
const bgImage = await this.loadOrCacheBG();
|
||||
const bgMetadata = await bgImage.metadata();
|
||||
|
||||
const svgOverlay = this.createSvgOverlay(bgMetadata.width!, bgMetadata.height!);
|
||||
const compositeImage = bgImage.composite([{ input: svgOverlay, top: 0, left: 0, density: bgMetadata.density! }]).withMetadata();
|
||||
this.baseImage = await compositeImage.toBuffer();
|
||||
this.baseImageMetadata = await compositeImage.metadata();
|
||||
|
||||
return this.baseImage;
|
||||
}
|
||||
|
||||
private createSvgOverlay(width: number, height: number): Buffer {
|
||||
const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, frameColorRGB, drawFrame } = this.config;
|
||||
const frame = drawFrame
|
||||
? `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgb(${frameColorRGB.r},${frameColorRGB.g},${frameColorRGB.b})" stroke-width="${this.config.borderWidth}" />`
|
||||
: '';
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||
<text y="${textPositionY}" font-family="${fontFace}" font-size="${fontSize}">
|
||||
<tspan x="${textPositionX}" dy="0" fill="rgb(${this.config.textColorRGB.r},${this.config.textColorRGB.g},${this.config.textColorRGB.b})">${customText}</tspan>
|
||||
<tspan x="${textPositionX}" dy="1.2em" fill="rgb(${this.config.secondaryTextColorRGB.r},${this.config.secondaryTextColorRGB.g},${this.config.secondaryTextColorRGB.b})">${secondaryText}</tspan>
|
||||
</text>
|
||||
${frame}
|
||||
</svg>`;
|
||||
return Buffer.from(svg);
|
||||
}
|
||||
|
||||
private createSVGNumberOverlay(hits: number, imgWidth: number, imgHeight: number): Buffer {
|
||||
const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${imgWidth}" height="${imgHeight}">
|
||||
<text x="${numberPositionX}" y="${numberPositionY}" text-anchor="end" font-family="${fontFace}" font-size="${fontSize}" fill="rgb(${numberColorRGB.r},${numberColorRGB.g},${numberColorRGB.b})">${hits}</text>
|
||||
</svg>`;
|
||||
return Buffer.from(svg);
|
||||
}
|
||||
|
||||
async render(): Promise<Buffer> {
|
||||
const hits = await this.counter.getHits();
|
||||
return await sharp(await this.getBaseImage())
|
||||
.composite([
|
||||
{
|
||||
input: this.createSVGNumberOverlay(hits, this.baseImageMetadata.width!, this.baseImageMetadata.height!),
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
])
|
||||
.toFormat(this.imageFormat)
|
||||
.toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
class HitCounter {
|
||||
private db: DB;
|
||||
private renderer: BaseHitCounterRenderer;
|
||||
private config: HitCounterConfig;
|
||||
private preparedGet: Statement;
|
||||
private preparedIncrement: Statement;
|
||||
private preparedReset: Statement;
|
||||
private preparedStatements: Record<string, Statement>;
|
||||
private mimeType: string;
|
||||
private config: HitCounterConfig;
|
||||
|
||||
constructor(renderer: Type<BaseHitCounterRenderer>, config?: HitCounterConfig) {
|
||||
this.config = { ...defaultHitCounterConfig, ...config };
|
||||
this.renderer = new renderer(this, this.config);
|
||||
this.openDB();
|
||||
this.setupDB();
|
||||
this.setupStatements();
|
||||
this.mimeType = mimeTypeMap[fileExt(this.config.imageFile)];
|
||||
constructor(renderer: Type<BaseHitCounterRenderer>, config: HitCounterConfig = defaultHitCounterConfig) {
|
||||
config = { ...defaultHitCounterConfig, ...config };
|
||||
this.config = config;
|
||||
this.renderer = new renderer(this, config);
|
||||
this.mimeType = mimeTypeMap[fileExt(config.imageFile)];
|
||||
this.db = this.initDB(config.counterDB);
|
||||
this.preparedStatements = this.prepareStatements();
|
||||
}
|
||||
|
||||
private openDB(): void {
|
||||
const db = new Database(this.config.counterDB, {fileMustExist: false});
|
||||
private initDB(filePath: string): DB {
|
||||
const db = new Database(filePath, {fileMustExist: false});
|
||||
db.pragma('locking_mode = EXCLUSIVE');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = OFF');
|
||||
this.db = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
private setupDB(): void {
|
||||
private prepareStatements(): Record<string, Statement> {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS hit_counter (
|
||||
id INTEGER PRIMARY KEY,
|
||||
hits INTEGER NOT NULL
|
||||
hits INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
INSERT OR IGNORE INTO hit_counter (id, hits)
|
||||
VALUES (?, ${this.config.siteId})
|
||||
VALUES (${this.config.siteId}, 0)
|
||||
`);
|
||||
}
|
||||
|
||||
private closeDB(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
private setupStatements(): void {
|
||||
this.preparedGet = this.db.prepare(`
|
||||
SELECT hits
|
||||
FROM hit_counter
|
||||
WHERE id = ?
|
||||
`);
|
||||
this.preparedIncrement = this.db.prepare(`
|
||||
UPDATE hit_counter
|
||||
SET hits = hits + 1
|
||||
WHERE id = ?
|
||||
`);
|
||||
this.preparedReset = this.db.prepare(`
|
||||
UPDATE hit_counter
|
||||
SET hits = 0
|
||||
WHERE id = ?
|
||||
`);
|
||||
return {
|
||||
get: this.db.prepare('SELECT hits FROM hit_counter WHERE id = ?'),
|
||||
increment: this.db.prepare('UPDATE hit_counter SET hits = hits + 1 WHERE id = ?'),
|
||||
reset: this.db.prepare('UPDATE hit_counter SET hits = 0 WHERE id = ?'),
|
||||
};
|
||||
}
|
||||
|
||||
async getHits(): Promise<number> {
|
||||
const result = this.preparedGet.get(this.config.siteId) as HitResult;
|
||||
const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
|
||||
return result.hits;
|
||||
}
|
||||
|
||||
async increment(): Promise<void> {
|
||||
this.preparedIncrement.run(this.config.siteId);
|
||||
this.preparedStatements.increment.run(this.config.siteId);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.preparedReset.run(this.config.siteId);
|
||||
this.preparedStatements.reset.run(this.config.siteId);
|
||||
}
|
||||
|
||||
async render(): Promise<any> {
|
||||
|
@ -379,16 +287,11 @@ class HitCounter {
|
|||
return this.mimeType;
|
||||
}
|
||||
|
||||
// destructor
|
||||
destroy(): void {
|
||||
this.closeDB();
|
||||
finalize(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export {
|
||||
HitCounter,
|
||||
HitCounterConfig,
|
||||
|
@ -396,5 +299,4 @@ export {
|
|||
BaseHitCounterRenderer,
|
||||
ImageHitCounterRenderer
|
||||
};
|
||||
|
||||
export default HitCounter;
|
||||
|
|
Loading…
Reference in a new issue