From 9fbe72d95c24bd5479d1584f2117f91be82a3898 Mon Sep 17 00:00:00 2001
From: Kyle Kemp <kyle@seiyria.com>
Date: Wed, 26 Jun 2024 13:58:10 -0500
Subject: [PATCH] closes #98

---
 interfaces/carddata.ts                        |  3 +
 search/operators/errata.ts                    | 30 ++++++++++
 search/operators/faq.ts                       | 30 ++++++++++
 search/operators/index.ts                     |  2 +
 search/search.ts                              | 58 +++++++++++++++----
 .../search-cards/search-cards.component.html  |  2 +-
 src/app/_shared/helpers/navigation.ts         |  1 -
 src/app/advanced/advanced.page.ts             | 16 +++++
 src/app/cards.service.ts                      | 16 ++++-
 src/app/syntax/syntax.page.ts                 |  4 ++
 10 files changed, 149 insertions(+), 13 deletions(-)
 create mode 100644 search/operators/errata.ts
 create mode 100644 search/operators/faq.ts

diff --git a/interfaces/carddata.ts b/interfaces/carddata.ts
index fa29742..4e5feed 100644
--- a/interfaces/carddata.ts
+++ b/interfaces/carddata.ts
@@ -13,4 +13,7 @@ export interface ICard {
 
   imageClass?: string;
   meta: Record<string, number>;
+
+  faq?: number;
+  errata?: number;
 }
diff --git a/search/operators/errata.ts b/search/operators/errata.ts
new file mode 100644
index 0000000..f0b9d25
--- /dev/null
+++ b/search/operators/errata.ts
@@ -0,0 +1,30 @@
+import { type ICardHelp } from '../../interfaces';
+import { numericalOperator } from './_helpers';
+
+export const errata = numericalOperator(['errata', 'e'], 'errata');
+
+export const errataDescription: ICardHelp = {
+  name: 'Errata',
+  id: 'errata',
+
+  icon: 'flask-outline',
+
+  color: '#aa3c06',
+
+  help: `
+You can find cards that have errata entries with the \`errata:\` or \`e:\` operator.
+
+This operator is a numeric operator, meaning you'll by default want to search for \`errata:>0\` to find entries with any errata entries.
+`,
+
+  examples: [
+    {
+      example: '`errata:>0`',
+      explanation: 'Cards that have any number of errata entries.',
+    },
+    {
+      example: '`errata:=0`',
+      explanation: 'Cards with no errata entries.',
+    },
+  ],
+};
diff --git a/search/operators/faq.ts b/search/operators/faq.ts
new file mode 100644
index 0000000..771627f
--- /dev/null
+++ b/search/operators/faq.ts
@@ -0,0 +1,30 @@
+import { type ICardHelp } from '../../interfaces';
+import { numericalOperator } from './_helpers';
+
+export const faq = numericalOperator(['faq', 'f'], 'faq');
+
+export const faqDescription: ICardHelp = {
+  name: 'FAQ',
+  id: 'faq',
+
+  icon: 'chatbubbles-outline',
+
+  color: '#aa063c',
+
+  help: `
+You can find cards that have FAQ entries with the \`faq:\` or \`f:\` operator.
+
+This operator is a numeric operator, meaning you'll by default want to search for \`faq:>0\` to find entries with any FAQs.
+`,
+
+  examples: [
+    {
+      example: '`faq:>0`',
+      explanation: 'Cards that have any number of FAQ entries.',
+    },
+    {
+      example: '`faq:=0`',
+      explanation: 'Cards with no FAQ entries.',
+    },
+  ],
+};
diff --git a/search/operators/index.ts b/search/operators/index.ts
index 06712ba..b9218a4 100644
--- a/search/operators/index.ts
+++ b/search/operators/index.ts
@@ -1,5 +1,7 @@
 export * from './bare';
 export * from './card';
+export * from './errata';
+export * from './faq';
 export * from './name';
 export * from './product';
 export * from './subproduct';
diff --git a/search/search.ts b/search/search.ts
index cb9c210..dacd711 100644
--- a/search/search.ts
+++ b/search/search.ts
@@ -4,13 +4,25 @@ import * as parser from 'search-query-parser';
 
 import { type ICard } from '../interfaces';
 
