Added files

This commit is contained in:
DrMint 2023-01-28 10:21:11 +01:00
commit 231ffa472b
41 changed files with 13592 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
node_modules

1
README.md Normal file
View File

@ -0,0 +1 @@
# Import data from Strapi to Meilisearch engine

8
docker-compose.yml Normal file
View File

@ -0,0 +1,8 @@
version: "3.3"
services:
meilisearch:
image: "getmeili/meilisearch:latest"
ports:
- "61876:7700"
volumes:
- "./meili_data:/meili_data"

20
graphql-codegen.config.js Normal file
View File

@ -0,0 +1,20 @@
require("dotenv").config();
module.exports = {
overwrite: true,
schema: {
[process.env.URL_GRAPHQL]: {
headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` },
},
},
documents: [
"src/shared/fragments/*.graphql",
"src/core/graphql/operations/*.graphql",
"src/core/graphql/fragments/*.graphql",
],
generates: {
"src/core/graphql/generated.ts": {
plugins: ["typescript", "typescript-operations", "typescript-graphql-request"],
},
},
};

5804
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "search.accords-library.com",
"private": true,
"scripts": {
"generate": "graphql-codegen --config graphql-codegen.config.js"
},
"dependencies": {
"@digitak/esrun": "^3.2.15",
"dotenv": "^16.0.3",
"meilisearch": "^0.30.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.16.1",
"@graphql-codegen/typescript": "2.8.5",
"@graphql-codegen/typescript-graphql-request": "^4.5.8",
"@graphql-codegen/typescript-operations": "^2.5.10",
"@types/node": "^18.11.17",
"graphql": "^16.6.0",
"graphql-request": "^5.1.0"
}
}

21
prettier.config.js Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
quoteProps: "as-needed",
jsxSingleQuote: false,
trailingComma: "es5",
bracketSpacing: true,
bracketSameLine: true,
arrowParens: "always",
rangeStart: 0,
rangeEnd: Infinity,
requirePragma: false,
insertPragma: false,
proseWrap: "preserve",
htmlWhitespaceSensitivity: "ignore",
endOfLine: "lf",
singleAttributePerLine: false,
};

View File

@ -0,0 +1 @@
npx esrun --tsconfig=./tsconfig.json src/app.ts

11
src/app.ts Normal file
View File

@ -0,0 +1,11 @@
import http from "http";
import { requestListener } from "./server";
import { synchronizeStrapiAndMeili } from "./syncho";
import * as dotenv from "dotenv";
dotenv.config();
await synchronizeStrapiAndMeili();
http.createServer(requestListener).listen(process.env.PORT, () => {
console.log(`Server started on port: ${process.env.PORT}`);
});

View File

@ -0,0 +1,46 @@
fragment contentAttributes on Content {
slug
updatedAt
translations(pagination: { limit: -1 }) {
pre_title
title
subtitle
description
language {
data {
attributes {
code
}
}
}
text_set {
text
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}

View File

@ -0,0 +1,110 @@
fragment libraryItemAttributes on LibraryItem {
title
subtitle
slug
root_item
primary
descriptions(pagination: { limit: -1 }) {
description
language {
data {
attributes {
code
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
release_date {
...datePicker
}
price {
...pricePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
metadata {
__typename
... on ComponentMetadataBooks {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
}
... on ComponentMetadataVideo {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
}
... on ComponentMetadataAudio {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
}
... on ComponentMetadataGroup {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
subitems_type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
title
}
}
}
}
}
}
}

View File

@ -0,0 +1,41 @@
fragment postAttributes on Post {
slug
hidden
date {
...datePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
translations(pagination: { limit: -1 }) {
language {
data {
attributes {
code
}
}
}
title
excerpt
body
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}

View File

@ -0,0 +1,21 @@
fragment videoAttributes on Video {
uid
title
description
published_date {
year
month
day
}
views
channel {
data {
attributes {
title
uid
}
}
}
gone
duration
}

View File

@ -0,0 +1,66 @@
fragment wikiPageAttributes on WikiPage {
slug
thumbnail {
data {
attributes {
...uploadImage
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
definitions(pagination: { limit: -1 }) {
translations {
language {
data {
attributes {
code
}
}
}
definition
}
}
tags(pagination: { limit: -1 }) {
data {
id
attributes {
slug
titles(filters: { language: { code: { eq: "en" } } }) {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
translations(pagination: { limit: -1 }) {
title
aliases {
alias
}
body {
body
}
summary
language {
data {
attributes {
code
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
import {
ContentAttributesFragment,
GetContentQuery,
GetLibraryItemQuery,
GetPostQuery,
GetVideoQuery,
GetWikiPageQuery,
LibraryItemAttributesFragment,
PostAttributesFragment,
VideoAttributesFragment,
WikiPageAttributesFragment,
} from "./generated";
export interface MeiliLibraryItem extends LibraryItemAttributesFragment {
id: string;
sortable_name: string;
sortable_price: number | undefined;
sortable_date: number | undefined;
untangible_group_item: boolean;
}
export interface MeiliContent
extends Omit<ContentAttributesFragment, "translations" | "updatedAt"> {
id: string;
translations: (Omit<
NonNullable<NonNullable<ContentAttributesFragment["translations"]>[number]>,
"text_set" | "description"
> & {
displayable_description?: string | null;
})[];
sortable_updated_date: number;
}
export interface MeiliVideo extends VideoAttributesFragment {
id: string;
sortable_published_date: number;
channel_uid?: string;
}
export interface MeiliPost extends PostAttributesFragment {
id: string;
sortable_date: number;
}
export interface MeiliWikiPage extends Omit<WikiPageAttributesFragment, "translations"> {
id: string;
translations: (Omit<
NonNullable<NonNullable<WikiPageAttributesFragment["translations"]>[number]>,
"body"
> & {
displayable_description?: string | null;
})[];
}
export enum MeiliIndices {
LIBRARY_ITEM = "library-item",
CONTENT = "content",
VIDEOS = "video",
POST = "post",
WIKI_PAGE = "wiki-page",
}
export type MeiliDocumentsType =
| {
index: MeiliIndices.LIBRARY_ITEM;
documents: MeiliLibraryItem;
strapi: GetLibraryItemQuery["libraryItem"];
}
| {
index: MeiliIndices.CONTENT;
documents: MeiliContent;
strapi: GetContentQuery["content"];
}
| {
index: MeiliIndices.VIDEOS;
documents: MeiliVideo;
strapi: GetVideoQuery["video"];
}
| {
index: MeiliIndices.POST;
documents: MeiliPost;
strapi: GetPostQuery["post"];
}
| {
index: MeiliIndices.WIKI_PAGE;
documents: MeiliWikiPage;
strapi: GetWikiPageQuery["wikiPage"];
};

View File

@ -0,0 +1,10 @@
query getContent($id: ID) {
content(id: $id) {
data {
id
attributes {
...contentAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getContents {
contents(pagination: { limit: -1 }) {
data {
id
attributes {
...contentAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getLibraryItem($id: ID) {
libraryItem(id: $id) {
data {
id
attributes {
...libraryItemAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getLibraryItems {
libraryItems(pagination: { limit: -1 }) {
data {
id
attributes {
...libraryItemAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getPost($id: ID) {
post(id: $id) {
data {
id
attributes {
...postAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getPosts {
posts(pagination: { limit: -1 }) {
data {
id
attributes {
...postAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getVideo($id: ID) {
video(id: $id) {
data {
id
attributes {
...videoAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getVideos {
videos(pagination: { limit: -1 }) {
data {
id
attributes {
...videoAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getWikiPage($id: ID) {
wikiPage(id: $id) {
data {
id
attributes {
...wikiPageAttributes
}
}
}
}

View File

@ -0,0 +1,10 @@
query getWikiPages {
wikiPages(pagination: { limit: -1 }) {
data {
id
attributes {
...wikiPageAttributes
}
}
}
}

9
src/core/graphql/sdk.ts Normal file
View File

@ -0,0 +1,9 @@
import { GraphQLClient } from "graphql-request";
import { getSdk } from "./generated";
export const getReadySdk = (): ReturnType<typeof getSdk> => {
const client = new GraphQLClient(process.env.URL_GRAPHQL ?? "", {
headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` },
});
return getSdk(client);
};

View File

@ -0,0 +1,79 @@
type JoinDot<K extends string, P extends string> = `${K}${"" extends K ? "" : "."}${P}`;
type PathDot<T, Acc extends string = ""> = T extends object
? {
[K in keyof T]: K extends string ? JoinDot<Acc, K> | PathDot<T[K], JoinDot<Acc, K>> : never;
}[keyof T]
: Acc;
type PathHead<T extends unknown[]> = T extends [infer head]
? head
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
T extends [infer head, ...infer rest]
? head
: "";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type PathRest<T extends unknown[]> = T extends [infer head, ...infer rest]
? rest extends []
? never
: rest
: never;
type PathLast<T extends unknown[]> = T["length"] extends 1 ? true : false;
type Recursive<T, Path extends unknown[]> = PathHead<Path> extends keyof T
? Omit<T, PathHead<Path>> & {
[P in PathHead<Path>]-?: PathLast<Path> extends true
? NonNullable<T[P]>
: Recursive<NonNullable<T[P]>, PathRest<Path>>;
}
: T;
type Split<Str, Acc extends string[] = []> = Str extends `${infer Head}.${infer Rest}`
? Split<Rest, [...Acc, Head]>
: Str extends `${infer Last}`
? [...Acc, Last]
: never;
export type SelectiveNonNullable<T, P extends PathDot<T>> = Recursive<NonNullable<T>, Split<P>>;
export const isDefined = <T>(t: T): t is NonNullable<T> => t !== null && t !== undefined;
export const isUndefined = <T>(t: T | null | undefined): t is null | undefined => !isDefined(t);
export const isDefinedAndNotEmpty = (string: string | null | undefined): string is string =>
isDefined(string) && string.length > 0;
export const filterDefined = <T>(t: T[] | null | undefined): NonNullable<T>[] =>
isUndefined(t) ? [] : (t.filter((item) => isDefined(item)) as NonNullable<T>[]);
export const filterHasAttributes = <T, P extends PathDot<T>>(
t: T[] | null | undefined,
paths: readonly P[]
): SelectiveNonNullable<T, typeof paths[number]>[] =>
isDefined(t)
? (t.filter((item) => hasAttributes(item, paths)) as unknown as SelectiveNonNullable<
T,
typeof paths[number]
>[])
: [];
const hasAttributes = <T>(item: T, paths: readonly PathDot<T>[]): boolean =>
isDefined(item) && paths.every((path) => hasAttribute(item, path));
const hasAttribute = <T>(item: T, path: string): boolean => {
if (isDefined(item)) {
const [head, ...rest] = path.split(".");
if (isDefined(head) && Object.keys(item).includes(head)) {
const attribute = head as keyof T;
if (isDefined(item[attribute])) {
if (rest.length > 0) {
return hasAttribute(item[attribute], rest.join("."));
}
return true;
}
}
}
return false;
};

25
src/core/helpers/date.ts Normal file
View File

@ -0,0 +1,25 @@
import { isDefined, isUndefined } from "./asserts";
export type DatePickerFragment = {
year?: number | null;
month?: number | null;
day?: number | null;
};
export const compareDate = (
a: DatePickerFragment | null | undefined,
b: DatePickerFragment | null | undefined
): number => {
if (isUndefined(a) || isUndefined(b)) {
return 0;
}
return dateInDays(a) - dateInDays(b);
};
export const dateInDays = (date: DatePickerFragment | null | undefined): number =>
isDefined(date) ? (date.year ?? Infinity) * 365 + (date.month ?? 12) * 31 + (date.day ?? 31) : 0;
export const datePickerToDate = (date: DatePickerFragment): Date =>
new Date(date.year ?? 0, date.month ? date.month - 1 : 0, date.day ?? 1);
export const getUnixTime = (date: Date): number => Math.floor(date.getTime() / 1000);

View File

@ -0,0 +1,221 @@
import { isDefinedAndNotEmpty } from "./asserts";
export const prettySlug = (slug?: string, parentSlug?: string): string => {
let newSlug = slug;
if (newSlug) {
if (isDefinedAndNotEmpty(parentSlug) && newSlug.startsWith(parentSlug))
newSlug = newSlug.substring(parentSlug.length + 1);
newSlug = newSlug.replaceAll("-", " ");
return capitalizeString(newSlug);
}
return "";
};
export const prettyInlineTitle = (
pretitle: string | null | undefined,
title: string | null | undefined,
subtitle: string | null | undefined
): string => {
let result = "";
if (pretitle) result += `${pretitle}: `;
result += title;
if (subtitle) result += ` - ${subtitle}`;
return result;
};
/* eslint-disable id-denylist */
export const prettyItemSubType = (
metadata:
| {
__typename: "ComponentMetadataAudio";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataBooks";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataGame";
platforms?: {
data: {
id?: string | null;
attributes?: {
short: string;
} | null;
}[];
} | null;
}
| {
__typename: "ComponentMetadataGroup";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
subitems_type?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| {
__typename: "ComponentMetadataVideo";
subtype?: {
data?: {
attributes?: {
slug: string;
titles?:
| ({
title: string;
} | null)[]
| null;
} | null;
} | null;
} | null;
}
| { __typename: "ComponentMetadataOther" }
| { __typename: "Error" }
| null
): string => {
if (metadata) {
switch (metadata.__typename) {
case "ComponentMetadataAudio":
case "ComponentMetadataBooks":
case "ComponentMetadataVideo":
return metadata.subtype?.data?.attributes?.titles &&
metadata.subtype.data.attributes.titles.length > 0 &&
metadata.subtype.data.attributes.titles[0]
? metadata.subtype.data.attributes.titles[0].title
: prettySlug(metadata.subtype?.data?.attributes?.slug);
case "ComponentMetadataGame":
return metadata.platforms?.data &&
metadata.platforms.data.length > 0 &&
metadata.platforms.data[0]?.attributes
? metadata.platforms.data[0].attributes.short
: "";
case "ComponentMetadataGroup": {
const firstPart =
metadata.subtype?.data?.attributes?.titles &&
metadata.subtype.data.attributes.titles.length > 0 &&
metadata.subtype.data.attributes.titles[0]
? metadata.subtype.data.attributes.titles[0].title
: prettySlug(metadata.subtype?.data?.attributes?.slug);
const secondPart =
metadata.subitems_type?.data?.attributes?.titles &&
metadata.subitems_type.data.attributes.titles.length > 0 &&
metadata.subitems_type.data.attributes.titles[0]
? metadata.subitems_type.data.attributes.titles[0].title
: prettySlug(metadata.subitems_type?.data?.attributes?.slug);
return `${secondPart} ${firstPart}`;
}
default:
return "";
}
}
return "";
};
/* eslint-enable id-denylist */
export const prettyShortenNumber = (number: number): string => {
if (number > 1_000_000) {
return `${(number / 1_000_000).toLocaleString(undefined, {
maximumSignificantDigits: 3,
})}M`;
} else if (number > 1_000) {
return `${(number / 1_000).toLocaleString(undefined, {
maximumSignificantDigits: 2,
})}K`;
}
return number.toLocaleString();
};
export const prettyDuration = (seconds: number): string => {
let hours = 0;
let minutes = 0;
let remainingSeconds = seconds;
while (remainingSeconds > 60) {
minutes++;
remainingSeconds -= 60;
}
while (minutes > 60) {
hours++;
minutes -= 60;
}
let result = "";
if (hours) result += `${hours.toString().padStart(2, "0")}:`;
result += `${minutes.toString().padStart(2, "0")}:`;
result += remainingSeconds.toString().padStart(2, "0");
return result;
};
export const prettyURL = (url: string): string => {
const domain = new URL(url);
return domain.hostname.replace("www.", "");
};
const capitalizeString = (string: string): string => {
const capitalizeWord = (word: string): string => word.charAt(0).toUpperCase() + word.substring(1);
let words = string.split(" ");
words = words.map((word) => capitalizeWord(word));
return words.join(" ");
};
export const slugify = (string: string | undefined): string => {
if (!string) {
return "";
}
return string
.replace(/[ÀÁÂÃÄÅàáâãäåæÆ]/gu, "a")
.replace(/[çÇ]/gu, "c")
.replace(/[ðÐ]/gu, "d")
.replace(/[ÈÉÊËéèêë]/gu, "e")
.replace(/[ÏïÎîÍíÌì]/gu, "i")
.replace(/[Ññ]/gu, "n")
.replace(/[øØœŒÕõÔôÓóÒò]/gu, "o")
.replace(/[ÜüÛûÚúÙù]/gu, "u")
.replace(/[ŸÿÝý]/gu, "y")
.toLowerCase()
.replace(/[^a-z0-9- ]/gu, "")
.trim()
.replace(/ /gu, "-");
};
export const sJoin = (...args: (string | null | undefined)[]): string => args.join("");

View File

@ -0,0 +1,16 @@
import { isDefined } from "./asserts";
export const isUntangibleGroupItem = (
metadata:
| {
__typename: string;
// eslint-disable-next-line id-denylist
subtype?: { data?: { attributes?: { slug: string } | null } | null } | null;
}
| null
| undefined
): boolean =>
isDefined(metadata) &&
metadata.__typename === "ComponentMetadataGroup" &&
(metadata.subtype?.data?.attributes?.slug === "variant-set" ||
metadata.subtype?.data?.attributes?.slug === "relation-set");

View File

@ -0,0 +1,25 @@
import { Currencies } from "./localData";
import { PricePickerFragment } from "graphql/generated";
export const convertPrice = (
pricePicker: PricePickerFragment,
targetCurrency: Currencies[number]
): number => {
if (pricePicker.amount && pricePicker.currency?.data?.attributes && targetCurrency.attributes)
return (
(pricePicker.amount * pricePicker.currency.data.attributes.rate_to_usd) /
targetCurrency.attributes.rate_to_usd
);
return 0;
};
export const convertMmToInch = (mm: number | null | undefined): string =>
mm ? (mm * 0.03937008).toPrecision(3) : "";
export const randomInt = (min: number, max: number): number =>
Math.floor(Math.random() * (max - min)) + min;
export const isInteger = (value: string): boolean => /^[+-]?[0-9]+$/u.test(value);
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);

143
src/helpers/meili.ts Normal file
View File

@ -0,0 +1,143 @@
import {
MeiliContent,
MeiliDocumentsType,
MeiliIndices,
MeiliWikiPage,
} from "core/graphql/meiliTypes";
import {
filterDefined,
filterHasAttributes,
isDefined,
isDefinedAndNotEmpty,
} from "core/helpers/asserts";
import { datePickerToDate, getUnixTime } from "core/helpers/date";
import { prettyInlineTitle } from "core/helpers/formatters";
import { isUntangibleGroupItem } from "core/helpers/libraryItem";
import { MeiliSearch } from "meilisearch";
export const getMeili = () =>
new MeiliSearch({
host: process.env.MEILISEARCH_URL ?? "",
apiKey: process.env.MEILISEARCH_MASTER_KEY,
});
type TransformFunction<I extends MeiliDocumentsType["index"]> = (
data: NonNullable<Extract<MeiliDocumentsType, { index: I }>["strapi"]>["data"]
) => Extract<MeiliDocumentsType, { index: I }>["documents"];
const transformVideo: TransformFunction<MeiliIndices.VIDEOS> = (data) => {
if (!data) throw new Error(`Data is empty ${MeiliIndices.VIDEOS}`);
if (!data.attributes || !data.id)
throw new Error(`Incorrect data stucture on ${MeiliIndices.VIDEOS}`);
const { id, attributes } = data;
return {
id,
...attributes,
sortable_published_date: getUnixTime(datePickerToDate(attributes.published_date)),
channel_uid: attributes.channel?.data?.attributes?.uid,
};
};
const transformContent: TransformFunction<MeiliIndices.CONTENT> = (data) => {
if (!data) throw new Error(`Data is empty ${MeiliIndices.CONTENT}`);
if (!data.attributes || !data.id)
throw new Error(`Incorrect data stucture on ${MeiliIndices.CONTENT}`);
const {
id,
attributes: { translations, updatedAt, ...otherAttributes },
} = data;
return {
id,
...otherAttributes,
translations: filterDefined(translations).map(
({ text_set, description, ...otherTranslatedFields }) => {
let displayable_description = "";
if (isDefinedAndNotEmpty(description)) displayable_description += description;
if (isDefinedAndNotEmpty(text_set?.text))
displayable_description += `\n\n${text_set?.text}`;
return {
...otherTranslatedFields,
displayable_description,
};
}
),
sortable_updated_date: updatedAt,
};
};
const transformLibraryItem: TransformFunction<MeiliIndices.LIBRARY_ITEM> = (data) => {
if (!data) throw new Error(`Data is empty ${MeiliIndices.LIBRARY_ITEM}`);
if (!data.attributes || !data.id)
throw new Error(`Incorrect data stucture on ${MeiliIndices.LIBRARY_ITEM}`);
const { id, attributes } = data;
return {
id,
sortable_date: isDefined(attributes.release_date)
? getUnixTime(datePickerToDate(attributes.release_date))
: undefined,
sortable_name: prettyInlineTitle(undefined, attributes.title, attributes.subtitle),
sortable_price:
attributes.price?.currency?.data?.attributes && isDefined(attributes.price.amount)
? attributes.price.amount * attributes.price.currency.data.attributes.rate_to_usd
: undefined,
untangible_group_item: isUntangibleGroupItem(attributes.metadata?.[0]),
...attributes,
};
};
const transformPost: TransformFunction<MeiliIndices.POST> = (data) => {
if (!data) throw new Error(`Data is empty ${MeiliIndices.POST}`);
if (!data.attributes || !data.id)
throw new Error(`Incorrect data stucture on ${MeiliIndices.POST}`);
const { id, attributes } = data;
return {
id,
...attributes,
sortable_date: getUnixTime(datePickerToDate(attributes.date)),
};
};
const transformWikiPage: TransformFunction<MeiliIndices.WIKI_PAGE> = (data) => {
if (!data) throw new Error(`Data is empty ${MeiliIndices.POST}`);
if (!data.attributes || !data.id)
throw new Error(`Incorrect data stucture on ${MeiliIndices.POST}`);
const {
id,
attributes: { translations, definitions, ...otherAttributes },
} = data;
return {
id,
definitions,
translations: filterDefined(translations).map(({ summary, body, ...otherTransAttributes }) => {
let displayable_description = "";
if (isDefinedAndNotEmpty(summary)) displayable_description += summary;
if (isDefined(body) && isDefinedAndNotEmpty(body.body))
displayable_description += `\n\n${body.body}`;
definitions?.forEach((def) =>
def?.translations?.forEach((defTranslation) => {
if (
defTranslation?.language?.data?.attributes?.code ===
otherTransAttributes.language?.data?.attributes?.code &&
isDefined(defTranslation?.definition)
) {
displayable_description += `\n\n${defTranslation?.definition}`;
}
})
);
return {
summary,
...otherTransAttributes,
displayable_description,
};
}),
...otherAttributes,
};
};
export const strapiToMeiliTransformFunctions = {
video: transformVideo,
"library-item": transformLibraryItem,
content: transformContent,
post: transformPost,
"wiki-page": transformWikiPage,
};

