├── web
├── .npmrc
├── test-results
│ └── .last-run.json
├── static
│ ├── favicon.ico
│ ├── robots.txt
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── manifest.json
│ └── offline.html
├── src
│ ├── lib
│ │ ├── NotoSans-Regular.ttf
│ │ ├── svgs
│ │ │ ├── search.svelte
│ │ │ ├── bookmark.svelte
│ │ │ ├── bookmarks.svelte
│ │ │ ├── download.svelte
│ │ │ └── books.svelte
│ │ ├── HadithList.svelte
│ │ ├── HadithCard.svelte
│ │ ├── HadithFilters.svelte
│ │ ├── Nav.svelte
│ │ ├── SearchBox.svelte
│ │ └── Hadith.svelte
│ ├── app.d.ts
│ ├── routes
│ │ ├── +page.ts
│ │ ├── book
│ │ │ ├── +page.ts
│ │ │ └── +page.svelte
│ │ ├── api
│ │ │ └── og
│ │ │ │ └── +server.js
│ │ ├── bookmarks
│ │ │ └── +page.svelte
│ │ ├── +layout.svelte
│ │ └── +page.svelte
│ ├── store.ts
│ ├── models.ts
│ ├── app.html
│ └── app.css
├── postcss.config.mjs
├── .gitignore
├── .eslintignore
├── svelte.config.js
├── .prettierignore
├── .prettierrc
├── playwright.config.ts
├── vite.config.js
├── .eslintrc.cjs
├── tsconfig.json
├── tests
│ └── app.spec.ts
├── package.json
└── tailwind.config.js
├── .gitignore
├── .github
├── dependabot.yml
├── FUNDING.yml
└── workflows
│ ├── test.yml
│ └── codeql-analysis.yml
├── vercel.json
├── go.mod
├── LICENSE.md
├── api
├── seach_test.go
├── book_test.go
├── book.go
└── search.go
├── readme.md
└── go.sum
/web/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/web/test-results/.last-run.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "passed",
3 | "failedTests": []
4 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | venv/
2 | .idea/
3 | .vscode/
4 | .DS_Store
5 | .vercel
6 |
7 | # go
8 | vendor
--------------------------------------------------------------------------------
/web/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ananto30/ask-hadith/HEAD/web/static/favicon.ico
--------------------------------------------------------------------------------
/web/static/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/web/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ananto30/ask-hadith/HEAD/web/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/web/src/lib/NotoSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ananto30/ask-hadith/HEAD/web/src/lib/NotoSans-Regular.ttf
--------------------------------------------------------------------------------
/web/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/web/static/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ananto30/ask-hadith/HEAD/web/static/android-chrome-192x192.png
--------------------------------------------------------------------------------
/web/static/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ananto30/ask-hadith/HEAD/web/static/android-chrome-512x512.png
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 | .vercel
10 | .output
11 | vite.config.js.timestamp-*
12 | vite.config.ts.timestamp-*
13 |
--------------------------------------------------------------------------------
/web/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/web/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 |
3 | /** @type {import('@sveltejs/kit').Config} */
4 | const config = {
5 | kit: {
6 | adapter: adapter()
7 | }
8 | };
9 |
10 | export default config;
11 |
--------------------------------------------------------------------------------
/web/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/web/src/lib/svgs/search.svelte:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/web/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 | // and what to do when importing types
4 | declare namespace App {
5 | // interface Error {}
6 | // interface Locals {}
7 | // interface PageData {}
8 | // interface Platform {}
9 | }
10 |
--------------------------------------------------------------------------------
/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
7 | "overrides": [
8 | {
9 | "files": "*.svelte",
10 | "options": {
11 | "parser": "svelte"
12 | }
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/web/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | webServer: {
5 | command: 'npm run dev',
6 | port: 5173,
7 | timeout: 120 * 1000,
8 | reuseExistingServer: !process.env.CI
9 | },
10 | projects: [
11 | {
12 | name: 'chromium',
13 | use: { ...devices['Desktop Chrome'] }
14 | }
15 | ]
16 | });
17 |
--------------------------------------------------------------------------------
/web/src/routes/+page.ts:
--------------------------------------------------------------------------------
1 | export async function load({ fetch, url }) {
2 | const searchKey = url.searchParams.get('search');
3 | if (!searchKey) {
4 | return {
5 | resp: {},
6 | searchKey
7 | };
8 | }
9 | const res = await fetch(`https://ask-hadith.vercel.app/api/search?search=${searchKey}`);
10 | // const res = await fetch(`http://localhost:3000/api/search?search=${searchKey}`);
11 |
12 | if (res.ok) {
13 | const data = await res.json();
14 | return {
15 | resp: data || {},
16 | searchKey
17 | };
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gomod" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/web/vite.config.js:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 |
3 | import fs from 'fs';
4 |
5 | /** @type {import('vite').UserConfig} */
6 | const config = {
7 | plugins: [sveltekit(), rawFonts(['.ttf'])]
8 | };
9 |
10 | function rawFonts(ext) {
11 | return {
12 | name: 'vite-plugin-raw-fonts',
13 | transform(code, id) {
14 | if (ext.some((e) => id.endsWith(e))) {
15 | const buffer = fs.readFileSync(id);
16 | return { code: `export default ${JSON.stringify(buffer)}`, map: null };
17 | }
18 | }
19 | };
20 | }
21 |
22 | export default config;
23 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/api/(.*)",
5 | "headers": [
6 | { "key": "Access-Control-Allow-Credentials", "value": "true" },
7 | { "key": "Access-Control-Allow-Origin", "value": "*" },
8 | { "key": "Access-Control-Allow-Methods", "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT" },
9 | { "key": "Access-Control-Allow-Headers", "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" }
10 | ]
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/web/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
5 | plugins: ['svelte3', '@typescript-eslint'],
6 | ignorePatterns: ['*.cjs'],
7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
8 | settings: {
9 | 'svelte3/typescript': () => require('typescript')
10 | },
11 | parserOptions: {
12 | sourceType: 'module',
13 | ecmaVersion: 2020
14 | },
15 | env: {
16 | browser: true,
17 | es2017: true,
18 | node: true
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/web/src/lib/svgs/bookmark.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
23 |
--------------------------------------------------------------------------------
/web/src/store.ts:
--------------------------------------------------------------------------------
1 | import { writable } from 'svelte/store';
2 | import type { CollectionCounter, HadithModel } from './models';
3 |
4 | export const searchKey = writable('');
5 | export const selectedCollection = writable('');
6 | export const firstHadithBase64 = writable('');
7 |
8 | export const collectionsSorted = writable([]);
9 | export const hadithsByCollection = writable
25 |
26 | ## Technologies 📱
27 |
28 | * **Vercel**: For serverless functions (in Go) to search and get hadiths.
29 | * **MongoDB Atlas**: Database and search engine.
30 | * **Svelte**: Web app (Frontend).
31 | * **Netlify**: Web deployment.
32 | * **TailwindCSS**: Styling.
33 |
34 | ## Features ⭐
35 |
36 | * Search Hadiths by anything (full text search).
37 | * Search hadith by book name and number, like `bukhari 1029`. (currently has some issues)
38 | * Bookmark hadith (local storage).
39 | * Install as PWA.
40 | * Copy and share hadith.
41 |
42 | ## Development 🧑💻
43 |
44 | * Web (Svelte)
45 |
46 |
47 |
48 | cd web
49 | npm install
50 | npm run dev
51 |
52 | * Serverless functions (Go)
53 |
54 |
55 |
56 | npm i -g vercel
57 | vercel dev
58 |
59 | Test the api `/api/search?search=cat`
60 |
61 | ## Support 🙋
62 |
63 | [](https://www.buymeacoffee.com/ananto30)
64 |
--------------------------------------------------------------------------------
/web/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 |
19 |
20 |
21 | %sveltekit.head%
22 |
23 |
24 |
28 |
29 |
52 |
53 |
54 |
55 | %sveltekit.body%
56 |
57 |
58 |
59 |
60 |
69 |
70 |
--------------------------------------------------------------------------------
/web/src/routes/bookmarks/+page.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 | Ask Hadith: Bookmarks
16 |
17 |
18 |
19 |
20 |
21 |
24 |
25 |
26 | Bookmarks
27 |
28 |
29 | Your saved hadiths ({bookmarkedHadiths.length})
30 |
31 |
32 |
33 |
34 |
35 |
36 | {#if bookmarkedHadiths?.length === 0}
37 |
38 |
39 |
📚
40 |
41 | No bookmarks yet
42 |
43 |
44 | Start bookmarking hadiths to save them for later
45 |
46 |
50 | Search Hadiths
51 |
52 |
53 |
54 | {:else}
55 |
56 |
59 |
60 | Note: Bookmarks are saved locally in your browser. Clearing
61 | your cache or browser data will remove them.
62 |
63 |
64 |
65 | {#each bookmarkedHadiths as hadith}
66 |
67 | {/each}
68 |
69 |
70 | {/if}
71 |
72 |
73 |
--------------------------------------------------------------------------------
/web/src/app.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @layer base {
4 | html {
5 | @apply antialiased;
6 | font-size: 16px;
7 | font-feature-settings:
8 | 'rlig' 1,
9 | 'calt' 1;
10 | }
11 |
12 | body {
13 | @apply bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50;
14 | font-family:
15 | 'Geist',
16 | system-ui,
17 | -apple-system,
18 | sans-serif;
19 | }
20 |
21 | /* Headings */
22 | h1 {
23 | @apply text-5xl font-bold tracking-tight;
24 | line-height: 1.2;
25 | margin-bottom: 1rem;
26 | }
27 |
28 | h2 {
29 | @apply text-3xl font-bold tracking-tight;
30 | line-height: 1.3;
31 | margin-bottom: 0.875rem;
32 | }
33 |
34 | h3 {
35 | @apply text-xl font-semibold tracking-tight;
36 | line-height: 1.4;
37 | margin-bottom: 0.75rem;
38 | }
39 |
40 | h4 {
41 | @apply text-lg font-semibold tracking-tight;
42 | line-height: 1.5;
43 | margin-bottom: 0.625rem;
44 | }
45 |
46 | h5 {
47 | @apply text-base font-semibold;
48 | line-height: 1.5;
49 | margin-bottom: 0.5rem;
50 | }
51 |
52 | h6 {
53 | @apply text-sm font-semibold;
54 | line-height: 1.5;
55 | margin-bottom: 0.5rem;
56 | }
57 |
58 | /* Paragraph and text */
59 | p {
60 | @apply mb-4;
61 | line-height: 1.75;
62 | }
63 |
64 | a {
65 | @apply text-neutral-600 underline-offset-4 hover:underline dark:text-neutral-300;
66 | transition: color 0.2s;
67 | }
68 |
69 | button {
70 | @apply transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none;
71 | }
72 |
73 | /* Text selections */
74 | ::selection {
75 | @apply bg-neutral-700 text-neutral-50 dark:bg-neutral-200 dark:text-neutral-900;
76 | }
77 | }
78 |
79 | html {
80 | overflow-y: scroll;
81 | scroll-behavior: smooth;
82 | }
83 |
84 | /* Scrollbar styles */
85 | ::-webkit-scrollbar {
86 | width: 8px;
87 | height: 8px;
88 | }
89 |
90 | ::-webkit-scrollbar-track {
91 | @apply bg-neutral-100 dark:bg-neutral-800;
92 | }
93 |
94 | ::-webkit-scrollbar-thumb {
95 | @apply rounded-full bg-neutral-400 dark:bg-neutral-600;
96 | border: 2px solid;
97 | border-color: inherit;
98 | background-clip: padding-box;
99 | }
100 |
101 | ::-webkit-scrollbar-thumb:hover {
102 | @apply bg-neutral-600 dark:bg-neutral-400;
103 | }
104 |
105 | /* Focus visible for better keyboard navigation */
106 | :focus-visible {
107 | @apply outline-2 outline-offset-2 outline-neutral-600 dark:outline-neutral-300;
108 | }
109 |
110 | /* Smooth transitions */
111 | * {
112 | @apply transition-colors duration-200;
113 | }
114 |
115 | input,
116 | textarea,
117 | select {
118 | @apply transition-colors duration-200;
119 | }
120 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ netlify ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ netlify ]
20 | schedule:
21 | - cron: '29 17 * * 6'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript', 'go' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v2
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v1
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v1
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v1
72 |
--------------------------------------------------------------------------------
/web/src/lib/Nav.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
84 |
--------------------------------------------------------------------------------
/api/book.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package handler provides the HTTP handlers for the serverless API.
3 | This file contains the handler for the /book endpoint.
4 | */
5 | package handler
6 |
7 | import (
8 | "fmt"
9 | "net/http"
10 | "strings"
11 |
12 | "go.mongodb.org/mongo-driver/bson"
13 | )
14 |
15 | // GetHadithByBookRefNo returns a hadith by its collection ID, book number and
16 | // book reference number. These are all required query parameters.
17 | func GetHadithByBookRefNo(w http.ResponseWriter, r *http.Request) {
18 | ctx := r.Context()
19 |
20 | collectionID := r.URL.Query().Get("collection_id")
21 | book := r.URL.Query().Get("book")
22 | refNo := r.URL.Query().Get("ref_no")
23 | if reason, ok := validateGetHadithByBookRefNoQueryParams(collectionID, book, refNo); !ok {
24 | sendBadRequestResp(w, reason)
25 | return
26 | }
27 |
28 | client, err := getMongoClient()
29 | if err != nil {
30 | sendServerErrorResp(w, err)
31 | return
32 | }
33 | collection := client.Database("hadith").Collection("hadiths")
34 |
35 | var resp BookResponse
36 | if err := collection.FindOne(
37 | ctx,
38 | bson.M{
39 | "collection_id": collectionID,
40 | "book_no": book,
41 | "book_ref_no": refNo,
42 | },
43 | ).
44 | Decode(&resp); err != nil {
45 | sendServerErrorResp(w, err)
46 | return
47 | }
48 |
49 | resp.Base64 = hadithToBase64(*resp.HadithResponse)
50 |
51 | sendResp(w, resp)
52 | }
53 |
54 | func validateGetHadithByBookRefNoQueryParams(collectionID, book, refNo string) (string, bool) {
55 | if reason, ok := validateCollectionID(collectionID); !ok {
56 | return reason, false
57 | }
58 | if reason, ok := validateBook(book); !ok {
59 | return reason, false
60 | }
61 | if reason, ok := validateRefNo(refNo); !ok {
62 | return reason, false
63 | }
64 |
65 | return "", true
66 | }
67 |
68 | func validateCollectionID(collectionID string) (string, bool) {
69 | if collectionID == "" {
70 | return "collection_id is required", false
71 | }
72 | if !contains(shortNames, collectionID) {
73 | return fmt.Sprintf("collection_id must be one of %s", strings.Join(shortNames, ", ")), false
74 | }
75 |
76 | return "", true
77 | }
78 |
79 | func validateBook(book string) (string, bool) {
80 | if book == "" {
81 | return "book is required", false
82 | }
83 | if !isNumber(book) {
84 | return "book must be a number", false
85 | }
86 |
87 | return "", true
88 | }
89 |
90 | func validateRefNo(refNo string) (string, bool) {
91 | if refNo == "" {
92 | return "ref_no is required", false
93 | }
94 | if !isNumber(refNo) {
95 | return "ref_no must be a number", false
96 | }
97 |
98 | return "", true
99 | }
100 |
101 | type BookResponse struct {
102 | *HadithResponse `bson:",inline" json:",inline"`
103 | Base64 string `json:"base64"`
104 | }
105 |
--------------------------------------------------------------------------------
/web/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
91 |
92 |
--------------------------------------------------------------------------------
/web/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./src/**/*.{html,js,svelte,ts}'],
4 | theme: {
5 | colors: {
6 | white: '#ffffff',
7 | neutral: {
8 | 50: '#fafafa',
9 | 100: '#f5f5f5',
10 | 200: '#e5e5e5',
11 | 300: '#d4d4d4',
12 | 400: '#a3a3a3',
13 | 600: '#737373',
14 | 700: '#404040',
15 | 800: '#262626',
16 | 900: '#171717'
17 | }
18 | },
19 | extend: {
20 | fontSize: {
21 | xs: ['0.75rem', { lineHeight: '1rem' }],
22 | sm: ['0.875rem', { lineHeight: '1.25rem' }],
23 | base: ['1rem', { lineHeight: '1.5rem' }],
24 | lg: ['1.125rem', { lineHeight: '1.75rem' }],
25 | xl: ['1.25rem', { lineHeight: '1.75rem' }],
26 | '2xl': ['1.5rem', { lineHeight: '2rem' }],
27 | '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
28 | '4xl': ['2.25rem', { lineHeight: '2.5rem' }],
29 | '5xl': ['3rem', { lineHeight: '1' }],
30 | '6xl': ['3.75rem', { lineHeight: '1' }],
31 | '7xl': ['4.5rem', { lineHeight: '1' }],
32 | '8xl': ['6rem', { lineHeight: '1' }],
33 | '9xl': ['8rem', { lineHeight: '1' }]
34 | },
35 | spacing: {
36 | 0: '0px',
37 | 1: '0.25rem',
38 | 2: '0.5rem',
39 | 3: '0.75rem',
40 | 4: '1rem',
41 | 5: '1.25rem',
42 | 6: '1.5rem',
43 | 7: '1.75rem',
44 | 8: '2rem',
45 | 9: '2.25rem',
46 | 10: '2.5rem',
47 | 12: '3rem',
48 | 14: '3.5rem',
49 | 16: '4rem',
50 | 20: '5rem',
51 | 24: '6rem',
52 | 28: '7rem',
53 | 32: '8rem',
54 | 36: '9rem',
55 | 40: '10rem',
56 | 44: '11rem',
57 | 48: '12rem',
58 | 52: '13rem',
59 | 56: '14rem',
60 | 60: '15rem',
61 | 64: '16rem',
62 | 72: '18rem',
63 | 80: '20rem',
64 | 96: '24rem'
65 | },
66 | fontFamily: {
67 | sans: [
68 | '"Geist"',
69 | '-apple-system',
70 | 'BlinkMacSystemFont',
71 | '"Segoe UI"',
72 | 'Roboto',
73 | '"Helvetica Neue"',
74 | 'Arial',
75 | '"Noto Sans"',
76 | 'sans-serif'
77 | ],
78 | serif: ['Georgia', '"Times New Roman"', 'serif'],
79 | mono: ['"Fira Code"', '"Courier New"', 'monospace']
80 | },
81 | borderRadius: {
82 | none: '0',
83 | sm: '0.125rem',
84 | base: '0.25rem',
85 | md: '0.375rem',
86 | lg: '0.5rem',
87 | xl: '0.75rem',
88 | '2xl': '1rem',
89 | '3xl': '1.5rem',
90 | full: '9999px'
91 | },
92 | boxShadow: {
93 | none: 'none',
94 | sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
95 | base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
96 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
97 | lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
98 | xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
99 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
100 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)'
101 | }
102 | }
103 | },
104 | plugins: [],
105 | darkMode: 'class'
106 | };
107 |
--------------------------------------------------------------------------------
/web/src/routes/book/+page.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 | {ogTitle()}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {#if !hadith}
51 |
52 |
Hadith not found
53 |
54 | {:else}
55 |
56 |
59 |
60 |
61 | {hadith.collection}
62 |
63 |
64 | Book {hadith.book_no}
65 | •
66 | Hadith {hadith.book_ref_no}
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | {#if $searchKey}
80 |
93 | {/if}
94 |
95 |
96 | {/if}
97 |
98 |
--------------------------------------------------------------------------------
/web/src/lib/SearchBox.svelte:
--------------------------------------------------------------------------------
1 |
67 |
68 |
69 |
70 |
73 |
80 |
87 |
88 |
89 |
90 |
91 |
108 | {#if showInstructions}
109 |
113 | -
114 | •
115 | Search is based on exact match of words.
116 |
117 | -
118 | •
119 | Multiple words like "cat water" show results containing all words.
120 |
121 | -
122 | •
123 | Search specific hadith: "bukhari 1028", "muslim 3", etc.
124 |
125 |
126 | {/if}
127 |
128 |
129 |
--------------------------------------------------------------------------------
/web/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
59 |
60 |
61 | {ogTitle()}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
97 |
98 |
99 |
100 | {#if searching}
101 |
102 |
103 |
106 |
107 | Searching hadiths...
108 |
109 |
110 |
111 | {:else if notFound}
112 |
113 |
114 |
🔍
115 |
116 | No results found
117 |
118 |
119 | Try adjusting your search terms
120 |
121 |
122 |
123 | {:else}
124 |
125 |
126 | {/if}
127 |
128 |
129 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
4 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7 | github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
8 | github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
9 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
10 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
11 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
12 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
13 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
14 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
15 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
16 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
17 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
18 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
19 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
20 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
21 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
22 | go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
23 | go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
26 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
27 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
28 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
29 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
30 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
31 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
32 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
33 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
34 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
35 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
37 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
38 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
39 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
40 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
42 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
43 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
44 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
45 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
46 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
47 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
48 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
49 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
50 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
51 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
52 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
53 |
--------------------------------------------------------------------------------
/web/src/lib/Hadith.svelte:
--------------------------------------------------------------------------------
1 |
98 |
99 |
103 |
104 |
105 |
106 |
107 |
108 | {hadith.collection}
109 |
110 | {#if hadith.hadith_no}
111 |
114 | {hadith.hadith_no}
115 |
116 | {/if}
117 |
118 |
119 |
140 |
162 |
163 |
164 |
165 | {hadith.book_en}
166 | {#if hadith.book_no || hadith.book_ref_no}
167 | |
168 | B:{hadith.book_no} - H:{hadith.book_ref_no}
169 | {/if}
170 |
171 |
172 |
173 |
174 |
175 |
176 | {#if hadith.narrator_en}
177 |
178 | {hadith.narrator_en}
179 |
180 | {/if}
181 |
182 |
183 |
184 | {#each hadith.body_en.split(' ') as word}
185 | {#if hadith.highlights && hadith.highlights.includes(word.replace(/[.,/#!$%^&*;:{}=\-_`~()"']/g, ''))}
186 | {word}
190 | {:else}
191 | {word}
192 | {/if}
193 | {' '}
194 | {/each}
195 |
196 |
197 |
198 |
199 |
200 | {#if hadith.chapter_en}
201 |
Chapter: {hadith.chapter_en}
202 | {/if}
203 | {#if hadith.hadith_grade}
204 |
205 |
206 | {hadith.hadith_grade}
210 |
211 | {/if}
212 |
213 |
214 | {#if copied}
215 |
219 | Hadith copied!
220 |
221 | {/if}
222 |
223 |
--------------------------------------------------------------------------------
/api/search.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package handler provides the HTTP handlers for the serverless API.
3 | This file contains the handler for the /search endpoint.
4 | */
5 | package handler
6 |
7 | import (
8 | "context"
9 | "encoding/base64"
10 | "encoding/json"
11 | "fmt"
12 | "net/http"
13 | "os"
14 | "regexp"
15 | "strconv"
16 | "strings"
17 | "time"
18 |
19 | lru "github.com/hashicorp/golang-lru"
20 | "go.mongodb.org/mongo-driver/bson"
21 | "go.mongodb.org/mongo-driver/mongo"
22 | "go.mongodb.org/mongo-driver/mongo/options"
23 | )
24 |
25 | const (
26 | pageSize = 500
27 | )
28 |
29 | var (
30 | mongoClient *mongo.Client
31 | cache *lru.Cache
32 |
33 | shortNames = []string{"bukhari", "abudawud", "nasai", "tirmidhi", "ibnmajah", "muslim"}
34 | simpleEnglishRegex = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`)
35 | )
36 |
37 | // SearchHadith returns a list of hadiths that match the search query.
38 | func SearchHadith(w http.ResponseWriter, r *http.Request) {
39 | ctx := r.Context()
40 |
41 | query := r.URL.Query().Get("search")
42 | if reason, ok := validateQuery(query); !ok {
43 | sendBadRequestResp(w, reason)
44 | return
45 | }
46 | query = sanitizeQuery(query)
47 |
48 | cache, err := getCache()
49 | if err != nil {
50 | sendServerErrorResp(w, err)
51 | return
52 | }
53 |
54 | if val, ok := cache.Get(query); ok {
55 | fmt.Println("cache hit")
56 | sendResp(w, val)
57 | return
58 | }
59 |
60 | colResps, err := searchInMongo(ctx, query)
61 | if err != nil {
62 | sendServerErrorResp(w, err)
63 | return
64 | }
65 | searchResp := colRespToSearchResp(colResps)
66 |
67 | cache.Add(query, searchResp)
68 | fmt.Println("cache added")
69 |
70 | sendResp(w, searchResp)
71 | }
72 |
73 | func searchInMongo(ctx context.Context, query string) ([]CollectionResponse, error) {
74 | result := make([]CollectionResponse, 0)
75 |
76 | words := strings.Fields(query)
77 | if isSpecificHadith(words) {
78 | hadith, err := getHadith(ctx, strings.ToLower(words[0]), words[1])
79 | if err != nil {
80 | return result, err
81 | }
82 |
83 | cache.Add(query, hadith)
84 | result = append(result, hadith...)
85 | return result, nil
86 | }
87 |
88 | hadithGroups, err := compoundSearch(ctx, query)
89 | if err != nil {
90 | return result, err
91 | }
92 |
93 | for _, group := range hadithGroups {
94 | result = append(result, mongoResultToColResp(group))
95 | }
96 |
97 | cache.Add(query, result)
98 | return result, nil
99 | }
100 |
101 | func compoundSearch(ctx context.Context, query string) ([]MongoGroupByResult, error) {
102 | client, err := getMongoClient()
103 | if err != nil {
104 | return nil, err
105 | }
106 | collection := client.Database("hadith").Collection("hadiths")
107 |
108 | pipeline := pipelineQueryGroupByCollection(query)
109 | cursor, err := collection.Aggregate(ctx, pipeline)
110 | if err != nil {
111 | return nil, err
112 | }
113 |
114 | var results []MongoGroupByResult
115 | err = cursor.All(ctx, &results)
116 | if err != nil {
117 | return nil, err
118 | }
119 |
120 | return results, nil
121 | }
122 |
123 | func pipelineQueryGroupByCollection(query string) []bson.M {
124 | return []bson.M{
125 | {
126 | "$search": bson.M{
127 | "compound": bson.M{
128 | // "must": bson.A{
129 | // bson.M{
130 | // "text": bson.M{
131 | // "query": query,
132 | // "path": bson.A{"body_en", "chapter_en"},
133 | // "fuzzy": bson.M{"maxEdits": 1, "maxExpansions": 10},
134 | // },
135 | // },
136 | // },
137 | "should": bson.A{
138 | bson.M{
139 | "phrase": bson.M{
140 | "query": query,
141 | "path": bson.A{"body_en", "chapter_en"},
142 | "slop": 5,
143 | },
144 | },
145 | },
146 | },
147 | "highlight": bson.M{
148 | "path": bson.A{"body_en", "chapter_en"},
149 | },
150 | },
151 | },
152 | {
153 | "$group": bson.M{
154 | "_id": "$collection_id",
155 | "collection": bson.M{
156 | "$first": "$collection",
157 | },
158 | "count": bson.M{
159 | "$sum": 1,
160 | },
161 | "hadiths": bson.M{
162 | "$push": bson.M{
163 | "collection_id": "$collection_id",
164 | "collection": "$collection",
165 | "hadith_no": "$hadith_no",
166 | "book_no": "$book_no",
167 | "book_en": "$book_en",
168 | "chapter_no": "$chapter_no",
169 | "chapter_en": "$chapter_en",
170 | "narrator_en": "$narrator_en",
171 | "body_en": "$body_en",
172 | "book_ref_no": "$book_ref_no",
173 | "hadith_grade": "$hadith_grade",
174 | "score": bson.M{"$meta": "searchScore"},
175 | "highlights": bson.M{"$meta": "searchHighlights"},
176 | },
177 | },
178 | },
179 | },
180 | {
181 | "$limit": pageSize,
182 | },
183 | {
184 | "$sort": bson.M{
185 | "hadiths.score": -1,
186 | },
187 | },
188 | }
189 | }
190 |
191 | func getHadith(ctx context.Context, hadithName, hadithNo string) ([]CollectionResponse, error) {
192 | client, err := getMongoClient()
193 | if err != nil {
194 | return nil, err
195 | }
196 | collection := client.Database("hadith").Collection("hadiths")
197 |
198 | cursor, err := collection.Find(
199 | ctx,
200 | bson.M{
201 | "collection_id": hadithName,
202 | "hadith_no": bson.M{
203 | "$regex": hadithNo + ".*",
204 | "$options": "",
205 | },
206 | },
207 | )
208 | if err != nil {
209 | return nil, err
210 | }
211 |
212 | var results []MongoHadith
213 | err = cursor.All(ctx, &results)
214 | if err != nil {
215 | return nil, err
216 | }
217 |
218 | // usually there is only one hadith
219 | res := make([]CollectionResponse, 0)
220 | if len(results) == 0 {
221 | return res, nil
222 | }
223 |
224 | first := CollectionResponse{}
225 | first.GroupByResult = &GroupByResult{}
226 | first.ID = results[0].CollectionID
227 | first.Collection = results[0].Collection
228 | first.Count = 1
229 | first.Hadiths = make([]HadithResponse, 0)
230 | first.Hadiths = append(first.Hadiths, mongoHadithToResp(results[0]))
231 | res = append(res, first)
232 |
233 | return res, nil
234 | }
235 |
236 | func validateQuery(query string) (string, bool) {
237 | if len(query) < 3 {
238 | return "Query must be at least 3 characters", false
239 | }
240 |
241 | if !simpleEnglishRegex.MatchString(query) {
242 | return "Query must be in simple English and numbers", false
243 | }
244 |
245 | return "", true
246 | }
247 |
248 | func sanitizeQuery(query string) string {
249 | return strings.ToLower(strings.ReplaceAll(query, "'", ""))
250 | }
251 |
252 | func isSpecificHadith(words []string) bool {
253 | if len(words) == 2 &&
254 | contains(shortNames, words[0]) &&
255 | isNumber(words[1]) {
256 | return true
257 | }
258 | return false
259 | }
260 |
261 | func sendResp(w http.ResponseWriter, r interface{}) {
262 | w.Header().Set("Content-Type", "application/json")
263 | w.WriteHeader(http.StatusOK)
264 | json.NewEncoder(w).Encode(r)
265 | }
266 |
267 | func sendBadRequestResp(w http.ResponseWriter, reason string) {
268 | w.WriteHeader(http.StatusBadRequest)
269 | w.Write([]byte(reason))
270 | }
271 |
272 | func sendServerErrorResp(w http.ResponseWriter, err error) {
273 | w.WriteHeader(http.StatusInternalServerError)
274 | w.Write([]byte(err.Error()))
275 | }
276 |
277 | func getMongoClient() (*mongo.Client, error) {
278 | if mongoClient != nil {
279 | return mongoClient, nil
280 | }
281 |
282 | client, err := mongo.NewClient(options.Client().ApplyURI(os.Getenv("MONGO_URI")))
283 | if err != nil {
284 | return nil, err
285 | }
286 |
287 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
288 | defer cancel()
289 |
290 | err = client.Connect(ctx)
291 | if err != nil {
292 | return nil, err
293 | }
294 |
295 | mongoClient = client
296 | return mongoClient, nil
297 | }
298 |
299 | func getCache() (*lru.Cache, error) {
300 | if cache != nil {
301 | return cache, nil
302 | }
303 |
304 | c, err := lru.New(1000)
305 | if err != nil {
306 | return nil, err
307 | }
308 |
309 | cache = c
310 | return cache, nil
311 | }
312 |
313 | func contains(s []string, e string) bool {
314 | for _, a := range s {
315 | if a == e {
316 | return true
317 | }
318 | }
319 | return false
320 | }
321 |
322 | func isNumber(s string) bool {
323 | _, err := strconv.Atoi(s)
324 | return err == nil
325 | }
326 |
327 | type Hadith struct {
328 | CollectionID string `bson:"collection_id" json:"collection_id"`
329 | Collection string `bson:"collection" json:"collection"`
330 | HadithNo string `bson:"hadith_no" json:"hadith_no"`
331 | BookNo string `bson:"book_no" json:"book_no"`
332 | BookEn string `bson:"book_en" json:"book_en"`
333 | ChapterNo string `bson:"chapter_no" json:"chapter_no"`
334 | ChapterEn string `bson:"chapter_en" json:"chapter_en"`
335 | NarratorEn string `bson:"narrator_en" json:"narrator_en"`
336 | BodyEn string `bson:"body_en" json:"body_en"`
337 | BookRefNo string `bson:"book_ref_no" json:"book_ref_no"`
338 | HadithGrade string `bson:"hadith_grade" json:"hadith_grade"`
339 | Score float64 `bson:"score" json:"score"`
340 | }
341 |
342 | type MongoHadith struct {
343 | *Hadith `bson:",inline" json:",inline"`
344 | Highlights []MongoHighlight `bson:"highlights" json:"highlights"`
345 | }
346 |
347 | type HadithResponse struct {
348 | *Hadith `bson:",inline" json:",inline"`
349 | Highlights []string `json:"highlights"`
350 | }
351 |
352 | type MongoHighlight struct {
353 | Path string `bson:"path" json:"path"`
354 | Score float64 `bson:"score" json:"score"`
355 | Texts []MongoText `bson:"texts" json:"texts"`
356 | }
357 |
358 | type MongoText struct {
359 | Type string `bson:"type" json:"type"`
360 | Value string `bson:"value" json:"value"`
361 | }
362 |
363 | type GroupByResult struct {
364 | ID string `bson:"_id" json:"_id"`
365 | Count int `bson:"count" json:"count"`
366 | Collection string `bson:"collection" json:"collection"`
367 | }
368 |
369 | type MongoGroupByResult struct {
370 | *GroupByResult `bson:",inline" json:",inline"`
371 | Hadiths []MongoHadith `bson:"hadiths" json:"hadiths"`
372 | }
373 |
374 | type CollectionResponse struct {
375 | *GroupByResult `bson:",inline" json:",inline"`
376 | Hadiths []HadithResponse `bson:"hadiths" json:"hadiths"`
377 | }
378 |
379 | type SearchResponse struct {
380 | Data []CollectionResponse `json:"data"`
381 | FirstHadithBase64 string `json:"first_hadith_base64"`
382 | }
383 |
384 | func mongoHighlightToSimpleHighlight(highlight MongoHighlight) []string {
385 | var res []string
386 | for _, text := range highlight.Texts {
387 | if text.Type == "hit" {
388 | res = append(res, text.Value)
389 | }
390 | }
391 | return res
392 | }
393 |
394 | func mongoHadithToResp(hadith MongoHadith) HadithResponse {
395 | highlights := NewSet()
396 | for _, highlight := range hadith.Highlights {
397 | highlights.AddMulti(mongoHighlightToSimpleHighlight(highlight))
398 | }
399 | return HadithResponse{hadith.Hadith, highlights.ToSlice()}
400 | }
401 |
402 | func mongoResultToColResp(group MongoGroupByResult) CollectionResponse {
403 | var res []HadithResponse
404 | for i := range group.Hadiths {
405 | hadith := group.Hadiths[i]
406 | res = append(res, mongoHadithToResp(hadith))
407 | }
408 | return CollectionResponse{group.GroupByResult, res}
409 | }
410 |
411 | func colRespToSearchResp(colResps []CollectionResponse) SearchResponse {
412 | var firstHadithBase64 string
413 | if len(colResps) > 0 && len(colResps[0].Hadiths) > 0 {
414 | firstHadithBase64 = hadithToBase64(colResps[0].Hadiths[0])
415 | }
416 | return SearchResponse{colResps, firstHadithBase64}
417 | }
418 |
419 | func hadithToBase64(hadith HadithResponse) string {
420 | bytes, err := json.Marshal(hadith)
421 | if err != nil {
422 | return ""
423 | }
424 | return base64.URLEncoding.EncodeToString(bytes)
425 | }
426 |
427 | type Set map[string]struct{}
428 |
429 | func NewSet() Set {
430 | return make(map[string]struct{})
431 | }
432 |
433 | func (s Set) Add(v string) {
434 | s[v] = struct{}{}
435 | }
436 |
437 | func (s Set) AddMulti(v []string) {
438 | for _, vv := range v {
439 | s.Add(vv)
440 | }
441 | }
442 |
443 | func (s Set) ToSlice() []string {
444 | var result []string
445 | for k := range s {
446 | result = append(result, k)
447 | }
448 | return result
449 | }
450 |
--------------------------------------------------------------------------------