Moved to subfolder "query"

pull/73/head
MillenniumEarl 2021-02-25 12:05:54 +01:00
parent 2c9f8e2d0b
commit 3c34b63cb6
4 changed files with 355 additions and 128 deletions

View File

@ -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
}

View File

@ -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<LatestSearchQuery>();
else query = this.cast<ThreadSearchQuery>();
return query.createURL();
}
public cast<T extends IQuery>(): 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
}

View File

@ -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;
}
//#endregion Public methods
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
}

View File

@ -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
}