40
src/server.ts Normal file
View File

@ -0,0 +1,40 @@
import http from "http";
import { webhookHandler } from "webhook";
export const requestListener: http.RequestListener = async (req, res) => {
if (req.method !== "POST") {
res
.writeHead(405, { "Content-Type": "application/json" })
.end(JSON.stringify({ message: "Method Not Allowed. Use POST." }));
return;
}
if (req.url !== "/strapi/") {
res
.writeHead(404, { "Content-Type": "application/json" })
.end(JSON.stringify({ message: "Route not found.", route: req.url }));
return;
}
if (req.headers.authorization !== `Bearer ${process.env.WEBHOOK_TOKEN}`) {
res
.writeHead(403, { "Content-Type": "application/json" })
.end(JSON.stringify({ message: "Invalid auth token." }));
return;
}
// Retrieve and parse body
const buffers: Uint8Array[] = [];
for await (const chunk of req) {
buffers.push(chunk);
}
const data = JSON.parse(Buffer.concat(buffers).toString());
await webhookHandler(data, res);
res.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
message: "Done.",
})
);
};

View File

@ -0,0 +1,5 @@
fragment datePicker on ComponentBasicsDatepicker {
year
month
day
}

View File

@ -0,0 +1,12 @@
fragment pricePicker on ComponentBasicsPrice {
amount
currency {
data {
attributes {
symbol
code
rate_to_usd
}
}
}
}

