mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-22 17:47:16 +00:00
First stab at adopting Grats!
This commit is contained in:
parent
b8438caf42
commit
59a1ed88db
22 changed files with 940 additions and 106 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { Int } from "grats";
|
||||
import SkinModel from "../../data/SkinModel";
|
||||
import { knex } from "../../db";
|
||||
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
|
||||
|
||||
/** @gqlType */
|
||||
export default class ModernSkinsConnection {
|
||||
_first: number;
|
||||
_offset: number;
|
||||
|
|
@ -14,12 +16,14 @@ export default class ModernSkinsConnection {
|
|||
return query;
|
||||
}
|
||||
|
||||
async count() {
|
||||
/** @gqlField */
|
||||
async count(): Promise<Int> {
|
||||
const count = await this._getQuery().count("*", { as: "count" });
|
||||
return count[0].count;
|
||||
return Number(count[0].count);
|
||||
}
|
||||
|
||||
async nodes(_args, ctx) {
|
||||
/** @gqlField */
|
||||
async nodes(_args: never, ctx): Promise<ModernSkinResolver[]> {
|
||||
const skins = await this._getQuery()
|
||||
.select()
|
||||
.limit(this._first)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import * as Skins from "../../data/skins";
|
|||
import { knex } from "../../db";
|
||||
import SkinResolver from "./resolvers/SkinResolver";
|
||||
import LRU from "lru-cache";
|
||||
import { Int } from "grats";
|
||||
import { ISkin } from "./resolvers/CommonSkinResolver";
|
||||
|
||||
const options = {
|
||||
max: 100,
|
||||
|
|
@ -34,12 +36,13 @@ async function getSkinMuseumPageFromCache(first: number, offset: number) {
|
|||
return skins;
|
||||
}
|
||||
|
||||
/** @gqlType */
|
||||
export default class SkinsConnection {
|
||||
_first: number;
|
||||
_offset: number;
|
||||
_sort: string;
|
||||
_filter: string;
|
||||
constructor(first: number, offset: number, sort: string, filter: string) {
|
||||
_sort?: string;
|
||||
_filter?: string;
|
||||
constructor(first: number, offset: number, sort?: string, filter?: string) {
|
||||
this._first = first;
|
||||
this._offset = offset;
|
||||
this._filter = filter;
|
||||
|
|
@ -73,16 +76,18 @@ export default class SkinsConnection {
|
|||
return query;
|
||||
}
|
||||
|
||||
async count() {
|
||||
/** @gqlField */
|
||||
async count(): Promise<Int> {
|
||||
if (this._sort === "MUSEUM") {
|
||||
// This is the common case, so serve it from cache.
|
||||
return getMuseumSkinCountFromCache();
|
||||
}
|
||||
const count = await this._getQuery().count("*", { as: "count" });
|
||||
return count[0].count;
|
||||
return Number(count[0].count);
|
||||
}
|
||||
|
||||
async nodes(args, ctx) {
|
||||
/** @gqlField */
|
||||
async nodes(args: never, ctx): Promise<ISkin[]> {
|
||||
if (this._sort === "MUSEUM") {
|
||||
if (this._filter) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { Int } from "grats";
|
||||
import TweetModel from "../../data/TweetModel";
|
||||
import { knex } from "../../db";
|
||||
import TweetResolver from "./resolvers/TweetResolver";
|
||||
|
||||
/** @gqlEnum */
|
||||
export type TweetsSortOption = "LIKES" | "RETWEETS";
|
||||
|
||||
/** @gqlType */
|
||||
export default class TweetsConnection {
|
||||
_first: number;
|
||||
_offset: number;
|
||||
_sort: string;
|
||||
constructor(first: number, offset: number, sort: string) {
|
||||
_sort?: TweetsSortOption;
|
||||
constructor(first: number, offset: number, sort?: TweetsSortOption) {
|
||||
this._first = first;
|
||||
this._offset = offset;
|
||||
this._sort = sort;
|
||||
|
|
@ -23,12 +28,20 @@ export default class TweetsConnection {
|
|||
return query;
|
||||
}
|
||||
|
||||
async count() {
|
||||
/**
|
||||
* The total number of tweets
|
||||
* @gqlField
|
||||
*/
|
||||
async count(): Promise<Int> {
|
||||
const count = await this._getQuery().count("*", { as: "count" });
|
||||
return count[0].count;
|
||||
return Number(count[0].count);
|
||||
}
|
||||
|
||||
async nodes(args, ctx) {
|
||||
/**
|
||||
* The list of tweets
|
||||
* @gqlField
|
||||
*/
|
||||
async nodes(args: never, ctx): Promise<TweetResolver[]> {
|
||||
const tweets = await this._getQuery()
|
||||
.select()
|
||||
.limit(this._first)
|
||||
|
|
|
|||
257
packages/skin-database/api/graphql/grats-schema.graphql
Normal file
257
packages/skin-database/api/graphql/grats-schema.graphql
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
|
||||
directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION
|
||||
|
||||
directive @methodName(name: String!) on FIELD_DEFINITION
|
||||
|
||||
type ArchiveFile {
|
||||
date: String
|
||||
file_md5: String
|
||||
filename: String
|
||||
is_directory: Boolean
|
||||
size: Int
|
||||
skin: Skin
|
||||
text_content: String
|
||||
url: String
|
||||
}
|
||||
|
||||
type ClassicSkin implements Node & Skin {
|
||||
archive_files: [ArchiveFile!]
|
||||
average_color: String
|
||||
download_url: String
|
||||
filename(normalize_extension: Boolean = false): String
|
||||
has_media_library: Boolean
|
||||
id: ID!
|
||||
internet_archive_item: InternetArchiveItem
|
||||
last_algolia_index_update_date: String
|
||||
md5: String
|
||||
museum_url: String
|
||||
nsfw: Boolean
|
||||
readme_text: String
|
||||
reviews: [Review!]
|
||||
screenshot_url: String
|
||||
transparent_pixels: Int
|
||||
tweeted: Boolean
|
||||
tweets: [Tweet!]
|
||||
webamp_url: String
|
||||
}
|
||||
|
||||
type DatabaseStatistics {
|
||||
approved_skins_count: Int
|
||||
nsfw_skins_count: Int
|
||||
rejected_skins_count: Int
|
||||
tweetable_skins_count: Int
|
||||
tweeted_skins_count: Int
|
||||
unique_classic_skins_count: Int
|
||||
unreviewed_skins_count: Int
|
||||
uploads_in_error_state_count: Int
|
||||
uploads_pending_processing_count: Int
|
||||
web_uploads_count: Int
|
||||
}
|
||||
|
||||
type InternetArchiveItem {
|
||||
identifier: String
|
||||
last_metadata_scrape_date_UNSTABLE: String
|
||||
metadata_url: String
|
||||
raw_metadata_json: String
|
||||
skin: Skin
|
||||
url: String
|
||||
}
|
||||
|
||||
type ModernSkin implements Node & Skin {
|
||||
archive_files: [ArchiveFile!]
|
||||
average_color: String
|
||||
download_url: String
|
||||
filename(normalize_extension: Boolean = false): String
|
||||
id: ID!
|
||||
internet_archive_item: InternetArchiveItem
|
||||
md5: String
|
||||
museum_url: String
|
||||
nsfw: Boolean
|
||||
readme_text: String
|
||||
reviews: [Review!]
|
||||
screenshot_url: String
|
||||
tweeted: Boolean
|
||||
tweets: [Tweet!]
|
||||
webamp_url: String
|
||||
}
|
||||
|
||||
type ModernSkinsConnection {
|
||||
count: Int
|
||||
nodes: [ModernSkin!]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
approve_skin(md5: String!): Boolean
|
||||
mark_skin_nsfw(md5: String!): Boolean
|
||||
reject_skin(md5: String!): Boolean
|
||||
request_nsfw_review_for_skin(md5: String!): Boolean
|
||||
send_feedback(email: String, message: String, url: String): Boolean
|
||||
upload: UploadMutations
|
||||
}
|
||||
|
||||
"""
|
||||
A globally unique object. The `id` here is intended only for use within
|
||||
GraphQL.
|
||||
https://graphql.org/learn/global-object-identification/
|
||||
"""
|
||||
interface Node {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""
|
||||
Fetch archive file by it's MD5 hash
|
||||
Get information about a file found within a skin's wsz/wal/zip archive.
|
||||
"""
|
||||
fetch_archive_file_by_md5(md5: String!): ArchiveFile
|
||||
"""
|
||||
Get an archive.org item by its identifier. You can find this in the URL:
|
||||
https://archive.org/details/<identifier>/
|
||||
"""
|
||||
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem
|
||||
"""Get a skin by its MD5 hash"""
|
||||
fetch_skin_by_md5(md5: String!): Skin
|
||||
"""Get a tweet by its URL"""
|
||||
fetch_tweet_by_url(url: String!): Tweet
|
||||
"""The currently authenticated user, if any."""
|
||||
me: User
|
||||
modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection
|
||||
"""
|
||||
Get a globally unique object by its ID.
|
||||
https://graphql.org/learn/global-object-identification/
|
||||
"""
|
||||
node(id: ID!): Node
|
||||
"""
|
||||
Search the database using the Algolia search index used by the Museum.
|
||||
|
||||
Useful for locating a particular skin.
|
||||
"""
|
||||
search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin!]
|
||||
skin_to_review: Skin
|
||||
skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection
|
||||
statistics: DatabaseStatistics
|
||||
"""Tweets tweeted by @winampskins"""
|
||||
tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection
|
||||
upload_statuses(ids: [String!]!): [SkinUpload!]
|
||||
"""Get the status of a batch of uploads by md5s"""
|
||||
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload!]
|
||||
}
|
||||
|
||||
enum Rating {
|
||||
APPROVED
|
||||
NSFW
|
||||
REJECTED
|
||||
}
|
||||
|
||||
"""
|
||||
A review of a skin. Done either on the Museum's Tinder-style
|
||||
reivew page, or via the Discord bot.
|
||||
"""
|
||||
type Review {
|
||||
"""The rating that the user gave the skin"""
|
||||
rating: Rating
|
||||
"""
|
||||
The user who made the review (if known). **Note:** In the early days we
|
||||
didn't track this, so many will be null.
|
||||
"""
|
||||
reviewer: String
|
||||
"""The skin that was reviewed"""
|
||||
skin: Skin
|
||||
}
|
||||
|
||||
interface Skin {
|
||||
archive_files: [ArchiveFile!]
|
||||
average_color: String @deprecated(reason: "Needed for migration to new skin model")
|
||||
download_url: String
|
||||
filename(normalize_extension: Boolean = false): String
|
||||
id: ID!
|
||||
internet_archive_item: InternetArchiveItem
|
||||
md5: String
|
||||
museum_url: String @deprecated(reason: "Needed for migration to new skin model")
|
||||
nsfw: Boolean @deprecated(reason: "Needed for migration to new skin model")
|
||||
readme_text: String @deprecated(reason: "Needed for migration to new skin model")
|
||||
reviews: [Review!]
|
||||
screenshot_url: String @deprecated(reason: "Needed for migration to new skin model")
|
||||
tweeted: Boolean
|
||||
tweets: [Tweet!]
|
||||
webamp_url: String @deprecated(reason: "Needed for migration to new skin model")
|
||||
}
|
||||
|
||||
type SkinUpload {
|
||||
id: String
|
||||
skin: Skin
|
||||
status: SkinUploadStatus
|
||||
upload_md5: String
|
||||
}
|
||||
|
||||
"""
|
||||
The current status of a pending upload.
|
||||
**Note:** Expect more values here as we try to be more transparent about
|
||||
the status of a pending uploads.
|
||||
"""
|
||||
enum SkinUploadStatus {
|
||||
ARCHIVED
|
||||
DELAYED
|
||||
ERRORED
|
||||
UPLOAD_REPORTED
|
||||
URL_REQUESTED
|
||||
}
|
||||
|
||||
type SkinsConnection {
|
||||
count: Int
|
||||
nodes: [Skin!]
|
||||
}
|
||||
|
||||
enum SkinsFilterOption {
|
||||
APPROVED
|
||||
NSFW
|
||||
REJECTED
|
||||
TWEETED
|
||||
}
|
||||
|
||||
enum SkinsSortOption {
|
||||
MUSEUM
|
||||
}
|
||||
|
||||
type Tweet {
|
||||
likes: Int
|
||||
retweets: Int
|
||||
skin: Skin
|
||||
url: String
|
||||
}
|
||||
|
||||
type TweetsConnection {
|
||||
"""The total number of tweets"""
|
||||
count: Int
|
||||
"""The list of tweets"""
|
||||
nodes: [Tweet!]
|
||||
}
|
||||
|
||||
enum TweetsSortOption {
|
||||
LIKES
|
||||
RETWEETS
|
||||
}
|
||||
|
||||
type UploadMutations {
|
||||
get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl!]
|
||||
report_skin_uploaded(id: String!, md5: String!): Boolean
|
||||
}
|
||||
|
||||
type UploadUrl {
|
||||
id: String
|
||||
md5: String
|
||||
url: String
|
||||
}
|
||||
|
||||
input UploadUrlRequest {
|
||||
filename: String!
|
||||
md5: String!
|
||||
}
|
||||
|
||||
type User {
|
||||
username: String
|
||||
}
|
||||
|
|
@ -1,35 +1,49 @@
|
|||
import { Int } from "grats";
|
||||
import ArchiveFileModel from "../../../data/ArchiveFileModel";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
||||
/** @gqlType ArchiveFile */
|
||||
export default class ArchiveFileResolver {
|
||||
_model: ArchiveFileModel;
|
||||
constructor(model: ArchiveFileModel) {
|
||||
this._model = model;
|
||||
}
|
||||
filename() {
|
||||
|
||||
/** @gqlField */
|
||||
filename(): string {
|
||||
return this._model.getFileName();
|
||||
}
|
||||
url() {
|
||||
/** @gqlField */
|
||||
url(): string {
|
||||
return this._model.getUrl();
|
||||
}
|
||||
file_md5() {
|
||||
/** @gqlField */
|
||||
file_md5(): string {
|
||||
return this._model.getFileMd5();
|
||||
}
|
||||
size() {
|
||||
/** @gqlField */
|
||||
size(): Promise<Int | null> {
|
||||
return this._model.getFileSize();
|
||||
}
|
||||
text_content() {
|
||||
/** @gqlField */
|
||||
text_content(): Promise<string | null> {
|
||||
return this._model.getTextContent();
|
||||
}
|
||||
is_directory() {
|
||||
/** @gqlField */
|
||||
is_directory(): boolean {
|
||||
return this._model.getIsDirectory();
|
||||
}
|
||||
async skin(_, { ctx }) {
|
||||
|
||||
/** @gqlField */
|
||||
async skin(_: never, { ctx }): Promise<ISkin | null> {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, this._model.getMd5());
|
||||
return SkinResolver.fromModel(model);
|
||||
}
|
||||
date() {
|
||||
|
||||
/** @gqlField */
|
||||
date(): string {
|
||||
return this._model.getFileDate().toISOString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,100 @@
|
|||
import CommonSkinResolver from "./CommonSkinResolver";
|
||||
import CommonSkinResolver, { ISkin } from "./CommonSkinResolver";
|
||||
import { NodeResolver, toId } from "./NodeResolver";
|
||||
import ReviewResolver from "./ReviewResolver";
|
||||
import path from "path";
|
||||
import { ID, Int } from "grats";
|
||||
import TweetResolver from "./TweetResolver";
|
||||
import ArchiveFileResolver from "./ArchiveFileResolver";
|
||||
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
|
||||
|
||||
/** @gqlType ClassicSkin */
|
||||
export default class ClassicSkinResolver
|
||||
extends CommonSkinResolver
|
||||
implements NodeResolver
|
||||
implements NodeResolver, ISkin
|
||||
{
|
||||
__typename = "ClassicSkin";
|
||||
async id() {
|
||||
/**
|
||||
* @gqlField
|
||||
* @killsParentOnException
|
||||
*/
|
||||
id(): ID {
|
||||
return toId(this.__typename, this.md5());
|
||||
}
|
||||
async filename(normalize_extension = false) {
|
||||
/** @gqlField */
|
||||
async filename({
|
||||
normalize_extension = false,
|
||||
}: {
|
||||
normalize_extension?: boolean;
|
||||
}): Promise<string> {
|
||||
const filename = await this._model.getFileName();
|
||||
if (normalize_extension) {
|
||||
return path.parse(filename).name + ".wsz";
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
museum_url() {
|
||||
|
||||
/** @gqlField */
|
||||
md5(): string {
|
||||
return super.md5();
|
||||
}
|
||||
/** @gqlField */
|
||||
download_url(): string {
|
||||
return super.download_url();
|
||||
}
|
||||
/** @gqlField */
|
||||
tweeted(): Promise<boolean> {
|
||||
return super.tweeted();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
tweets(): Promise<TweetResolver[]> {
|
||||
return super.tweets();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
archive_files(): Promise<ArchiveFileResolver[]> {
|
||||
return super.archive_files();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
|
||||
return super.internet_archive_item();
|
||||
}
|
||||
/** @gqlField */
|
||||
museum_url(): string {
|
||||
return this._model.getMuseumUrl();
|
||||
}
|
||||
webamp_url() {
|
||||
/** @gqlField */
|
||||
webamp_url(): string {
|
||||
return this._model.getWebampUrl();
|
||||
}
|
||||
screenshot_url() {
|
||||
/** @gqlField */
|
||||
screenshot_url(): string {
|
||||
return this._model.getScreenshotUrl();
|
||||
}
|
||||
readme_text() {
|
||||
/** @gqlField */
|
||||
readme_text(): Promise<string | null> {
|
||||
return this._model.getReadme();
|
||||
}
|
||||
nsfw() {
|
||||
/** @gqlField */
|
||||
nsfw(): Promise<boolean> {
|
||||
return this._model.getIsNsfw();
|
||||
}
|
||||
average_color() {
|
||||
/** @gqlField */
|
||||
average_color(): string {
|
||||
return this._model.getAverageColor();
|
||||
}
|
||||
/** @gqlField */
|
||||
has_media_library(): Promise<boolean> {
|
||||
return this._model.hasMediaLibrary();
|
||||
}
|
||||
async reviews() {
|
||||
/** @gqlField */
|
||||
async reviews(): Promise<ReviewResolver[]> {
|
||||
const reviews = await this._model.getReviews();
|
||||
return reviews.map((row) => new ReviewResolver(row));
|
||||
}
|
||||
async last_algolia_index_update_date() {
|
||||
/** @gqlField */
|
||||
async last_algolia_index_update_date(): Promise<string | null> {
|
||||
const updates = await this._model.getAlgoliaIndexUpdates(1);
|
||||
if (updates.length < 1) {
|
||||
return null;
|
||||
|
|
@ -51,7 +102,8 @@ export default class ClassicSkinResolver
|
|||
const update = updates[0];
|
||||
return new Date(update.update_timestamp * 1000).toISOString();
|
||||
}
|
||||
transparent_pixels(): Promise<number> {
|
||||
/** @gqlField */
|
||||
transparent_pixels(): Promise<Int> {
|
||||
return this._model.transparentPixels();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,108 @@
|
|||
import { ID } from "grats";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import ArchiveFileResolver from "./ArchiveFileResolver";
|
||||
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
|
||||
import ReviewResolver from "./ReviewResolver";
|
||||
import TweetResolver from "./TweetResolver";
|
||||
|
||||
/** @gqlInterface Skin */
|
||||
export interface ISkin {
|
||||
__typename: string;
|
||||
/**
|
||||
* @gqlField
|
||||
* @killsParentOnException
|
||||
*/
|
||||
id(): ID;
|
||||
|
||||
/** @gqlField */
|
||||
filename({
|
||||
normalize_extension = false,
|
||||
}: {
|
||||
normalize_extension?: boolean;
|
||||
}): Promise<string>;
|
||||
|
||||
/** @gqlField */
|
||||
md5(): string;
|
||||
|
||||
/** @gqlField */
|
||||
download_url(): string;
|
||||
|
||||
/** @gqlField */
|
||||
tweeted(): Promise<boolean>;
|
||||
|
||||
/** @gqlField */
|
||||
tweets(): Promise<TweetResolver[]>;
|
||||
|
||||
/** @gqlField */
|
||||
archive_files(): Promise<ArchiveFileResolver[]>;
|
||||
|
||||
/** @gqlField */
|
||||
internet_archive_item(): Promise<InternetArchiveItemResolver | null>;
|
||||
|
||||
/** @gqlField */
|
||||
reviews(): Promise<ReviewResolver[] | null>;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
museum_url(): string | null;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
webamp_url(): string | null;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
screenshot_url(): string | null;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
readme_text(): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
nsfw(): Promise<boolean | null>;
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @deprecated Needed for migration to new skin model
|
||||
*/
|
||||
average_color(): string | null;
|
||||
}
|
||||
|
||||
export default class CommonSkinResolver {
|
||||
_model: SkinModel;
|
||||
constructor(model: SkinModel) {
|
||||
this._model = model;
|
||||
}
|
||||
md5() {
|
||||
md5(): string {
|
||||
return this._model.getMd5();
|
||||
}
|
||||
|
||||
download_url() {
|
||||
download_url(): string {
|
||||
return this._model.getSkinUrl();
|
||||
}
|
||||
tweeted() {
|
||||
tweeted(): Promise<boolean> {
|
||||
return this._model.tweeted();
|
||||
}
|
||||
async tweets() {
|
||||
async tweets(): Promise<TweetResolver[]> {
|
||||
const tweets = await this._model.getTweets();
|
||||
return tweets.map((tweetModel) => new TweetResolver(tweetModel));
|
||||
}
|
||||
async archive_files() {
|
||||
async archive_files(): Promise<ArchiveFileResolver[]> {
|
||||
const files = await this._model.getArchiveFiles();
|
||||
return files.map((file) => new ArchiveFileResolver(file));
|
||||
}
|
||||
async internet_archive_item() {
|
||||
async internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
|
||||
const item = await this._model.getIaItem();
|
||||
if (item == null) {
|
||||
return null;
|
||||
|
|
@ -35,7 +110,7 @@ export default class CommonSkinResolver {
|
|||
return new InternetArchiveItemResolver(item);
|
||||
}
|
||||
|
||||
async reviews() {
|
||||
async reviews(): Promise<ReviewResolver[] | null> {
|
||||
const reviews = await this._model.getReviews();
|
||||
return reviews.map((row) => new ReviewResolver(row));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,46 @@
|
|||
import { Int } from "grats";
|
||||
import * as Skins from "../../../data/skins";
|
||||
|
||||
/** @gqlType DatabaseStatistics */
|
||||
export default class DatabaseStatisticsResolver {
|
||||
unique_classic_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
unique_classic_skins_count(): Promise<Int> {
|
||||
return Skins.getClassicSkinCount();
|
||||
}
|
||||
tweeted_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
tweeted_skins_count(): Promise<Int> {
|
||||
return Skins.getTweetedSkinCount();
|
||||
}
|
||||
approved_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
approved_skins_count(): Promise<Int> {
|
||||
return Skins.getApprovedSkinCount();
|
||||
}
|
||||
rejected_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
rejected_skins_count(): Promise<Int> {
|
||||
return Skins.getRejectedSkinCount();
|
||||
}
|
||||
nsfw_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
nsfw_skins_count(): Promise<Int> {
|
||||
return Skins.getNsfwSkinCount();
|
||||
}
|
||||
unreviewed_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
unreviewed_skins_count(): Promise<Int> {
|
||||
return Skins.getUnreviewedSkinCount();
|
||||
}
|
||||
tweetable_skins_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
tweetable_skins_count(): Promise<Int> {
|
||||
return Skins.getTweetableSkinCount();
|
||||
}
|
||||
uploads_pending_processing_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
uploads_pending_processing_count(): Promise<Int> {
|
||||
return Skins.getUploadsAwaitingProcessingCount();
|
||||
}
|
||||
uploads_in_error_state_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
uploads_in_error_state_count(): Promise<Int> {
|
||||
return Skins.getUploadsErroredCount();
|
||||
}
|
||||
web_uploads_count(): Promise<number> {
|
||||
/** @gqlField */
|
||||
web_uploads_count(): Promise<Int> {
|
||||
return Skins.getWebUploadsCount();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,37 @@
|
|||
import IaItemModel from "../../../data/IaItemModel";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
||||
/** @gqlType InternetArchiveItem */
|
||||
export default class InternetArchiveItemResolver {
|
||||
_model: IaItemModel;
|
||||
constructor(model: IaItemModel) {
|
||||
this._model = model;
|
||||
}
|
||||
identifier() {
|
||||
/** @gqlField */
|
||||
identifier(): string {
|
||||
return this._model.getIdentifier();
|
||||
}
|
||||
url() {
|
||||
/** @gqlField */
|
||||
url(): string {
|
||||
return this._model.getUrl();
|
||||
}
|
||||
metadata_url() {
|
||||
/** @gqlField */
|
||||
metadata_url(): string {
|
||||
return this._model.getMetadataUrl();
|
||||
}
|
||||
|
||||
last_metadata_scrape_date_UNSTABLE() {
|
||||
/** @gqlField */
|
||||
last_metadata_scrape_date_UNSTABLE(): string | null {
|
||||
return this._model.getMetadataTimestamp();
|
||||
}
|
||||
|
||||
raw_metadata_json() {
|
||||
/** @gqlField */
|
||||
raw_metadata_json(): string | null {
|
||||
return this._model.getMetadataJSON();
|
||||
}
|
||||
async skin() {
|
||||
/** @gqlField */
|
||||
async skin(): Promise<ISkin | null> {
|
||||
const skin = await this._model.getSkin();
|
||||
if (skin == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,99 @@
|
|||
import SkinModel from "../../../data/SkinModel";
|
||||
import CommonSkinResolver from "./CommonSkinResolver";
|
||||
import CommonSkinResolver, { ISkin } from "./CommonSkinResolver";
|
||||
import { NodeResolver, toId } from "./NodeResolver";
|
||||
import path from "path";
|
||||
import { ID } from "grats";
|
||||
import ReviewResolver from "./ReviewResolver";
|
||||
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
|
||||
import ArchiveFileResolver from "./ArchiveFileResolver";
|
||||
import TweetResolver from "./TweetResolver";
|
||||
|
||||
/** @gqlType ModernSkin */
|
||||
export default class ModernSkinResolver
|
||||
extends CommonSkinResolver
|
||||
implements NodeResolver
|
||||
implements NodeResolver, ISkin
|
||||
{
|
||||
_model: SkinModel;
|
||||
__typename = "ModernSkin";
|
||||
async id() {
|
||||
return toId(this.__typename, this.md5());
|
||||
}
|
||||
async filename(normalize_extension = false) {
|
||||
/** @gqlField */
|
||||
async filename({
|
||||
normalize_extension = false,
|
||||
}: {
|
||||
normalize_extension?: boolean;
|
||||
}): Promise<string> {
|
||||
const filename = await this._model.getFileName();
|
||||
if (normalize_extension) {
|
||||
return path.parse(filename).name + ".wal";
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/* TODO: Get all of these from the parent class/interface */
|
||||
|
||||
/**
|
||||
* @gqlField
|
||||
* @killsParentOnException
|
||||
*/
|
||||
id(): ID {
|
||||
return toId(this.__typename, this.md5());
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
md5(): string {
|
||||
return super.md5();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
download_url(): string {
|
||||
return super.download_url();
|
||||
}
|
||||
/** @gqlField */
|
||||
tweeted(): Promise<boolean> {
|
||||
return super.tweeted();
|
||||
}
|
||||
/** @gqlField */
|
||||
async tweets(): Promise<TweetResolver[]> {
|
||||
return super.tweets();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
async archive_files(): Promise<ArchiveFileResolver[]> {
|
||||
return super.archive_files();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
async internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
|
||||
return super.internet_archive_item();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
async reviews(): Promise<ReviewResolver[] | null> {
|
||||
return super.reviews();
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
museum_url(): string | null {
|
||||
return null;
|
||||
}
|
||||
/** @gqlField */
|
||||
webamp_url(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @gqlField */
|
||||
screenshot_url(): string | null {
|
||||
return null;
|
||||
}
|
||||
/** @gqlField */
|
||||
async readme_text(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
/** @gqlField */
|
||||
async nsfw(): Promise<boolean | null> {
|
||||
return null;
|
||||
}
|
||||
/** @gqlField */
|
||||
average_color(): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,24 @@ import * as Skins from "../../../data/skins";
|
|||
import { processUserUploads } from "../../processUserUploads";
|
||||
|
||||
// We don't use a resolver here, just return the value directly.
|
||||
type UploadUrl = { id: string; url: string; md5: string };
|
||||
/** @gqlType */
|
||||
type UploadUrl = {
|
||||
/** @gqlField */
|
||||
id: string;
|
||||
/** @gqlField */
|
||||
url: string;
|
||||
/** @gqlField */
|
||||
md5: string;
|
||||
};
|
||||
|
||||
/** @gqlInput */
|
||||
type UploadUrlRequest = { filename: string; md5: string };
|
||||
|
||||
/** @gqlType UploadMutations */
|
||||
class UploadMutationResolver {
|
||||
/** @gqlField */
|
||||
async get_upload_urls(
|
||||
{ files }: { files: { filename: string; md5: string }[] },
|
||||
{ files }: { files: UploadUrlRequest[] },
|
||||
{ ctx }
|
||||
): Promise<UploadUrl[]> {
|
||||
const missing: UploadUrl[] = [];
|
||||
|
|
@ -28,7 +41,11 @@ class UploadMutationResolver {
|
|||
return missing;
|
||||
}
|
||||
|
||||
async report_skin_uploaded({ id, md5 }, req) {
|
||||
/** @gqlField */
|
||||
async report_skin_uploaded(
|
||||
{ id, md5 }: { id: string; md5: string },
|
||||
req
|
||||
): Promise<boolean> {
|
||||
// TODO: Validate md5 and id;
|
||||
await Skins.recordUserUploadComplete(md5, id);
|
||||
// Don't await, just kick off the task.
|
||||
|
|
@ -47,11 +64,17 @@ function requireAuthed(handler) {
|
|||
};
|
||||
}
|
||||
|
||||
/** @gqlType Mutation */
|
||||
export default class MutationResolver {
|
||||
async upload() {
|
||||
/** @gqlField */
|
||||
async upload(): Promise<UploadMutationResolver> {
|
||||
return new UploadMutationResolver();
|
||||
}
|
||||
async send_feedback({ message, email, url }, req) {
|
||||
/** @gqlField */
|
||||
async send_feedback(
|
||||
{ message, email, url }: { message?: string; email?: string; url?: string },
|
||||
req
|
||||
): Promise<boolean> {
|
||||
req.notify({
|
||||
type: "GOT_FEEDBACK",
|
||||
url,
|
||||
|
|
@ -61,7 +84,12 @@ export default class MutationResolver {
|
|||
return true;
|
||||
}
|
||||
|
||||
reject_skin = requireAuthed(async ({ md5 }, req) => {
|
||||
/** @gqlField */
|
||||
reject_skin(args: { md5: string }, req): Promise<boolean> {
|
||||
return this._reject_skin(args, req);
|
||||
}
|
||||
|
||||
_reject_skin = requireAuthed(async ({ md5 }, req) => {
|
||||
req.log(`Rejecting skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5Assert(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
|
|
@ -72,7 +100,12 @@ export default class MutationResolver {
|
|||
return true;
|
||||
});
|
||||
|
||||
approve_skin = requireAuthed(async ({ md5 }, req) => {
|
||||
/** @gqlField */
|
||||
approve_skin(args: { md5: string }, req): Promise<boolean> {
|
||||
return this._approve_skin(args, req);
|
||||
}
|
||||
|
||||
_approve_skin = requireAuthed(async ({ md5 }, req) => {
|
||||
req.log(`Approving skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
|
|
@ -83,7 +116,12 @@ export default class MutationResolver {
|
|||
return true;
|
||||
});
|
||||
|
||||
mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => {
|
||||
/** @gqlField */
|
||||
mark_skin_nsfw(args: { md5: string }, req): Promise<boolean> {
|
||||
return this._mark_skin_nsfw(args, req);
|
||||
}
|
||||
|
||||
_mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => {
|
||||
req.log(`Approving skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
|
|
@ -94,7 +132,11 @@ export default class MutationResolver {
|
|||
return true;
|
||||
});
|
||||
|
||||
async request_nsfw_review_for_skin({ md5 }, req) {
|
||||
/** @gqlField */
|
||||
async request_nsfw_review_for_skin(
|
||||
{ md5 }: { md5: string },
|
||||
req
|
||||
): Promise<boolean> {
|
||||
req.log(`Reporting skin with hash "${md5}"`);
|
||||
// Blow up if there is no skin with this hash
|
||||
await SkinModel.fromMd5Assert(req.ctx, md5);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { ID } from "grats";
|
||||
|
||||
/**
|
||||
* A globally unique object. The `id` here is intended only for use within
|
||||
* GraphQL.
|
||||
* https://graphql.org/learn/global-object-identification/
|
||||
*
|
||||
* @gqlInterface Node
|
||||
*/
|
||||
export interface NodeResolver {
|
||||
id({ id }): Promise<string>;
|
||||
/**
|
||||
* @gqlField
|
||||
* @killsParentOnException
|
||||
*/
|
||||
id(): ID;
|
||||
__typename: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,38 @@
|
|||
import { ReviewRow } from "../../../types";
|
||||
import { Rating, ReviewRow } from "../../../types";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
||||
/**
|
||||
* A review of a skin. Done either on the Museum's Tinder-style
|
||||
* reivew page, or via the Discord bot.
|
||||
* @gqlType Review */
|
||||
export default class ReviewResolver {
|
||||
_model: ReviewRow;
|
||||
constructor(model: ReviewRow) {
|
||||
this._model = model;
|
||||
}
|
||||
reviewer() {
|
||||
|
||||
/**
|
||||
* The skin that was reviewed
|
||||
* @gqlField
|
||||
*/
|
||||
skin(args: never, { ctx }): Promise<ISkin | null> {
|
||||
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who made the review (if known). **Note:** In the early days we
|
||||
* didn't track this, so many will be null.
|
||||
* @gqlField
|
||||
*/
|
||||
reviewer(): string {
|
||||
return this._model.reviewer;
|
||||
}
|
||||
rating() {
|
||||
/**
|
||||
* The rating that the user gave the skin
|
||||
* @gqlField
|
||||
*/
|
||||
rating(): Rating {
|
||||
return this._model.review;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import SkinResolver from "../resolvers/SkinResolver";
|
|||
import TweetResolver from "../resolvers/TweetResolver";
|
||||
import UserResolver from "../resolvers/UserResolver";
|
||||
import SkinsConnection from "../SkinsConnection";
|
||||
import TweetsConnection from "../TweetsConnection";
|
||||
import TweetsConnection, { TweetsSortOption } from "../TweetsConnection";
|
||||
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
|
||||
import * as Skins from "../../../data/skins";
|
||||
import { ID, Int } from "grats";
|
||||
|
||||
import algoliasearch from "algoliasearch";
|
||||
import MutationResolver from "./MutationResolver";
|
||||
|
|
@ -15,16 +16,23 @@ import { knex } from "../../../db";
|
|||
import ArchiveFileModel from "../../../data/ArchiveFileModel";
|
||||
import ArchiveFileResolver from "./ArchiveFileResolver";
|
||||
import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver";
|
||||
import { fromId } from "./NodeResolver";
|
||||
import { fromId, NodeResolver } from "./NodeResolver";
|
||||
import ModernSkinsConnection from "../ModernSkinsConnection";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
|
||||
// These keys are already in the web client, so they are not secret at all.
|
||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||
const index = client.initIndex("Skins");
|
||||
|
||||
/** @gqlType Query */
|
||||
class RootResolver extends MutationResolver {
|
||||
async node({ id }, { ctx }) {
|
||||
/**
|
||||
* Get a globally unique object by its ID.
|
||||
* https://graphql.org/learn/global-object-identification/
|
||||
* @gqlField
|
||||
*/
|
||||
async node({ id }: { id: ID }, { ctx }): Promise<NodeResolver | null> {
|
||||
const { graphqlType, id: localId } = fromId(id);
|
||||
// TODO Use typeResolver
|
||||
switch (graphqlType) {
|
||||
|
|
@ -39,7 +47,15 @@ class RootResolver extends MutationResolver {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
async fetch_skin_by_md5({ md5 }, { ctx }) {
|
||||
|
||||
/**
|
||||
* Get a skin by its MD5 hash
|
||||
* @gqlField
|
||||
*/
|
||||
async fetch_skin_by_md5(
|
||||
{ md5 }: { md5: string },
|
||||
{ ctx }
|
||||
): Promise<ISkin | null> {
|
||||
const skin = await SkinModel.fromMd5(ctx, md5);
|
||||
if (skin == null) {
|
||||
return null;
|
||||
|
|
@ -50,21 +66,47 @@ class RootResolver extends MutationResolver {
|
|||
return SkinResolver.fromModel(skin);
|
||||
}
|
||||
}
|
||||
async fetch_tweet_by_url({ url }, { ctx }) {
|
||||
|
||||
/**
|
||||
* Get a tweet by its URL
|
||||
* @gqlField
|
||||
*/
|
||||
async fetch_tweet_by_url(
|
||||
{ url }: { url: string },
|
||||
{ ctx }
|
||||
): Promise<TweetResolver | null> {
|
||||
const tweet = await TweetModel.fromAnything(ctx, url);
|
||||
if (tweet == null) {
|
||||
return null;
|
||||
}
|
||||
return new TweetResolver(tweet);
|
||||
}
|
||||
async fetch_internet_archive_item_by_identifier({ identifier }, { ctx }) {
|
||||
|
||||
/**
|
||||
* Get an archive.org item by its identifier. You can find this in the URL:
|
||||
* https://archive.org/details/<identifier>/
|
||||
* @gqlField
|
||||
*/
|
||||
async fetch_internet_archive_item_by_identifier(
|
||||
{ identifier }: { identifier: string },
|
||||
{ ctx }
|
||||
): Promise<InternetArchiveItemResolver | null> {
|
||||
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
|
||||
if (iaItem == null) {
|
||||
return null;
|
||||
}
|
||||
return new InternetArchiveItemResolver(iaItem);
|
||||
}
|
||||
async fetch_archive_file_by_md5({ md5 }, { ctx }) {
|
||||
|
||||
/**
|
||||
* Fetch archive file by it's MD5 hash
|
||||
* Get information about a file found within a skin's wsz/wal/zip archive.
|
||||
* @gqlField
|
||||
*/
|
||||
async fetch_archive_file_by_md5(
|
||||
{ md5 }: { md5: string },
|
||||
{ ctx }
|
||||
): Promise<ArchiveFileResolver | null> {
|
||||
const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5);
|
||||
if (archiveFile == null) {
|
||||
return null;
|
||||
|
|
@ -72,7 +114,20 @@ class RootResolver extends MutationResolver {
|
|||
return new ArchiveFileResolver(archiveFile);
|
||||
}
|
||||
|
||||
async search_skins({ query, first, offset }, { ctx }) {
|
||||
/**
|
||||
* Search the database using the Algolia search index used by the Museum.
|
||||
*
|
||||
* Useful for locating a particular skin.
|
||||
* @gqlField
|
||||
*/
|
||||
async search_skins(
|
||||
{
|
||||
query,
|
||||
first = 10,
|
||||
offset = 0,
|
||||
}: { query: string; first?: Int; offset?: Int },
|
||||
{ ctx }
|
||||
): Promise<ISkin[]> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Can only query 1000 records via search.");
|
||||
}
|
||||
|
|
@ -91,21 +146,40 @@ class RootResolver extends MutationResolver {
|
|||
);
|
||||
}
|
||||
|
||||
async skins({ first, offset, sort, filter }) {
|
||||
/** @gqlField */
|
||||
skins({
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
filter,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: SkinsSortOption;
|
||||
filter?: SkinsFilterOption;
|
||||
}): SkinsConnection {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
return new SkinsConnection(first, offset, sort, filter);
|
||||
}
|
||||
|
||||
async modern_skins({ first, offset }) {
|
||||
/** @gqlField */
|
||||
async modern_skins({
|
||||
first = 10,
|
||||
offset = 0,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
}): Promise<ModernSkinsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
return new ModernSkinsConnection(first, offset);
|
||||
}
|
||||
|
||||
async skin_to_review(_args, { ctx }) {
|
||||
/** @gqlField */
|
||||
async skin_to_review(_args: never, { ctx }): Promise<ISkin | null> {
|
||||
if (!ctx.authed()) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -113,20 +187,50 @@ class RootResolver extends MutationResolver {
|
|||
const model = await SkinModel.fromMd5Assert(ctx, md5);
|
||||
return SkinResolver.fromModel(model);
|
||||
}
|
||||
async tweets({ first, offset, sort }) {
|
||||
|
||||
/**
|
||||
* Tweets tweeted by @winampskins
|
||||
* @gqlField
|
||||
*/
|
||||
async tweets({
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: TweetsSortOption;
|
||||
}): Promise<TweetsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
return new TweetsConnection(first, offset, sort);
|
||||
}
|
||||
me() {
|
||||
|
||||
/**
|
||||
* The currently authenticated user, if any.
|
||||
* @gqlField
|
||||
*/
|
||||
me(): UserResolver | null {
|
||||
return new UserResolver();
|
||||
}
|
||||
async upload_statuses_by_md5({ md5s }, { ctx }) {
|
||||
|
||||
/**
|
||||
* Get the status of a batch of uploads by md5s
|
||||
* @gqlField
|
||||
*/
|
||||
async upload_statuses_by_md5(
|
||||
{ md5s }: { md5s: string[] },
|
||||
{ ctx }
|
||||
): Promise<SkinUpload[]> {
|
||||
return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
|
||||
}
|
||||
|
||||
async upload_statuses({ ids }, { ctx }) {
|
||||
/** @gqlField */
|
||||
async upload_statuses(
|
||||
{ ids }: { ids: string[] },
|
||||
{ ctx }
|
||||
): Promise<SkinUpload[]> {
|
||||
return this._upload_statuses({ keyName: "id", keys: ids }, ctx);
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +256,83 @@ class RootResolver extends MutationResolver {
|
|||
);
|
||||
}
|
||||
|
||||
statistics() {
|
||||
/** @gqlField */
|
||||
statistics(): DatabaseStatisticsResolver {
|
||||
return new DatabaseStatisticsResolver();
|
||||
}
|
||||
}
|
||||
|
||||
/** @gqlType */
|
||||
type SkinUpload = {
|
||||
/** @gqlField */
|
||||
id: string;
|
||||
/** @gqlField */
|
||||
status: SkinUploadStatus;
|
||||
/** @gqlField */
|
||||
skin: ISkin | null;
|
||||
/** @gqlField */
|
||||
upload_md5: string;
|
||||
};
|
||||
|
||||
/** @gqlEnum */
|
||||
type SkinsSortOption =
|
||||
/*
|
||||
the Museum's (https://skins.webamp.org) special sorting rules.
|
||||
|
||||
Roughly speaking, it's:
|
||||
|
||||
1. The four classic default skins
|
||||
2. Tweeted skins first (sorted by the number of likes/retweets)
|
||||
3. Approved, but not tweeted yet, skins
|
||||
4. Unreviwed skins
|
||||
5. Rejected skins
|
||||
6. NSFW skins
|
||||
*/
|
||||
"MUSEUM";
|
||||
|
||||
/** @gqlEnum */
|
||||
type SkinsFilterOption =
|
||||
/*
|
||||
Only the skins that have been approved for tweeting
|
||||
*/
|
||||
| "APPROVED"
|
||||
|
||||
/*
|
||||
Only the skins that have been rejected for tweeting
|
||||
*/
|
||||
| "REJECTED"
|
||||
|
||||
/*
|
||||
Only the skins that have been marked NSFW
|
||||
*/
|
||||
| "NSFW"
|
||||
|
||||
/*
|
||||
Only the skins that have been tweeted
|
||||
*/
|
||||
| "TWEETED";
|
||||
|
||||
/**
|
||||
* The current status of a pending upload.
|
||||
* **Note:** Expect more values here as we try to be more transparent about
|
||||
* the status of a pending uploads.
|
||||
* @gqlEnum
|
||||
*/
|
||||
type SkinUploadStatus =
|
||||
/** The user has requested a URL, but the skin has not yet been processed. */
|
||||
| "URL_REQUESTED"
|
||||
/** The user has notified us that the skin has been uploaded, but we haven't yet
|
||||
processed it. */
|
||||
| "UPLOAD_REPORTED"
|
||||
/** An error occured processing the skin. Usually this is a transient error, and
|
||||
the skin will be retried at a later time. */
|
||||
| "ERRORED"
|
||||
/** An error occured processing the skin, but it was the fault of the server. It
|
||||
will be processed at a later date. */
|
||||
| "DELAYED"
|
||||
/** The skin has been successfully added to the Museum.
|
||||
* @deprecated
|
||||
*/
|
||||
| "ARCHIVED";
|
||||
|
||||
export default RootResolver;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import SkinModel from "../../../data/SkinModel";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import ClassicSkinResolver from "./ClassicSkinResolver";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
|
||||
export default class SkinResolver {
|
||||
constructor() {
|
||||
throw new Error("This is a stub.");
|
||||
}
|
||||
static fromModel(model: SkinModel) {
|
||||
static async fromMd5(ctx: UserContext, md5: string): Promise<ISkin | null> {
|
||||
const skin = await SkinModel.fromMd5(ctx, md5);
|
||||
if (skin == null) {
|
||||
return null;
|
||||
}
|
||||
return this.fromModel(skin);
|
||||
}
|
||||
static fromModel(model: SkinModel): ISkin {
|
||||
if (model.getSkinType() === "MODERN") {
|
||||
return new ModernSkinResolver(model);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
import { Int } from "grats";
|
||||
import TweetModel from "../../../data/TweetModel";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
||||
/** @gqlType Tweet */
|
||||
export default class TweetResolver {
|
||||
_model: TweetModel;
|
||||
constructor(model: TweetModel) {
|
||||
this._model = model;
|
||||
}
|
||||
|
||||
url() {
|
||||
/** @gqlField */
|
||||
url(): string | null {
|
||||
return this._model.getUrl();
|
||||
}
|
||||
likes() {
|
||||
/** @gqlField */
|
||||
likes(): Int {
|
||||
return this._model.getLikes();
|
||||
}
|
||||
retweets() {
|
||||
/** @gqlField */
|
||||
retweets(): Int {
|
||||
return this._model.getRetweets();
|
||||
}
|
||||
async skin() {
|
||||
/** @gqlField */
|
||||
async skin(): Promise<ISkin | null> {
|
||||
const skin = await this._model.getSkin();
|
||||
if (skin == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/** @gqlType User */
|
||||
export default class UserResolver {
|
||||
username(_args, { ctx }) {
|
||||
/** @gqlField */
|
||||
username(_args: never, { ctx }): string {
|
||||
return ctx.username;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -599,28 +599,28 @@ type Mutation {
|
|||
|
||||
**Note:** Requires being logged in
|
||||
"""
|
||||
reject_skin(md5: String): Boolean
|
||||
reject_skin(md5: String!): Boolean
|
||||
|
||||
"""
|
||||
Approve skin for tweeting
|
||||
|
||||
**Note:** Requires being logged in
|
||||
"""
|
||||
approve_skin(md5: String): Boolean
|
||||
approve_skin(md5: String!): Boolean
|
||||
|
||||
"""
|
||||
Mark a skin as NSFW
|
||||
|
||||
**Note:** Requires being logged in
|
||||
"""
|
||||
mark_skin_nsfw(md5: String): Boolean
|
||||
mark_skin_nsfw(md5: String!): Boolean
|
||||
|
||||
"""
|
||||
Request that an admin check if this skin is NSFW.
|
||||
Unlike other review mutaiton endpoints, this one does not require being logged
|
||||
in.
|
||||
"""
|
||||
request_nsfw_review_for_skin(md5: String): Boolean
|
||||
request_nsfw_review_for_skin(md5: String!): Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
@ -695,7 +695,7 @@ type Query {
|
|||
|
||||
https://graphql.org/learn/global-object-identification/
|
||||
"""
|
||||
node(id: ID): Node
|
||||
node(id: ID!): Node
|
||||
|
||||
"""
|
||||
The currently authenticated user, if any.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"express-graphql": "^0.12.0",
|
||||
"express-sitemap-xml": "^2.0.0",
|
||||
"graphql": "14.7.0",
|
||||
"grats": "^0.0.0-main-44ac2e23",
|
||||
"imagemin": "^7.0.0",
|
||||
"imagemin-optipng": "^7.0.0",
|
||||
"knex": "^0.21.1",
|
||||
|
|
@ -54,7 +55,9 @@
|
|||
"bot": "ts-node --transpile-only ./discord-bot/index.js",
|
||||
"cli": "ts-node --transpile-only ./cli.ts",
|
||||
"sync": "ts-node --transpile-only ./tasks/syncWithArchive.ts",
|
||||
"migrate": "knex migrate:latest"
|
||||
"migrate": "knex migrate:latest",
|
||||
"grats": "grats --output=./api/graphql/grats-schema.graphql",
|
||||
"grats-progress": "yarn grats && graphql-schema-diff --fail-on-all-changes --sort-schema api/graphql/schema.graphql api/graphql/grats-schema.graphql"
|
||||
},
|
||||
"prettier": {},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"grats": {
|
||||
"nullableByDefault": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ export type TweetStatus =
|
|||
| "UNREVIEWED"
|
||||
| "NSFW";
|
||||
|
||||
/** @gqlEnum */
|
||||
export type Rating = "APPROVED" | "REJECTED" | "NSFW";
|
||||
|
||||
export type SkinType = "MODERN" | "CLASSIC";
|
||||
|
||||
export type SkinRow = {
|
||||
|
|
@ -32,7 +35,7 @@ export type InstagramPostRow = {
|
|||
|
||||
export type ReviewRow = {
|
||||
skin_md5: string;
|
||||
review: "APPROVED" | "REJECTED" | "NSFW";
|
||||
review: Rating;
|
||||
reviewer: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
33
yarn.lock
33
yarn.lock
|
|
@ -2912,6 +2912,14 @@
|
|||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@graphql-tools/utils@^9.2.1":
|
||||
version "9.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57"
|
||||
integrity sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==
|
||||
dependencies:
|
||||
"@graphql-typed-document-node/core" "^3.1.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@graphql-tools/wrap@9.2.1":
|
||||
version "9.2.1"
|
||||
resolved "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-9.2.1.tgz"
|
||||
|
|
@ -2923,6 +2931,11 @@
|
|||
tslib "^2.4.0"
|
||||
value-or-promise "1.0.11"
|
||||
|
||||
"@graphql-typed-document-node/core@^3.1.1":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
|
||||
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
|
||||
|
|
@ -10084,6 +10097,11 @@ comma-separated-tokens@^1.0.0:
|
|||
resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz"
|
||||
integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==
|
||||
|
||||
commander@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.0.tgz#71797971162cd3cf65f0b9d24eb28f8d303acdf1"
|
||||
integrity sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==
|
||||
|
||||
commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.3.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
|
||||
|
|
@ -14760,6 +14778,21 @@ graphql@14.7.0:
|
|||
dependencies:
|
||||
iterall "^1.2.2"
|
||||
|
||||
graphql@^16.6.0:
|
||||
version "16.6.0"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb"
|
||||
integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==
|
||||
|
||||
grats@^0.0.0-main-44ac2e23:
|
||||
version "0.0.0-main-44ac2e23"
|
||||
resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-44ac2e23.tgz#b788e44c3d3eaac28588741a7c34ff4c69832183"
|
||||
integrity sha512-IFVtiWuOtcjw24+TsvuZvYZci4e8jPvs7WiPrEQtsvFkZ4HEJqn+PUsMPq+R5D7Ox4NQSSTZP/r7iFUwsi19TQ==
|
||||
dependencies:
|
||||
"@graphql-tools/utils" "^9.2.1"
|
||||
commander "^10.0.0"
|
||||
graphql "^16.6.0"
|
||||
typescript "^4.9.5"
|
||||
|
||||
gulp-eslint@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/gulp-eslint/-/gulp-eslint-3.0.1.tgz"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue