├── 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 | 2 | 5 | 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 | 13 | 14 | 22 | 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>(new Map()); 10 | 11 | // Theme store - 'light', 'dark', or 'system' 12 | export const theme = writable<'light' | 'dark' | 'system'>('system'); 13 | -------------------------------------------------------------------------------- /web/src/lib/svgs/bookmarks.svelte: -------------------------------------------------------------------------------- 1 | 9 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /web/src/lib/HadithList.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | {#each hadiths as hadith} 16 | 17 | {/each} 18 |
19 | -------------------------------------------------------------------------------- /web/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Ask Hadith", 3 | "name": "Ask Hadith", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "/android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png", 19 | "purpose": "maskable" 20 | } 21 | ], 22 | "scope": "/", 23 | "start_url": "/", 24 | "display": "standalone", 25 | "theme_color": "#ffffff", 26 | "background_color": "#ffffff" 27 | } 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ananto 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.buymeacoffee.com/ananto30'] 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module api 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require go.mongodb.org/mongo-driver v1.17.6 8 | 9 | require github.com/montanaflynn/stats v0.7.1 // indirect 10 | 11 | require ( 12 | github.com/golang/snappy v0.0.4 // indirect 13 | github.com/hashicorp/golang-lru v1.0.2 // direct 14 | github.com/klauspost/compress v1.16.7 // indirect 15 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 16 | github.com/xdg-go/scram v1.1.2 // indirect 17 | github.com/xdg-go/stringprep v1.0.4 // indirect 18 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 19 | golang.org/x/crypto v0.45.0 // indirect 20 | golang.org/x/sync v0.18.0 // indirect 21 | golang.org/x/text v0.31.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /web/tests/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('app loads and displays the search box', async ({ page }) => { 4 | await page.goto('http://localhost:5173'); // Adjust the URL if necessary 5 | 6 | // Check if the search box is visible 7 | const searchBox = page.locator('input[placeholder="Search: Qadr, Bukhari 1029, Muslim 1763..."]'); 8 | await expect(searchBox).toBeVisible(); 9 | 10 | // Check if the search button is visible 11 | const searchButton = page.locator('button[aria-label="Search Hadiths"]'); 12 | await expect(searchButton).toBeVisible(); 13 | 14 | // Check if the navigation bar is visible 15 | const navBar = page.locator('nav'); 16 | await expect(navBar).toBeVisible(); 17 | }); 18 | -------------------------------------------------------------------------------- /web/static/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Ask Hadith 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 |
Oops, you appear to be offline, this app requires an internet connection.
20 | 21 | 22 | -------------------------------------------------------------------------------- /web/src/routes/book/+page.ts: -------------------------------------------------------------------------------- 1 | import type { RequestEvent } from '@sveltejs/kit/types/internal'; 2 | 3 | export async function load({ url }: RequestEvent) { 4 | const collectionId = url.searchParams.get('collection_id'); 5 | const book = url.searchParams.get('book'); 6 | const refNumber = url.searchParams.get('ref_no'); 7 | const searchKey = url.searchParams.get('search_key'); 8 | 9 | if (!collectionId || !book || !refNumber) { 10 | return { 11 | hadith: null 12 | }; 13 | } 14 | 15 | const res = await fetch( 16 | `https://ask-hadith.vercel.app/api/book?collection_id=${collectionId}&book=${book}&ref_no=${refNumber}` 17 | ); 18 | 19 | if (res.ok) { 20 | const data = await res.json(); 21 | return { 22 | hadith: data, 23 | searchKey 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /web/src/lib/svgs/download.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /web/src/lib/HadithCard.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 |
14 |
15 | {#if hadith.narrator_en} 16 |
17 | {narrator} 18 |
19 | {/if} 20 |
21 | {body} 22 |
23 |
24 | {hadith.collection} Book: {hadith.book_no}, Hadith: {hadith.book_ref_no} (Hadith No: {hadith.hadith_no ?? 25 | 'Unknown'}) 26 |
27 |
28 |
29 | 30 | 37 | -------------------------------------------------------------------------------- /web/src/lib/HadithFilters.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | {#if $collectionsSorted.length > 0} 8 | {#each $collectionsSorted as col} 9 | 19 | {/each} 20 | {/if} 21 |
22 |
23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Azizul Haque Ananto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/routes/api/og/+server.js: -------------------------------------------------------------------------------- 1 | import satori from 'satori'; 2 | import { Resvg } from '@resvg/resvg-js'; 3 | import NotoSans from '$lib/NotoSans-Regular.ttf'; 4 | import { html as toReactNode } from 'satori-html'; 5 | import HadithCard from '$lib/HadithCard.svelte'; 6 | 7 | const height = 630; 8 | const width = 1200; 9 | 10 | export const GET = async ({ url }) => { 11 | const base64Hadith = url.searchParams.get('hadith') ?? ''; 12 | const jsonHadith = Buffer.from(base64Hadith, 'base64').toString('utf-8'); 13 | const parsed = JSON.parse(jsonHadith); 14 | 15 | const result = HadithCard.render({ hadith: parsed }); 16 | const element = toReactNode(`${result.html}`); 17 | 18 | const svg = await satori(element, { 19 | fonts: [ 20 | { 21 | name: 'Noto Sans', 22 | data: Buffer.from(NotoSans), 23 | style: 'normal' 24 | } 25 | ], 26 | height, 27 | width 28 | }); 29 | 30 | const resvg = new Resvg(svg, { 31 | fitTo: { 32 | mode: 'width', 33 | value: width 34 | } 35 | }); 36 | 37 | const image = resvg.render(); 38 | 39 | return new Response(image.asPng(), { 40 | headers: { 41 | 'content-type': 'image/png' 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - netlify 7 | pull_request: 8 | branches: 9 | - netlify 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | # services: 16 | # mongo: 17 | # image: mongo:latest 18 | # ports: 19 | # - 27017:27017 20 | # options: >- 21 | # --health-cmd="mongo --eval 'db.runCommand({ ping: 1 })'" 22 | # --health-interval=10s 23 | # --health-timeout=5s 24 | # --health-retries=5 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | 30 | - name: Set up Go 31 | uses: actions/setup-go@v2 32 | with: 33 | go-version: 1.21 34 | 35 | - name: Install Go dependencies 36 | run: go mod download 37 | 38 | - name: Run Go tests 39 | run: go test ./api 40 | 41 | - name: Set up Node.js 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: '20' 45 | 46 | - name: Install Node.js dependencies 47 | run: npm install 48 | working-directory: web 49 | 50 | - name: Run end-to-end tests 51 | run: | 52 | npx playwright install 53 | npm run test:e2e 54 | working-directory: web -------------------------------------------------------------------------------- /api/seach_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValidateQuery(t *testing.T) { 8 | tests := []struct { 9 | query string 10 | expectedOk bool 11 | }{ 12 | {"valid query", true}, 13 | {"", false}, 14 | {"ab", false}, 15 | {"invalid_query!", false}, 16 | } 17 | 18 | for _, test := range tests { 19 | _, ok := validateQuery(test.query) 20 | if ok != test.expectedOk { 21 | t.Errorf("validateQuery(%s) = %v; want %v", test.query, ok, test.expectedOk) 22 | } 23 | } 24 | } 25 | 26 | func TestSanitizeQuery(t *testing.T) { 27 | tests := []struct { 28 | query string 29 | expected string 30 | }{ 31 | {"Valid Query", "valid query"}, 32 | {"O'Reilly", "oreilly"}, 33 | } 34 | 35 | for _, test := range tests { 36 | result := sanitizeQuery(test.query) 37 | if result != test.expected { 38 | t.Errorf("sanitizeQuery(%s) = %v; want %v", test.query, result, test.expected) 39 | } 40 | } 41 | } 42 | 43 | func TestIsSpecificHadith(t *testing.T) { 44 | tests := []struct { 45 | words []string 46 | expected bool 47 | }{ 48 | {[]string{"bukhari", "1"}, true}, 49 | {[]string{"bukhari", "invalid"}, false}, 50 | {[]string{"invalid", "1"}, false}, 51 | {[]string{"bukhari"}, false}, 52 | } 53 | 54 | for _, test := range tests { 55 | result := isSpecificHadith(test.words) 56 | if result != test.expected { 57 | t.Errorf("isSpecificHadith(%v) = %v; want %v", test.words, result, test.expected) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "askhadith", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "vite dev --host", 6 | "build": "vite build", 7 | "preview": "vite preview --host", 8 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 9 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 10 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 11 | "format": "prettier --plugin-search-dir . --write .", 12 | "test:e2e": "playwright test" 13 | }, 14 | "devDependencies": { 15 | "@fontsource/fira-mono": "^5.2.7", 16 | "@neoconfetti/svelte": "^2.2.2", 17 | "@playwright/test": "^1.57.0", 18 | "@resvg/resvg-js": "^2.6.2", 19 | "@sveltejs/adapter-auto": "^7.0.0", 20 | "@sveltejs/kit": "^2.49.0", 21 | "@tailwindcss/postcss": "^4.1.18", 22 | "@types/cookie": "^1.0.0", 23 | "@typescript-eslint/eslint-plugin": "^8.48.0", 24 | "@typescript-eslint/parser": "^8.48.0", 25 | "autoprefixer": "^10.4.22", 26 | "eslint": "^9.39.1", 27 | "eslint-config-prettier": "^10.1.8", 28 | "eslint-plugin-svelte": "^3.13.1", 29 | "postcss": "^8.5.6", 30 | "prettier": "^3.7.4", 31 | "prettier-plugin-svelte": "^3.4.0", 32 | "prettier-plugin-tailwindcss": "^0.7.2", 33 | "satori": "^0.18.3", 34 | "satori-html": "^0.3.2", 35 | "svelte": "^5.43.5", 36 | "svelte-check": "^4.3.4", 37 | "tailwindcss": "^4.1.17", 38 | "tslib": "^2.8.1", 39 | "typescript": "^5.9.3", 40 | "url-safe-base64": "^1.3.0", 41 | "vite": "^7.2.7" 42 | }, 43 | "type": "module" 44 | } 45 | -------------------------------------------------------------------------------- /web/src/models.ts: -------------------------------------------------------------------------------- 1 | export type HadithModel = { 2 | body_en: string; 3 | book_en: string; 4 | book_no: string; 5 | book_ref_no: string; 6 | chapter_en: string; 7 | chapter_no: string; 8 | collection: string; 9 | collection_id: string; 10 | hadith_grade: string; 11 | hadith_no: string; 12 | highlights: string[]; 13 | narrator_en: string; 14 | score: number; 15 | base64?: string; 16 | }; 17 | 18 | export type CollectionCounter = { 19 | collection: string; 20 | count: number; 21 | }; 22 | 23 | export type CollectionResponse = { 24 | collection: string; 25 | count: number; 26 | hadiths: HadithModel[]; 27 | }; 28 | 29 | export type SearchResponse = { 30 | data: CollectionResponse[]; 31 | first_hadith_base64: string; 32 | }; 33 | 34 | /** 35 | * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler 36 | * before a user is prompted to "install" a web site to a home screen on mobile. 37 | * 38 | * @deprecated Only supported on Chrome and Android Webview. 39 | */ 40 | export interface BeforeInstallPromptEvent extends Event { 41 | /** 42 | * Returns an array of DOMString items containing the platforms on which the event was dispatched. 43 | * This is provided for user agents that want to present a choice of versions to the user such as, 44 | * for example, "web" or "play" which would allow the user to chose between a web version or 45 | * an Android version. 46 | */ 47 | readonly platforms: Array; 48 | 49 | /** 50 | * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed". 51 | */ 52 | readonly userChoice: Promise<{ 53 | outcome: 'accepted' | 'dismissed'; 54 | platform: string; 55 | }>; 56 | 57 | /** 58 | * Allows a developer to show the install prompt at a time of their own choosing. 59 | * This method returns a Promise. 60 | */ 61 | prompt(): Promise; 62 | } 63 | -------------------------------------------------------------------------------- /web/src/lib/svgs/books.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | 33 | 45 | 56 | 69 | 80 | 91 | 92 | -------------------------------------------------------------------------------- /api/book_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValidateGetHadithByBookRefNoQueryParams(t *testing.T) { 8 | tests := []struct { 9 | collectionID string 10 | book string 11 | refNo string 12 | expectedOk bool 13 | }{ 14 | {"bukhari", "1", "1", true}, 15 | {"", "1", "1", false}, 16 | {"bukhari", "", "1", false}, 17 | {"bukhari", "1", "", false}, 18 | {"invalid", "1", "1", false}, 19 | {"bukhari", "invalid", "1", false}, 20 | {"bukhari", "1", "invalid", false}, 21 | } 22 | 23 | for _, test := range tests { 24 | _, ok := validateGetHadithByBookRefNoQueryParams(test.collectionID, test.book, test.refNo) 25 | if ok != test.expectedOk { 26 | t.Errorf("validateGetHadithByBookRefNoQueryParams(%s, %s, %s) = %v; want %v", test.collectionID, test.book, test.refNo, ok, test.expectedOk) 27 | } 28 | } 29 | } 30 | 31 | func TestValidateCollectionID(t *testing.T) { 32 | tests := []struct { 33 | collectionID string 34 | expectedOk bool 35 | }{ 36 | {"bukhari", true}, 37 | {"", false}, 38 | {"invalid", false}, 39 | } 40 | 41 | for _, test := range tests { 42 | _, ok := validateCollectionID(test.collectionID) 43 | if ok != test.expectedOk { 44 | t.Errorf("validateCollectionID(%s) = %v; want %v", test.collectionID, ok, test.expectedOk) 45 | } 46 | } 47 | } 48 | 49 | func TestValidateBook(t *testing.T) { 50 | tests := []struct { 51 | book string 52 | expectedOk bool 53 | }{ 54 | {"1", true}, 55 | {"", false}, 56 | {"invalid", false}, 57 | } 58 | 59 | for _, test := range tests { 60 | _, ok := validateBook(test.book) 61 | if ok != test.expectedOk { 62 | t.Errorf("validateBook(%s) = %v; want %v", test.book, ok, test.expectedOk) 63 | } 64 | } 65 | } 66 | 67 | func TestValidateRefNo(t *testing.T) { 68 | tests := []struct { 69 | refNo string 70 | expectedOk bool 71 | }{ 72 | {"1", true}, 73 | {"", false}, 74 | {"invalid", false}, 75 | } 76 | 77 | for _, test := range tests { 78 | _, ok := validateRefNo(test.refNo) 79 | if ok != test.expectedOk { 80 | t.Errorf("validateRefNo(%s) = %v; want %v", test.refNo, ok, test.expectedOk) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 |

Ask Hadith

3 |

4 | 5 | askhadith.com 6 | 7 |
8 | 🔎 Hadith search engine powered by Atlas Search

9 |

10 | 11 | License 12 | 13 | 14 | Depfu status 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |

24 |

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 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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 |
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 |
83 |

84 | Looking for related hadiths? 85 |

86 | 90 | View all results for "{$searchKey}" 91 | 92 |
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 |
83 |
84 | 94 | 95 |
96 |
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 | --------------------------------------------------------------------------------