From 3c34b63cb6cc8b5e3bcddc652b1c3cf222827da5 Mon Sep 17 00:00:00 2001 From: MillenniumEarl Date: Thu, 25 Feb 2021 12:05:54 +0100 Subject: [PATCH] Moved to subfolder "query" --- src/scripts/classes/handiwork-search-query.ts | 114 ------------ .../classes/query/handiwork-search-query.ts | 165 ++++++++++++++++++ .../{ => query}/latest-search-query.ts | 49 ++++-- .../classes/query/thread-search-query.ts | 155 ++++++++++++++++ 4 files changed, 355 insertions(+), 128 deletions(-) delete mode 100644 src/scripts/classes/handiwork-search-query.ts create mode 100644 src/scripts/classes/query/handiwork-search-query.ts rename src/scripts/classes/{ => query}/latest-search-query.ts (64%) create mode 100644 src/scripts/classes/query/thread-search-query.ts diff --git a/src/scripts/classes/handiwork-search-query.ts b/src/scripts/classes/handiwork-search-query.ts deleted file mode 100644 index 81f1b5e..0000000 --- a/src/scripts/classes/handiwork-search-query.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Public modules from npm -import validator from 'class-validator'; - -// Modules from file -import { urls } from "../constants/url.js"; -import PrefixParser from './prefix-parser.js'; -import { TCategory } from "../interfaces"; - -// Type definitions -type TSort = "date" | "likes" | "views" | "title" | "rating"; -type TDate = 365 | 180 | 90 | 30 | 14 | 7 | 3 | 1; - -/** - * Query used to search for specific threads on the platform. - */ -export default class HandiworkSearchQuery { - - //#region Private fields - private static MAX_TAGS = 5; - private static MIN_PAGE = 1; - //#endregion Private fields - - //#region Properties - /** - * Category of items to search among. - * Default: `games` - */ - public category: TCategory = 'games'; - /** - * List of IDs of tags to be included in the search. - * Max. 5 tags - */ - @validator.IsArray({ - message: "Expected an array, received $value" - }) - @validator.ArrayMaxSize(HandiworkSearchQuery.MAX_TAGS, { - message: "Too many tags: $value instead of $constraint1" - }) - public tags: string[] = []; - /** - * List of IDs of prefixes to be included in the search. - */ - @validator.IsArray({ - message: "Expected an array, received $value" - }) - public prefixes: string[] = []; - /** - * Sorting type. Default: `date`. - */ - public sort: TSort = 'date'; - /** - * Date limit in days, to be understood as "less than". - * Use `1` to indicate "today" or `null` to indicate "anytime". - * Default: `null` - */ - public date: TDate = null; - /** - * Index of the page to be obtained. - * Between 1 and infinity. - * Default: 1. - */ - @validator.IsInt({ - message: "$property expect an integer, received $value" - }) - @validator.Min(HandiworkSearchQuery.MIN_PAGE, { - message: "The minimum $property value must be $constraint1, received $value" - }) - public page = HandiworkSearchQuery.MIN_PAGE; - //#endregion Properties - - //#region Public methods - /** - * Verify that the query values are valid. - */ - public validate(): boolean { - return validator.validateSync(this).length === 0; - } - - /** - * From the query values it generates the corresponding URL for the platform. - * If the query is invalid it throws an exception. - */ - public createUrl(): URL { - // Check if the query is valid - if (!this.validate()) { - throw new Error("Invalid query") - } - - // Create the URL - const url = new URL(urls.F95_LATEST_PHP); - url.searchParams.set("cmd", "list"); - - // Set the category - url.searchParams.set("cat", this.category); - - // Add tags and prefixes - const parser = new PrefixParser(); - for (const tag of parser.prefixesToIDs(this.tags)) { - url.searchParams.append("tags[]", tag.toString()); - } - - for (const p of parser.prefixesToIDs(this.prefixes)) { - url.searchParams.append("prefixes[]", p.toString()); - } - - // Set the other values - url.searchParams.set("sort", this.sort.toString()); - url.searchParams.set("page", this.page.toString()); - if(this.date) url.searchParams.set("date", this.date.toString()); - - return url; - } - //#endregion Public methods -} \ No newline at end of file diff --git a/src/scripts/classes/query/handiwork-search-query.ts b/src/scripts/classes/query/handiwork-search-query.ts new file mode 100644 index 0000000..9b98cbd --- /dev/null +++ b/src/scripts/classes/query/handiwork-search-query.ts @@ -0,0 +1,165 @@ +"use strict"; + +// Public modules from npm +import validator from 'class-validator'; + +// Module from files +import { IQuery, TCategory, TQueryInterface } from "../../interfaces"; +import LatestSearchQuery, { TLatestOrder } from './latest-search-query'; +import ThreadSearchQuery, { TThreadOrder } from './thread-search-query'; + +// Type definitions +/** + * Method of sorting results. Try to unify the two types of + * sorts in the "Latest" section and in the "Thread search" + * section. Being dynamic research, if a sorting type is not + * available, the replacement sort is chosen. + * + * `date`: Order based on the latest update + * + * `likes`: Order based on the number of likes received. Replacement: `replies`. + * + * `relevance`: Order based on the relevance of the result (or rating). + * + * `replies`: Order based on the number of answers to the thread. Replacement: `views`. + * + * `title`: Order based on the growing alphabetical order of the titles. + * + * `views`: Order based on the number of visits. Replacement: `replies`. + */ +type THandiworkOrder = "date" | "likes" | "relevance" | "replies" | "title" | "views"; + +export default class HandiworkSearchQuery implements IQuery { + + //#region Private fields + static MIN_PAGE = 1; + //#endregion Private fields + + //#region Properties + /** + * Keywords to use in the search. + */ + public keywords: string = ""; + /** + * The results must be more recent than the date indicated. + */ + public newerThan: Date = null; + /** + * The results must be older than the date indicated. + */ + public olderThan: Date = null; + public includedTags: string[] = []; + /** + * Tags to exclude from the search. + */ + public excludedTags: string[] = []; + public includedPrefixes: string[]; + public category: TCategory; + /** + * Results presentation order. + */ + public order: THandiworkOrder = "relevance"; + @validator.IsInt({ + message: "$property expect an integer, received $value" + }) + @validator.Min(HandiworkSearchQuery.MIN_PAGE, { + message: "The minimum $property value must be $constraint1, received $value" + }) + public page: number = 1; + itype: TQueryInterface = "HandiworkSearchQuery"; + //#endregion Properties + + //#region Public methods + /** + * Select what kind of search should be + * performed based on the properties of + * the query. + */ + public selectSearchType(): "latest" | "thread" { + // Local variables + const MAX_TAGS_LATEST_SEARCH = 5; + const DEFAULT_SEARCH_TYPE = "latest"; + + // If the keywords are set or the number + // of included tags is greather than 5, + // we must perform a thread search + if (this.keywords || this.includedTags.length > MAX_TAGS_LATEST_SEARCH) return "thread"; + + return DEFAULT_SEARCH_TYPE; + } + + public validate(): boolean { + return validator.validateSync(this).length === 0; + } + + public createURL(): URL { + // Local variables + let query: LatestSearchQuery | ThreadSearchQuery = null; + + // Check if the query is valid + if (!this.validate()) { + throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`); + } + + // Convert the query + if (this.selectSearchType() === "latest") query = this.cast(); + else query = this.cast(); + + return query.createURL(); + } + + public cast(): T { + // Local variables + let returnValue = null; + + // Cast the query + const query:T = {} as IQuery as T; + + // Convert the query + if (query.itype === "LatestSearchQuery") returnValue = this.castToLatest(); + else if (query.itype === "ThreadSearchQuery") returnValue = this.castToThread(); + else returnValue = this as HandiworkSearchQuery; + + // Cast the result to T + return returnValue as T; + } + //#endregion Public methods + + //#region Private methods + private castToLatest(): LatestSearchQuery { + // Cast the basic query object + const query: LatestSearchQuery = this as IQuery as LatestSearchQuery; + let orderFilter = this.order as string; + query.itype = "LatestSearchQuery"; + + // Adapt order filter + if (orderFilter === "relevance") orderFilter = "rating"; + else if (orderFilter === "replies") orderFilter = "views"; + query.order = orderFilter as TLatestOrder; + + // Adapt date + if (this.newerThan) query.date = query.findNearestDate(this.newerThan); + + return query; + } + + private castToThread(): ThreadSearchQuery { + // Cast the basic query object + const query: ThreadSearchQuery = this as IQuery as ThreadSearchQuery; + let orderFilter = this.order as string; + + // Set common values + query.excludedTags = this.excludedTags; + query.newerThan = this.newerThan; + query.olderThan = this.olderThan; + query.onlyTitles = true; + query.keywords = this.keywords; + + // Adapt order filter + if (orderFilter === "likes" || orderFilter === "title") orderFilter = "relevance"; + query.order = orderFilter as TThreadOrder; + + return query; + } + //#endregion +} \ No newline at end of file diff --git a/src/scripts/classes/latest-search-query.ts b/src/scripts/classes/query/latest-search-query.ts similarity index 64% rename from src/scripts/classes/latest-search-query.ts rename to src/scripts/classes/query/latest-search-query.ts index 2418131..7ef5f13 100644 --- a/src/scripts/classes/latest-search-query.ts +++ b/src/scripts/classes/query/latest-search-query.ts @@ -2,12 +2,12 @@ import validator from 'class-validator'; // Modules from file -import { urls } from "../constants/url.js"; -import PrefixParser from './prefix-parser.js'; -import { IQuery, TCategory } from "../interfaces"; +import { urls } from "../../constants/url.js"; +import PrefixParser from '../prefix-parser.js'; +import { IQuery, TCategory, TQueryInterface } from "../../interfaces"; // Type definitions -type TOrder = "date" | "likes" | "views" | "title" | "rating"; +export type TLatestOrder = "date" | "likes" | "views" | "title" | "rating"; type TDate = 365 | 180 | 90 | 30 | 14 | 7 | 3 | 1; /** @@ -27,7 +27,7 @@ export default class LatestSearchQuery implements IQuery { * * Default: `date`. */ - public order: TOrder = 'date'; + public order: TLatestOrder = 'date'; /** * Date limit in days, to be understood as "less than". * Use `1` to indicate "today" or `null` to indicate "anytime". @@ -40,7 +40,6 @@ export default class LatestSearchQuery implements IQuery { message: "Too many tags: $value instead of $constraint1" }) public includedTags: string[] = []; - public includedPrefixes: string[] = []; @validator.IsInt({ @@ -50,24 +49,19 @@ export default class LatestSearchQuery implements IQuery { message: "The minimum $property value must be $constraint1, received $value" }) public page = LatestSearchQuery.MIN_PAGE; + itype: TQueryInterface = "LatestSearchQuery"; //#endregion Properties //#region Public methods - /** - * Verify that the query values are valid. - */ + public validate(): boolean { return validator.validateSync(this).length === 0; } - /** - * From the query values it generates the corresponding URL for the platform. - * If the query is invalid it throws an exception. - */ public createURL(): URL { // Check if the query is valid if (!this.validate()) { - throw new Error("Invalid query") + throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`); } // Create the URL @@ -94,5 +88,32 @@ export default class LatestSearchQuery implements IQuery { return url; } + + public findNearestDate(d: Date): TDate { + // Find the difference between today and the passed date + const diff = this.dateDiffInDays(new Date(), d); + + // Find the closest valid value in the array + const closest = [365, 180, 90, 30, 14, 7, 3, 1].reduce(function (prev, curr) { + return (Math.abs(curr - diff) < Math.abs(prev - diff) ? curr : prev); + }); + + return closest as TDate; + } //#endregion Public methods + + //#region Private methods + /** + * + */ + private dateDiffInDays(a: Date, b: Date) { + const MS_PER_DAY = 1000 * 60 * 60 * 24; + + // Discard the time and time-zone information. + const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((utc2 - utc1) / MS_PER_DAY); + } + //#endregion Private methodss } \ No newline at end of file diff --git a/src/scripts/classes/query/thread-search-query.ts b/src/scripts/classes/query/thread-search-query.ts new file mode 100644 index 0000000..3bd3a9a --- /dev/null +++ b/src/scripts/classes/query/thread-search-query.ts @@ -0,0 +1,155 @@ +"use strict"; + +// Public modules from npm +import validator from 'class-validator'; + +// Module from files +import { IQuery, TCategory, TQueryInterface } from "../interfaces"; +import { urls } from "../constants/url"; +import PrefixParser from "./prefix-parser"; + +// Type definitions +export type TThreadOrder = "relevance" | "date" | "last_update" | "replies"; + +export default class ThreadSearchQuery implements IQuery { + + //#region Private fields + static MIN_PAGE = 1; + //#endregion Private fields + + //#region Properties + /** + * Keywords to use in the search. + */ + public keywords: string = ""; + /** + * Indicates to search by checking only the thread titles and not the content. + */ + public onlyTitles: boolean = false; + /** + * The results must be more recent than the date indicated. + */ + public newerThan: Date = null; + /** + * The results must be older than the date indicated. + */ + public olderThan: Date = null; + public includedTags: string[] = []; + /** + * Tags to exclude from the search. + */ + public excludedTags: string[] = []; + /** + * Minimum number of answers that the thread must possess. + */ + public minimumReplies: number = 0; + public includedPrefixes: string[]; + public category: TCategory; + /** + * Results presentation order. + */ + public order: TThreadOrder = "relevance"; + @validator.IsInt({ + message: "$property expect an integer, received $value" + }) + @validator.Min(ThreadSearchQuery.MIN_PAGE, { + message: "The minimum $property value must be $constraint1, received $value" + }) + public page: number = 1; + itype: TQueryInterface = "ThreadSearchQuery"; + //#endregion Properties + + //#region Public methods + + public validate(): boolean { + return validator.validateSync(this).length === 0; + } + + public createURL(): URL { + // Check if the query is valid + if (!this.validate()) { + throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`); + } + + // Create the URL + const url = new URL(urls.F95_SEARCH_URL); + + // Specifiy if only the title should be searched + if (this.onlyTitles) url.searchParams.set("c[title_only]", "1"); + + // Add keywords + const encodedKeywords = this.keywords ? encodeURIComponent(this.keywords) : "*"; + url.searchParams.set("q", encodedKeywords); + + // Specify the scope of the search (only "threads/post") + url.searchParams.set("t", "post"); + + // Set the dates + if (this.newerThan) { + const date = this.convertShortDate(this.newerThan); + url.searchParams.set("c[newer_than]", date); + } + + if (this.olderThan) { + const date = this.convertShortDate(this.olderThan); + url.searchParams.set("c[older_than]", date); + } + + // Set included and excluded tags + // The tags are first joined with a comma, then encoded to URI + const includedTags = encodeURIComponent(this.includedTags.join(",")); + const excludedTags = encodeURIComponent(this.excludedTags.join(",")); + url.searchParams.set("c[tags]", includedTags); + url.searchParams.set("c[excludeTags]", excludedTags); + + // Set minimum reply number + url.searchParams.set("c[min_reply_count]", this.minimumReplies.toString()); + + // Add prefixes + const parser = new PrefixParser(); + const ids = parser.prefixesToIDs(this.includedPrefixes); + for (let i = 0; i < ids.length; i++) { + const name = `c[prefixes][${i}]`; + url.searchParams.set(name, ids[i].toString()); + } + + // Set the category + const catID = this.categoryToID(this.category).toString(); + url.searchParams.set("c[child_nodes]", "1"); // Always set + url.searchParams.set("c[nodes][0]", catID); + + // Set the other values + url.searchParams.set("o", this.order.toString()); + url.searchParams.set("page", this.page.toString()); + + return url; + } + //#endregion Public methods + + //#region Private methods + /** + * Convert a date in the YYYY-MM-DD format taking into account the time zone. + */ + private convertShortDate(d: Date): string { + const offset = d.getTimezoneOffset() + d = new Date(d.getTime() - (offset * 60 * 1000)) + return d.toISOString().split('T')[0] + } + + /** + * Gets the unique ID of the selected category. + */ + private categoryToID(category: TCategory): number { + const catMap = { + "games": 1, + "mods": 41, + "comics": 40, + "animations": 94, + "assets": 95, + } + + return catMap[category as string]; + } + //#endregion Private methods + +} \ No newline at end of file