View File

@ -0,0 +1,8 @@
fragment uploadImage on UploadFile {
name
alternativeText
caption
width
height
url
}

89
src/syncho.ts Normal file
View File

@ -0,0 +1,89 @@
import { MeiliDocumentsType, MeiliIndices } from "core/graphql/meiliTypes";
import { getReadySdk } from "core/graphql/sdk";
import { getMeili, strapiToMeiliTransformFunctions } from "helpers/meili";
export const synchronizeStrapiAndMeili = async () => {
const sdk = getReadySdk();
// [ LIBRARY ITEMS ]
{
const libraryItems = await sdk.getLibraryItems();
processIndex(
MeiliIndices.LIBRARY_ITEM,
libraryItems.libraryItems?.data.map((item) =>
strapiToMeiliTransformFunctions["library-item"](item)
),
["title", "subtitle", "descriptions"],
["sortable_name", "sortable_date", "sortable_price"],
["primary", "root_item", "id", "untangible_group_item"]
);
}
// [ CONTENT ]
{
const content = await sdk.getContents();
processIndex(
MeiliIndices.CONTENT,
content.contents?.data.map((item) => strapiToMeiliTransformFunctions["content"](item)),
["translations"],
["slug", "sortable_updated_date"]
);
}
// [ VIDEOS ]
{
const videos = await sdk.getVideos();
processIndex(
MeiliIndices.VIDEOS,
videos.videos?.data.map((item) => strapiToMeiliTransformFunctions["video"](item)),
["title", "channel", "description", "uid"],
["sortable_published_date", "duration", "views"],
["gone", "channel_uid"]
);
}
// [ POSTS ]
{
const posts = await sdk.getPosts();
processIndex(
MeiliIndices.POST,
posts.posts?.data.map((item) => strapiToMeiliTransformFunctions["post"](item)),
["translations"],
["sortable_date"],
["hidden"]
);
}
// [ WIKI PAGES ]
{
const wikiPages = await sdk.getWikiPages();
processIndex(
MeiliIndices.WIKI_PAGE,
wikiPages.wikiPages?.data.map((item) => strapiToMeiliTransformFunctions["wiki-page"](item)),
["translations", "definitions"]
);
}
};
const processIndex = async <I extends MeiliDocumentsType["index"]>(
indexName: I,
data?: Extract<MeiliDocumentsType, { index: I }>["documents"][],
searchableAttributes?: (keyof NonNullable<typeof data>[number])[],
sortableAttributes?: (keyof NonNullable<typeof data>[number])[],
filterableAttributes?: (keyof NonNullable<typeof data>[number])[]
) => {
const meili = getMeili();
await meili.deleteIndexIfExists(indexName);
if (data && data.length > 0) {
await meili.createIndex(indexName);
const index = meili.index(indexName);
index.updateSettings({
searchableAttributes: searchableAttributes as string[],
sortableAttributes: sortableAttributes as string[],
filterableAttributes: filterableAttributes as string[],
pagination: { maxTotalHits: 10000 },
});
index.addDocuments(data);
}
};