-import { bare, card, name, product, subproduct, tag, text } from './operators';
+import {
+  bare,
+  card,
+  errata,
+  faq,
+  name,
+  product,
+  subproduct,
+  tag,
+  text,
+} from './operators';
 
 const allKeywords = [
   ['id'], // exact text
   ['name', 'n'], // loose text
   ['cardtext', 't'], // loose text
   ['game', 'g'], // exact text
+  ['faq', 'f'], // numerical
+  ['errata', 'e'], // numerical
   ['product', 'expansion', 'p', 'e'], // exact text
   ['tag'], // array search
 ];
@@ -24,6 +36,8 @@ const operators: ParserOperator[] = [
   card,
   name,
   text,
+  faq,
+  errata,
   product,
   subproduct,
   tag,
@@ -80,6 +94,24 @@ const allQueryFormatters = [
       return `"${value}"`;
     },
   },
+  {
+    key: 'errata',
+    includes: 'is',
+    excludes: 'is not',
+    formatter: (result: Record<string, any>) => {
+      const value = result['errata'] ?? 0;
+      return `${value}`;
+    },
+  },
+  {
+    key: 'faq',
+    includes: 'is',
+    excludes: 'is not',
+    formatter: (result: Record<string, any>) => {
+      const value = result['faq'] ?? 0;
+      return `${value}`;
+    },
+  },
   /*
   {
     key: 'game',
@@ -138,35 +170,41 @@ export function queryToText(query: string, isPlural = true): string {
 
   const result = properOperatorsInsteadOfAliases(firstResult);
 
-  const text = [];
+  const textArrayEntries = [];
 
   const gameResult = result['game'];
   if (gameResult) {
-    text.push(`in ${gameResult}`);
+    textArrayEntries.push(`in ${gameResult}`);
   }
 
   allQueryFormatters.forEach((queryFormatter) => {
     const { key, includes, excludes, formatter } = queryFormatter;
 
     if (result[key]) {
-      text.push(`${key} ${includes} ${formatter(result)}`);
+      textArrayEntries.push(`${key} ${includes} ${formatter(result)}`);
     }
     if (result.exclude?.[key]) {
-      text.push(`${key} ${excludes} ${formatter(result.exclude)}`);
+      textArrayEntries.push(`${key} ${excludes} ${formatter(result.exclude)}`);
     }
   });
 
   if (result['in']) {
-    text.push(`in ${result['in']}`);
+    textArrayEntries.push(`in ${result['in']}`);
   }
 
   if (result['text']) {
-    text.push(`"${result['text']}" is in name or card id`);
+    textArrayEntries.push(`"${result['text']}" is in name or card id`);
+  }
+
+  if (query.includes('game:')) {
+    return `${cardText} ${textArrayEntries[0]} ${
+      textArrayEntries.length > 1 ? 'where' : ''
+    } ${textArrayEntries.slice(1).join(' and ')}`;
   }
 
-  return `${cardText} ${text[0]} ${text.length > 1 ? 'where' : ''} ${text
-    .slice(1)
-    .join(' and ')}`;
+  return `${cardText} ${
+    textArrayEntries.length > 0 ? 'where' : ''
+  } ${textArrayEntries.join(' and ')}`;
 }
 
 export function getGameFromQuery(query: string): string | undefined {
diff --git a/src/app/_shared/components/search-cards/search-cards.component.html b/src/app/_shared/components/search-cards/search-cards.component.html
index 2100888..2e046ab 100644
--- a/src/app/_shared/components/search-cards/search-cards.component.html
+++ b/src/app/_shared/components/search-cards/search-cards.component.html
@@ -20,7 +20,7 @@ <h2>{{ 'Pages.SearchResults.NoResults.Header' | translate }}</h2>
     <ion-row>
       <ion-col class="search-descriptor">
         <span>{{ searchService.displayCurrent() | number }} - {{ searchService.displayMaximum() | number }}
-          of&nbsp;</span> {{ searchService.displayTotal() | number }} {{ searchService.queryDesc() }}
+          of</span> {{ searchService.displayTotal() | number }} {{ searchService.queryDesc() }}
       </ion-col>
     </ion-row>
   </ion-grid>
diff --git a/src/app/_shared/helpers/navigation.ts b/src/app/_shared/helpers/navigation.ts
index 510fbd2..9999b8b 100644
--- a/src/app/_shared/helpers/navigation.ts
+++ b/src/app/_shared/helpers/navigation.ts
@@ -1,5 +1,4 @@
 export function tryNavigateToHash() {
-  console.log(document.location.hash);
   if (!document.location.hash) return;
 
   setTimeout(() => {
diff --git a/src/app/advanced/advanced.page.ts b/src/app/advanced/advanced.page.ts
index c6aa04c..1cde0d1 100644
--- a/src/app/advanced/advanced.page.ts
+++ b/src/app/advanced/advanced.page.ts
@@ -118,6 +118,15 @@ export class AdvancedPage implements OnInit {
       if (this.searchQuery.meta[filter.prop]) return;
 
       if (filter.type === 'number') {
+        if (['FAQ', 'Errata'].includes(filter.name)) {
+          this.searchQuery.meta[filter.prop] = {
+            operator: '>',
+            value: undefined,
+          };
+
+          return;
+        }
+
         this.searchQuery.meta[filter.prop] = {
           operator: '=',
           value: undefined,
@@ -165,6 +174,13 @@ export class AdvancedPage implements OnInit {
       this.visibleFilters = this.metaService.getFiltersByProductId(product);
       this.visibleTags = this.tagsByProduct[product];
     }
+
+    this.visibleFilters.unshift(
+      ...([
+        { name: 'FAQ', prop: 'faq', type: 'number' },
+        { name: 'Errata', prop: 'errata', type: 'number' },
+      ] as IProductFilter[])
+    );
   }
 
   getSearchQuery() {
diff --git a/src/app/cards.service.ts b/src/app/cards.service.ts
index fe5bb7a..6b55ab9 100644
--- a/src/app/cards.service.ts
+++ b/src/app/cards.service.ts
@@ -9,6 +9,8 @@ import { type ICard, type IProductFilter } from '../../interfaces';
 import { numericalOperator } from '../../search/operators/_helpers';
 import { parseQuery, type ParserOperator } from '../../search/search';
 import { environment } from '../environments/environment';
+import { ErrataService } from './errata.service';
+import { FAQService } from './faq.service';
 import { LocaleService } from './locale.service';
 import { MetaService } from './meta.service';
 
@@ -23,6 +25,8 @@ export class CardsService {
   private http = inject(HttpClient);
   private localeService = inject(LocaleService);
   private metaService = inject(MetaService);
+  private faqService = inject(FAQService);
+  private errataService = inject(ErrataService);
 
   public get allCards(): ICard[] {
     return this.cards;
@@ -59,6 +63,15 @@ export class CardsService {
   }
 
   // card utilities
+  private reformatCardsWithErrataAndFAQ(): ICard[] {
+    return this.cards.map((card) => ({
+      ...card,
+      faq: this.faqService.getCardFAQ(card.game, card.name).length ?? 0,
+      errata:
+        this.errataService.getCardErrata(card.game, card.name).length ?? 0,
+    }));
+  }
+
   public getCardByIdOrName(codeOrName: string): ICard | undefined {
     return (
       this.cardsById[codeOrName] ?? this.cardsByName[codeOrName] ?? undefined
@@ -66,7 +79,8 @@ export class CardsService {
   }
 
   public searchCards(query: string): ICard[] {
-    return parseQuery(this.cards, query, this.getExtraFilterOperators());
+    const formattedCards = this.reformatCardsWithErrataAndFAQ();
+    return parseQuery(formattedCards, query, this.getExtraFilterOperators());
   }
 
   public getExtraFilterOperators() {
diff --git a/src/app/syntax/syntax.page.ts b/src/app/syntax/syntax.page.ts
index 39a275b..091325e 100644
--- a/src/app/syntax/syntax.page.ts
+++ b/src/app/syntax/syntax.page.ts
@@ -8,6 +8,8 @@ import { type ICardHelp } from '../../../interfaces';
 import { TranslateService } from '@ngx-translate/core';
 import {
   cardDescription,
+  errataDescription,
+  faqDescription,
   nameDescription,
   productDescription,
   subproductDescription,
@@ -28,6 +30,8 @@ export class SyntaxPage implements OnInit {
   public allOperators: ICardHelp[] = [
     cardDescription,
     textDescription,
+    errataDescription,
+    faqDescription,
     nameDescription,
     productDescription,
     subproductDescription,