97
src/webhook.ts Normal file
View File

@ -0,0 +1,97 @@
import { MeiliDocumentsType, MeiliIndices } from "core/graphql/meiliTypes";
import { getReadySdk } from "core/graphql/sdk";
import { getMeili, strapiToMeiliTransformFunctions } from "helpers/meili";
import http from "http";
const allowedEvents = ["entry.create", "entry.update", "entry.delete"] as const;
type CRUDEvents = typeof allowedEvents[number];
type StrapiEvent = {
event: CRUDEvents;
model: MeiliIndices;
entry: {
id: string;
};
};
export const webhookHandler = async (data: StrapiEvent, res: http.ServerResponse) => {
console.log(data);
if (!allowedEvents.includes(data.event)) {
res.writeHead(406, { "Content-Type": "application/json" }).end(
JSON.stringify({
message: `The event given ${data.event} in not allowed.`,
})
);
return;
}
const sdk = getReadySdk();
switch (data.model) {
case MeiliIndices.LIBRARY_ITEM: {
processIndex(
data.model,
strapiToMeiliTransformFunctions["library-item"](
(await sdk.getLibraryItem({ id: data.entry.id })).libraryItem?.data
)
);
break;
}
case MeiliIndices.CONTENT: {
processIndex(
data.model,
strapiToMeiliTransformFunctions["content"](
(await sdk.getContent({ id: data.entry.id })).content?.data
)
);
break;
}
case MeiliIndices.VIDEOS: {
processIndex(
data.model,
strapiToMeiliTransformFunctions["video"](
(await sdk.getVideo({ id: data.entry.id })).video?.data
)
);
break;
}
case MeiliIndices.POST: {
processIndex(
data.model,
strapiToMeiliTransformFunctions["post"](
(await sdk.getPost({ id: data.entry.id })).post?.data
)
);
break;
}
case MeiliIndices.WIKI_PAGE: {
processIndex(
data.model,
strapiToMeiliTransformFunctions["wiki-page"](
(await sdk.getWikiPage({ id: data.entry.id })).wikiPage?.data
)
);
break;
}
default: {
console.log("Unrecognized data model", data.model);
break;
}
}
};
const processIndex = async <I extends MeiliDocumentsType["index"]>(
indexName: I,
data: Extract<MeiliDocumentsType, { index: I }>["documents"] | undefined | null
) => {
const meili = getMeili();
const index = meili.index(indexName);
await index.addDocuments([data]);
};

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
// Type Checking
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"strict": true,
"target": "ESNext",
"importHelpers": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "src"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long