Compare commits
14
Commits
43d4560fbb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
612c6ebde5 | ||
|
|
8ab2de40e0 | ||
|
|
ad1a9364b8 | ||
|
|
82f252f32f | ||
|
|
9cfa7f2dcf | ||
|
|
45100d4a68 | ||
|
|
960e6f713a | ||
|
|
c106d57fe6 | ||
|
|
07a9703a89 | ||
|
|
a53b200c21 | ||
|
|
26af8af8a9 | ||
|
|
f520473ed3 | ||
|
|
92568ad4e2 | ||
|
|
73316d4e62 |
+8
-2
@@ -1,2 +1,8 @@
|
|||||||
/.idea
|
.git
|
||||||
*.sqlite3*
|
.gitignore
|
||||||
|
|
||||||
|
prayertimes.sqlite3
|
||||||
|
prayertimes.sqlite3-wal
|
||||||
|
prayertimes.sqlite3-shm
|
||||||
|
|
||||||
|
**/*.log
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM golang:1.25-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/prayertimes .
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --no-install-recommends -y ca-certificates tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /out/prayertimes /usr/local/bin/prayertimes
|
||||||
|
|
||||||
|
ENV PORT=8000
|
||||||
|
ENV DB_PATH=/data/prayertimes.sqlite3
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/prayertimes"]
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Diyanet Namaz Vaktim API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
OpenAPI spec derived from decompiled Android app usage.
|
||||||
|
Base URL and endpoints reflect what the client calls.
|
||||||
|
servers:
|
||||||
|
- url: https://namazvakti.diyanet.gov.tr/api
|
||||||
|
security:
|
||||||
|
- basicAuth: []
|
||||||
|
tags:
|
||||||
|
- name: PrayerTimes
|
||||||
|
- name: Locations
|
||||||
|
- name: Content
|
||||||
|
- name: Mosques
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
basicAuth:
|
||||||
|
type: http
|
||||||
|
scheme: basic
|
||||||
|
schemas:
|
||||||
|
ResultMessage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
messageType:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
messageContent:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
messageCode:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
PrayerTime:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
imsak:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
gunes:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ogle:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ikindi:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
aksam:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
yatsi:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
gunes_dogus:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
gunes_batis:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
kible_saati:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
hicri_tarih_uzun:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
hicri_tarih_kisa:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
miladi_tarih_uzun:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
miladi_tarih_uzun_Iso8601:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
miladi_tarih_kisa_Iso8601:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
imsak_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
gunes_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ogle_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ikindi_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
aksam_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
yatsi_bg:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ayin_sekli_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
MyLocation:
|
||||||
|
type: object
|
||||||
|
description: Location object returned under "konum".
|
||||||
|
properties:
|
||||||
|
konum_Id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
additionalProperties: true
|
||||||
|
ResultLocation:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
konum:
|
||||||
|
$ref: '#/components/schemas/MyLocation'
|
||||||
|
namazVakti:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PrayerTime'
|
||||||
|
ResultLocationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
$ref: '#/components/schemas/ResultLocation'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
success: true
|
||||||
|
resultMessage:
|
||||||
|
messageType: 1
|
||||||
|
messageContent: Namaz vakitleri başarıyla oluşturuldu
|
||||||
|
messageCode: 200
|
||||||
|
resultObject:
|
||||||
|
konum:
|
||||||
|
konum_Id: 9340
|
||||||
|
timezone: Europe/Istanbul
|
||||||
|
namazVakti:
|
||||||
|
- imsak: "2026-02-19T06:19:00.0000000+03:00"
|
||||||
|
gunes: "2026-02-19T07:43:00.0000000+03:00"
|
||||||
|
ogle: "2026-02-19T13:20:00.0000000+03:00"
|
||||||
|
ikindi: "2026-02-19T16:18:00.0000000+03:00"
|
||||||
|
aksam: "2026-02-19T18:47:00.0000000+03:00"
|
||||||
|
yatsi: "2026-02-19T20:06:00.0000000+03:00"
|
||||||
|
gunes_dogus: "2026-02-19T07:50:00.0000000+03:00"
|
||||||
|
gunes_batis: "2026-02-19T18:40:00.0000000+03:00"
|
||||||
|
kible_saati: "2026-02-19T11:44:00.0000000+03:00"
|
||||||
|
hicri_tarih_uzun: 1 Ramazan 1447
|
||||||
|
hicri_tarih_kisa: 1.9.1447
|
||||||
|
miladi_tarih_uzun: 19 Şubat 2026 Perşembe
|
||||||
|
miladi_tarih_uzun_Iso8601: "2026-02-19T00:00:00.0000000+03:00"
|
||||||
|
miladi_tarih_kisa_Iso8601: 19.02.2026
|
||||||
|
imsak_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_imsak.png?v=6"
|
||||||
|
gunes_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_gunes.png?v=6"
|
||||||
|
ogle_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_ogle.png?v=6"
|
||||||
|
ikindi_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_ikindi.png?v=6"
|
||||||
|
aksam_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_aksam.png?v=6"
|
||||||
|
yatsi_bg: "https://namazvakti.diyanet.gov.tr/api/images/main_yatsi.png?v=6"
|
||||||
|
ayin_sekli_url: "https://namazvakti.diyanet.gov.tr/images/r1.gif"
|
||||||
|
serverTime: "20/02/2026 - 23:03"
|
||||||
|
PrayerTimeVersion:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
PrayerTimeVersionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
$ref: '#/components/schemas/PrayerTimeVersion'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
CityInfo:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
CityInfoResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CityInfo'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
Mosque:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
MosquesResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Mosque'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
SearchLocation:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cityID:
|
||||||
|
type: integer
|
||||||
|
cityNameTR:
|
||||||
|
type: string
|
||||||
|
stateNameTR:
|
||||||
|
type: string
|
||||||
|
countryNameTR:
|
||||||
|
type: string
|
||||||
|
cityNameEN:
|
||||||
|
type: string
|
||||||
|
stateNameEN:
|
||||||
|
type: string
|
||||||
|
countryNameEN:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
SearchLocationsResults:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SearchLocation'
|
||||||
|
SearchLocationsResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
$ref: '#/components/schemas/SearchLocationsResults'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
success: true
|
||||||
|
resultMessage:
|
||||||
|
messageType: 1
|
||||||
|
messageContent: İlçeler başarıyla listelendi
|
||||||
|
messageCode: 200
|
||||||
|
resultObject:
|
||||||
|
results:
|
||||||
|
- cityID: 9340
|
||||||
|
cityNameTR: İZNİK
|
||||||
|
stateNameTR: BURSA
|
||||||
|
countryNameTR: TÜRKİYE
|
||||||
|
cityNameEN: IZNIK
|
||||||
|
stateNameEN: BURSA
|
||||||
|
countryNameEN: TÜRKİYE
|
||||||
|
latitude: 40.429
|
||||||
|
longitude: 29.711
|
||||||
|
serverTime: "20/02/2026 - 23:01"
|
||||||
|
VersesHadithsPrayersArrays:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
VersesHadithsPrayersResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
resultMessage:
|
||||||
|
$ref: '#/components/schemas/ResultMessage'
|
||||||
|
resultObject:
|
||||||
|
$ref: '#/components/schemas/VersesHadithsPrayersArrays'
|
||||||
|
serverTime:
|
||||||
|
type: string
|
||||||
|
paths:
|
||||||
|
/NamazVakti/Aylik:
|
||||||
|
get:
|
||||||
|
tags: [PrayerTimes]
|
||||||
|
summary: Get monthly prayer times
|
||||||
|
description: |
|
||||||
|
Returns monthly prayer times for a location.
|
||||||
|
Provide either ilceId or latitude/longitude.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: ilceId
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: latitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: false
|
||||||
|
- in: query
|
||||||
|
name: longitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: false
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ResultLocationResponse'
|
||||||
|
/Version:
|
||||||
|
get:
|
||||||
|
tags: [PrayerTimes]
|
||||||
|
summary: Get prayer time version
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PrayerTimeVersionResponse'
|
||||||
|
/Search/GetByName:
|
||||||
|
get:
|
||||||
|
tags: [Locations]
|
||||||
|
summary: Search locations by name
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: searchText
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SearchLocationsResponse'
|
||||||
|
/ilce/GetByCoordinat:
|
||||||
|
get:
|
||||||
|
tags: [Locations]
|
||||||
|
summary: Get city/district info by coordinates
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: latitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: true
|
||||||
|
- in: query
|
||||||
|
name: longitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CityInfoResponse'
|
||||||
|
/Cami/GetByCoordinat:
|
||||||
|
get:
|
||||||
|
tags: [Mosques]
|
||||||
|
summary: Get mosques near coordinates
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: latitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: true
|
||||||
|
- in: query
|
||||||
|
name: longitude
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MosquesResponse'
|
||||||
|
/AyetDuaHadis/GetAll:
|
||||||
|
get:
|
||||||
|
tags: [Content]
|
||||||
|
summary: Get verses, hadiths, and prayers
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: dil
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Language code (e.g., tr)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VersesHadithsPrayersResponse'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
prayertimes:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: prayertimes
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
PORT: "8000"
|
||||||
|
DB_PATH: "/data/prayertimes.sqlite3"
|
||||||
|
volumes:
|
||||||
|
- ./prayertimes.sqlite3:/data/prayertimes.sqlite3
|
||||||
|
- ./prayertimes.sqlite3-wal:/data/prayertimes.sqlite3-wal
|
||||||
|
- ./prayertimes.sqlite3-shm:/data/prayertimes.sqlite3-shm
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,62 +1,47 @@
|
|||||||
module prayertimes
|
module prayertimes
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.1
|
github.com/doug-martin/goqu/v9 v9.19.0
|
||||||
github.com/doug-martin/goqu/v9 v9.18.0
|
github.com/gofiber/fiber/v3 v3.0.0
|
||||||
github.com/gofiber/fiber/v2 v2.42.0
|
|
||||||
github.com/imroc/req/v3 v3.57.0
|
github.com/imroc/req/v3 v3.57.0
|
||||||
github.com/rs/zerolog v1.29.0
|
github.com/rs/zerolog v1.29.0
|
||||||
github.com/samber/lo v1.37.0
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
modernc.org/sqlite v1.21.0
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gofiber/schema v1.6.0 // indirect
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0 // indirect
|
||||||
github.com/google/go-querystring v1.2.0 // indirect
|
github.com/google/go-querystring v1.2.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
|
||||||
github.com/icholy/digest v1.1.0 // indirect
|
github.com/icholy/digest v1.1.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/philhofer/fwd v1.1.2 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/samber/lo v1.52.0 // indirect
|
||||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
|
||||||
github.com/tinylib/msgp v1.1.8 // indirect
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.44.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
lukechampine.com/uint128 v1.2.0 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/cc/v3 v3.40.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/libc v1.22.3 // indirect
|
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
|
||||||
modernc.org/memory v1.5.0 // indirect
|
|
||||||
modernc.org/opt v0.1.3 // indirect
|
|
||||||
modernc.org/strutil v1.1.3 // indirect
|
|
||||||
modernc.org/token v1.1.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
|
||||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
|
||||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
|
||||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||||
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=
|
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
|
||||||
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
|
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofiber/fiber/v2 v2.42.0 h1:Fnp7ybWvS+sjNQsFvkhf4G8OhXswvB6Vee8hM/LyS+8=
|
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
||||||
github.com/gofiber/fiber/v2 v2.42.0/go.mod h1:3+SGNjqMh5VQH5Vz2Wdi43zTIV16ktlFd3x3R6O1Zlc=
|
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
|
||||||
|
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||||
|
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
||||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 h1:CqYfpuYIjnlNxM3msdyPRKabhXZWbKjf3Q8BWROFBso=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||||
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
|
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
|
||||||
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
|
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
|
||||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -47,20 +47,16 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|||||||
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
|
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
|
||||||
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
|
||||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -70,119 +66,63 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1
|
|||||||
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
|
||||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||||
github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw=
|
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA=
|
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
|
github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
|
||||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
|
github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||||
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
|
|
||||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
|
|
||||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
|
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||||
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.44.0 h1:R+gLUhldIsfg1HokMuQjdQ5bh9nuXHPIfvkYUu9eR5Q=
|
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||||
github.com/valyala/fasthttp v1.44.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 h1:3Ea9SZLCB0aRIhSEjM+iaGIlzzeDJdpi579El/YIhEE=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
|
||||||
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -191,31 +131,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs=
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
|
|
||||||
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
|
||||||
|
|||||||
+348
-36
@@ -1,18 +1,22 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/favicon"
|
"github.com/gofiber/fiber/v3/middleware/favicon"
|
||||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||||
recover "github.com/gofiber/fiber/v2/middleware/recover"
|
"github.com/gofiber/fiber/v3/middleware/static"
|
||||||
|
|
||||||
|
"prayertimes/pkg/hijricalendar"
|
||||||
"prayertimes/pkg/prayer"
|
"prayertimes/pkg/prayer"
|
||||||
"prayertimes/templates"
|
"prayertimes/templates"
|
||||||
)
|
)
|
||||||
@@ -22,17 +26,32 @@ type httpError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
TimesProvider prayer.TimesProvider
|
PrayerProvider PrayerProvider
|
||||||
LocationTimesProvider prayer.LocationTimesProvider
|
LocationProvider LocationProvider
|
||||||
|
LegacyLocationProvider LegacyLocationProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrayerProvider interface {
|
||||||
|
Get(ctx context.Context, locationID string) (prayer.TimesResult, error)
|
||||||
|
GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationProvider interface {
|
||||||
|
SearchLocations(ctx context.Context, query string) ([]prayer.Location, error)
|
||||||
|
SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegacyLocationProvider interface {
|
||||||
|
SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error)
|
||||||
|
GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(services Services) *fiber.App {
|
func New(services Services) *fiber.App {
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
Immutable: true,
|
Immutable: true,
|
||||||
DisableStartupMessage: false,
|
ReadTimeout: time.Second * 10,
|
||||||
ReadTimeout: time.Second * 10,
|
WriteTimeout: time.Second * 30,
|
||||||
WriteTimeout: time.Second * 30,
|
ErrorHandler: func(ctx fiber.Ctx, err error) error {
|
||||||
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
|
|
||||||
if errors.Is(err, prayer.ErrInvalidLocation) {
|
if errors.Is(err, prayer.ErrInvalidLocation) {
|
||||||
return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{
|
return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
@@ -50,43 +69,336 @@ func New(services Services) *fiber.App {
|
|||||||
cors.New(),
|
cors.New(),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.Get("/healthz", func(ctx *fiber.Ctx) error {
|
app.Get("/healthz", func(ctx fiber.Ctx) error {
|
||||||
return ctx.SendString("OK")
|
return ctx.SendString("OK")
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Get("/api/v1/diyanet/prayertimes", func(ctx *fiber.Ctx) error {
|
app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error {
|
||||||
var times []prayer.Times
|
var params struct {
|
||||||
var err error
|
LocationID string `query:"location_id"`
|
||||||
|
Latitude *float64 `query:"latitude"`
|
||||||
locationID := ctx.Query("location_id")
|
Longitude *float64 `query:"longitude"`
|
||||||
var loc struct {
|
UTC bool `query:"utc"`
|
||||||
Latitude float64 `query:"latitude"`
|
}
|
||||||
Longitude float64 `query:"longitude"`
|
if err := ctx.Bind().Query(¶ms); err != nil {
|
||||||
|
return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if locationID != "" {
|
locationID := strings.TrimSpace(params.LocationID)
|
||||||
times, err = services.TimesProvider.Get(ctx.Context(), locationID)
|
utc := params.UTC
|
||||||
} else if err := ctx.QueryParser(&loc); err == nil {
|
|
||||||
times, err = services.LocationTimesProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
var (
|
||||||
Latitude: loc.Latitude,
|
result prayer.TimesResult
|
||||||
Longitude: loc.Longitude,
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case locationID != "":
|
||||||
|
result, err = services.PrayerProvider.Get(ctx.Context(), locationID)
|
||||||
|
case params.Latitude != nil && params.Longitude != nil:
|
||||||
|
result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
||||||
|
Latitude: *params.Latitude,
|
||||||
|
Longitude: *params.Longitude,
|
||||||
})
|
})
|
||||||
} else {
|
default:
|
||||||
return fmt.Errorf("%w: missing location id or coordinates", fiber.ErrBadRequest)
|
return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to fetch prayer times: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Response().Header.Add(fiber.HeaderCacheControl, "max-age=86400")
|
location := result.Location
|
||||||
|
if location.Latitude != 0 || location.Longitude != 0 {
|
||||||
|
locations, locErr := services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
|
||||||
|
Latitude: location.Latitude,
|
||||||
|
Longitude: location.Longitude,
|
||||||
|
})
|
||||||
|
if locErr != nil {
|
||||||
|
return fmt.Errorf("failed to enrich prayer times location from database: %w", locErr)
|
||||||
|
}
|
||||||
|
if len(locations) > 0 {
|
||||||
|
dbLocation := locations[0]
|
||||||
|
if strings.TrimSpace(dbLocation.Timezone) == "" {
|
||||||
|
dbLocation.Timezone = location.Timezone
|
||||||
|
}
|
||||||
|
location = dbLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ctx.JSON(times)
|
mappedTimes, err := mapPrayerTimesForResponse(result.Times, location, utc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to map prayer times for response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
|
||||||
|
return ctx.JSON(fiber.Map{
|
||||||
|
"location": location,
|
||||||
|
"prayertimes": mappedTimes,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Use("/", filesystem.New(filesystem.Config{
|
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
|
||||||
Root: templates.HttpFS(),
|
var params struct {
|
||||||
}))
|
Text *string `query:"query"`
|
||||||
|
Latitude *float64 `query:"latitude"`
|
||||||
|
Longitude *float64 `query:"longitude"`
|
||||||
|
}
|
||||||
|
if err := ctx.Bind().Query(¶ms); err != nil {
|
||||||
|
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
locations []prayer.Location
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if params.Text != nil {
|
||||||
|
trimmed := strings.TrimSpace(*params.Text)
|
||||||
|
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), trimmed)
|
||||||
|
} else if params.Latitude != nil && params.Longitude != nil {
|
||||||
|
locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
|
||||||
|
Latitude: *params.Latitude,
|
||||||
|
Longitude: *params.Longitude,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to search locations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
|
||||||
|
return ctx.JSON(fiber.Map{
|
||||||
|
"locations": locations,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/api/diyanet/search", func(ctx fiber.Ctx) error {
|
||||||
|
setDeprecationHeaders(ctx, "/api/v1/diyanet/location")
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
Query string `query:"q"`
|
||||||
|
}
|
||||||
|
if err := ctx.Bind().Query(¶ms); err != nil {
|
||||||
|
return fmt.Errorf("failed to bind legacy location search query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Query = strings.TrimSpace(params.Query)
|
||||||
|
if params.Query == "" {
|
||||||
|
return fmt.Errorf("missing query parameter q: %w", fiber.ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
locations, err := services.LegacyLocationProvider.SearchLocations(ctx.Context(), params.Query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to search legacy diyanet locations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]fiber.Map, 0, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
resp = append(resp, fiber.Map{
|
||||||
|
"id": location.ID,
|
||||||
|
"country": location.Country,
|
||||||
|
"city": location.City,
|
||||||
|
"region": location.Region,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
|
||||||
|
return ctx.JSON(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/api/diyanet/countries", func(ctx fiber.Ctx) error {
|
||||||
|
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/api/diyanet/countries/:country/cities", func(ctx fiber.Ctx) error {
|
||||||
|
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/api/diyanet/locations", func(ctx fiber.Ctx) error {
|
||||||
|
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/api/diyanet/prayertimes", func(ctx fiber.Ctx) error {
|
||||||
|
setDeprecationHeaders(ctx, "/api/v1/diyanet/prayertimes")
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
LocationID int `query:"location_id"`
|
||||||
|
}
|
||||||
|
if err := ctx.Bind().Query(¶ms); err != nil {
|
||||||
|
return fmt.Errorf("failed to bind legacy prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||||
|
}
|
||||||
|
if params.LocationID <= 0 {
|
||||||
|
return fmt.Errorf("missing query parameter location_id: %w", fiber.ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyLocation, err := services.LegacyLocationProvider.GetLocationByID(ctx.Context(), params.LocationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve location by location_id: %w", errors.Join(fiber.ErrNotFound, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
||||||
|
Latitude: legacyLocation.Latitude,
|
||||||
|
Longitude: legacyLocation.Longitude,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch prayer times for location_id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
location := result.Location
|
||||||
|
location.ID = legacyLocation.ID
|
||||||
|
if strings.TrimSpace(legacyLocation.Timezone) != "" {
|
||||||
|
location.Timezone = legacyLocation.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped, err := mapPrayerTimesForLegacyResponse(result.Times, location)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to map legacy prayer times response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
|
||||||
|
return ctx.JSON(mapped)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Use("/", static.New("", static.Config{FS: templates.FS()}))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type prayerTimesResponse struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
DateHijri string `json:"date_hijri"`
|
||||||
|
Fajr string `json:"fajr"`
|
||||||
|
Sunrise string `json:"sunrise"`
|
||||||
|
Dhuhr string `json:"dhuhr"`
|
||||||
|
Asr string `json:"asr"`
|
||||||
|
Sunset string `json:"sunset,omitempty"`
|
||||||
|
Maghrib string `json:"maghrib"`
|
||||||
|
Isha string `json:"isha"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPrayerTimesForResponse(times []prayer.Times, location prayer.Location, utc bool) ([]any, error) {
|
||||||
|
if utc {
|
||||||
|
result := make([]any, 0, len(times))
|
||||||
|
for _, item := range times {
|
||||||
|
dateHijri := item.DateHijri
|
||||||
|
if dateHijri == "" {
|
||||||
|
dateHijri = hijricalendar.ToISODate(item.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, prayer.Times{
|
||||||
|
Date: item.Date.UTC(),
|
||||||
|
DateHijri: dateHijri,
|
||||||
|
Fajr: item.Fajr.UTC(),
|
||||||
|
Sunrise: item.Sunrise.UTC(),
|
||||||
|
Dhuhr: item.Dhuhr.UTC(),
|
||||||
|
Asr: item.Asr.UTC(),
|
||||||
|
Sunset: item.Sunset.UTC(),
|
||||||
|
Maghrib: item.Maghrib.UTC(),
|
||||||
|
Isha: item.Isha.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tz := time.UTC
|
||||||
|
if strings.TrimSpace(location.Timezone) != "" {
|
||||||
|
loadedTZ, err := loadTimezone(location.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load location timezone: %w", err)
|
||||||
|
}
|
||||||
|
tz = loadedTZ
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]any, 0, len(times))
|
||||||
|
for _, item := range times {
|
||||||
|
dateHijri := item.DateHijri
|
||||||
|
if dateHijri == "" {
|
||||||
|
dateHijri = hijricalendar.ToISODate(item.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, prayerTimesResponse{
|
||||||
|
Date: item.Date.In(tz).Format(time.DateOnly),
|
||||||
|
DateHijri: dateHijri,
|
||||||
|
Fajr: formatHHMM(item.Fajr, tz),
|
||||||
|
Sunrise: formatHHMM(item.Sunrise, tz),
|
||||||
|
Dhuhr: formatHHMM(item.Dhuhr, tz),
|
||||||
|
Asr: formatHHMM(item.Asr, tz),
|
||||||
|
Sunset: formatHHMM(item.Sunset, tz),
|
||||||
|
Maghrib: formatHHMM(item.Maghrib, tz),
|
||||||
|
Isha: formatHHMM(item.Isha, tz),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHHMM(value time.Time, tz *time.Location) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.In(tz).Format("15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTimezone(name string) (*time.Location, error) {
|
||||||
|
if strings.HasPrefix(name, "UTC") {
|
||||||
|
offsetText := strings.TrimPrefix(name, "UTC")
|
||||||
|
if offsetText == "" {
|
||||||
|
return time.UTC, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetHours, err := strconv.Atoi(offsetText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse utc offset timezone: %w", err)
|
||||||
|
}
|
||||||
|
return time.FixedZone(name, offsetHours*3600), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load iana timezone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPrayerTimesForLegacyResponse(times []prayer.Times, location prayer.Location) ([]fiber.Map, error) {
|
||||||
|
tz := time.UTC
|
||||||
|
if strings.TrimSpace(location.Timezone) != "" {
|
||||||
|
loadedTZ, err := loadTimezone(location.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load location timezone: %w", err)
|
||||||
|
}
|
||||||
|
tz = loadedTZ
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]fiber.Map, 0, len(times))
|
||||||
|
for _, item := range times {
|
||||||
|
out = append(out, fiber.Map{
|
||||||
|
"date": item.Date.In(tz).Format(time.RFC3339),
|
||||||
|
"fajr": formatHHMM(item.Fajr, tz),
|
||||||
|
"sun": formatHHMM(item.Sunrise, tz),
|
||||||
|
"dhuhr": formatHHMM(item.Dhuhr, tz),
|
||||||
|
"asr": formatHHMM(item.Asr, tz),
|
||||||
|
"maghrib": formatHHMM(item.Maghrib, tz),
|
||||||
|
"isha": formatHHMM(item.Isha, tz),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondDeprecatedEndpoint(ctx fiber.Ctx, replacementPath string) error {
|
||||||
|
setDeprecationHeaders(ctx, replacementPath)
|
||||||
|
|
||||||
|
return ctx.Status(http.StatusGone).JSON(httpError{
|
||||||
|
Message: "This endpoint has been removed. Use " + replacementPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDeprecationHeaders(ctx fiber.Ctx, replacementPath string) {
|
||||||
|
ctx.Response().Header.Set("Deprecation", "true")
|
||||||
|
ctx.Response().Header.Set("Sunset", "Wed, 30 Sep 2026 23:59:59 GMT")
|
||||||
|
ctx.Response().Header.Set("Link", "<"+replacementPath+">; rel=\"successor-version\"")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/doug-martin/goqu/v9"
|
|
||||||
goqusqlite3 "github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
goqu.RegisterDialect("sqlite3", func() *goqu.SQLDialectOptions {
|
|
||||||
do := goqusqlite3.DialectOptions()
|
|
||||||
do.SupportsReturn = true
|
|
||||||
return do
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSqliteDB(filename string) (*goqu.Database, error) {
|
|
||||||
pragmas := url.Values{
|
|
||||||
"_pragma": {
|
|
||||||
"journal_mode(WAL)",
|
|
||||||
"foreign_keys(1)",
|
|
||||||
"synchronous(NORMAL)",
|
|
||||||
"busy_timeout(5000)",
|
|
||||||
},
|
|
||||||
}.Encode()
|
|
||||||
dsn := fmt.Sprintf("%s?%s", filename, pragmas)
|
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return goqu.New("sqlite3", conn), nil
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package net
|
package net
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
"github.com/imroc/req/v3"
|
"github.com/imroc/req/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,30 +10,3 @@ var ReqClient = req.NewClient().
|
|||||||
DisableAutoReadResponse().
|
DisableAutoReadResponse().
|
||||||
EnableInsecureSkipVerify().
|
EnableInsecureSkipVerify().
|
||||||
SetUserAgent(userAgent)
|
SetUserAgent(userAgent)
|
||||||
|
|
||||||
func GetHTML(ctx context.Context, url string) (string, error) {
|
|
||||||
res, err := ReqClient.R().SetContext(ctx).SetRetryCount(3).Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get url: %w", err)
|
|
||||||
}
|
|
||||||
html, err := res.ToString()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
return html, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetParsed(ctx context.Context, url string) (*goquery.Document, error) {
|
|
||||||
html, err := GetHTML(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get html: %w", err)
|
|
||||||
}
|
|
||||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse html: %w", err)
|
|
||||||
}
|
|
||||||
return doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Fetcher struct {
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,52 +4,59 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"prayertimes/internal/api"
|
"prayertimes/internal/api"
|
||||||
"prayertimes/internal/database"
|
|
||||||
"prayertimes/internal/net"
|
"prayertimes/internal/net"
|
||||||
"prayertimes/pkg/dbtimesprovider"
|
"prayertimes/pkg/citydb"
|
||||||
"prayertimes/pkg/diyanet"
|
|
||||||
"prayertimes/pkg/diyanetapi"
|
"prayertimes/pkg/diyanetapi"
|
||||||
|
"prayertimes/pkg/diyanetcalc"
|
||||||
|
"prayertimes/pkg/diyanetcitydb"
|
||||||
|
"prayertimes/pkg/diyanetprovider"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := getDefaultEnv("PORT", "8000")
|
if err := run(); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to run app")
|
||||||
services, shutdown, err := newServices()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to init services")
|
|
||||||
}
|
|
||||||
defer shutdown()
|
|
||||||
app := api.New(services)
|
|
||||||
|
|
||||||
err = app.Listen(":" + port)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("exit with an error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServices() (api.Services, func(), error) {
|
func run() error {
|
||||||
diyanetAPIProvider := diyanetapi.New(net.ReqClient)
|
port := getDefaultEnv("PORT", "8000")
|
||||||
diyanetProvider := diyanet.New(net.GetParsed)
|
dbPath := strings.TrimSpace(getDefaultEnv("DB_PATH", "./prayertimes.sqlite3"))
|
||||||
|
if dbPath == "" {
|
||||||
|
return fmt.Errorf("DB_PATH is not set")
|
||||||
|
}
|
||||||
|
|
||||||
db, err := database.NewSqliteDB(getDefaultEnv("DATABASE_URL", "app.sqlite3"))
|
conn, err := newDB(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return api.Services{}, nil, fmt.Errorf("failed to init db: %w", err)
|
return fmt.Errorf("failed to initialize database connection: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
locationProvider := citydb.NewWithConn(conn)
|
||||||
|
legacyLocationProvider := diyanetcitydb.New(conn)
|
||||||
|
|
||||||
|
diyanetAPIProvider := diyanetapi.New(net.ReqClient)
|
||||||
|
diyanetCalcProvider := diyanetcalc.New()
|
||||||
|
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
|
||||||
|
|
||||||
|
services := api.Services{
|
||||||
|
PrayerProvider: prayerProvider,
|
||||||
|
LocationProvider: locationProvider,
|
||||||
|
LegacyLocationProvider: legacyLocationProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := dbtimesprovider.Migrate(db.Db.(*sql.DB)); err != nil {
|
app := api.New(services)
|
||||||
return api.Services{}, nil, fmt.Errorf("failed to migrate database: %w", err)
|
if err := app.Listen(":" + port); err != nil {
|
||||||
|
return fmt.Errorf("failed to listen on http port: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.Services{
|
return nil
|
||||||
TimesProvider: dbtimesprovider.New(db, diyanetProvider),
|
|
||||||
LocationTimesProvider: diyanetAPIProvider,
|
|
||||||
}, func() {
|
|
||||||
defer db.Db.(*sql.DB).Close()
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultEnv(name string, defaultValue string) string {
|
func getDefaultEnv(name string, defaultValue string) string {
|
||||||
@@ -59,3 +66,17 @@ func getDefaultEnv(name string, defaultValue string) string {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newDB(path string) (*sql.DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package citydb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/doug-martin/goqu/v9"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"prayertimes/pkg/prayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
db *goqu.Database
|
||||||
|
own bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *goqu.Database) Provider {
|
||||||
|
return Provider{db: db, own: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithConn(conn *sql.DB) Provider {
|
||||||
|
return Provider{db: goqu.New("sqlite3", conn), own: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (Provider, error) {
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return Provider{}, fmt.Errorf("failed to open cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return Provider{}, fmt.Errorf("failed to connect to cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Provider{db: goqu.New("sqlite3", conn), own: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) Close() error {
|
||||||
|
if !p.own {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.db.Db.(*sql.DB).Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type locationRow struct {
|
||||||
|
ID int `db:"geoname_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
ASCIIName string `db:"ascii_name"`
|
||||||
|
AlternateNames string `db:"alternate_names"`
|
||||||
|
CountryCode string `db:"country_code"`
|
||||||
|
Timezone string `db:"timezone"`
|
||||||
|
Latitude float64 `db:"latitude"`
|
||||||
|
Longitude float64 `db:"longitude"`
|
||||||
|
DistanceSq float64 `db:"distance_sq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return []prayer.Location{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := "%" + query + "%"
|
||||||
|
|
||||||
|
q := p.db.
|
||||||
|
From("cities").
|
||||||
|
Select(
|
||||||
|
goqu.I("geoname_id"),
|
||||||
|
goqu.I("name"),
|
||||||
|
goqu.I("ascii_name"),
|
||||||
|
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
|
||||||
|
goqu.I("country_code"),
|
||||||
|
goqu.I("timezone"),
|
||||||
|
goqu.I("latitude"),
|
||||||
|
goqu.I("longitude"),
|
||||||
|
goqu.V(0).As("distance_sq"),
|
||||||
|
).
|
||||||
|
Where(
|
||||||
|
goqu.Or(
|
||||||
|
goqu.L("name LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
goqu.L("ascii_name LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
goqu.L("alternate_names LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Order(goqu.I("population").Desc(), goqu.I("name").Asc()).
|
||||||
|
Limit(50)
|
||||||
|
|
||||||
|
var rows []locationRow
|
||||||
|
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query locations from cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
locations := make([]prayer.Location, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
locations = append(locations, prayer.Location{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
ASCIIName: row.ASCIIName,
|
||||||
|
AlternateNames: row.AlternateNames,
|
||||||
|
CountryCode: row.CountryCode,
|
||||||
|
Timezone: row.Timezone,
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error) {
|
||||||
|
q := p.db.
|
||||||
|
From("cities").
|
||||||
|
Select(
|
||||||
|
goqu.I("geoname_id"),
|
||||||
|
goqu.I("name"),
|
||||||
|
goqu.I("ascii_name"),
|
||||||
|
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
|
||||||
|
goqu.I("country_code"),
|
||||||
|
goqu.I("timezone"),
|
||||||
|
goqu.I("latitude"),
|
||||||
|
goqu.I("longitude"),
|
||||||
|
goqu.L(
|
||||||
|
"((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?))",
|
||||||
|
coords.Latitude,
|
||||||
|
coords.Latitude,
|
||||||
|
coords.Longitude,
|
||||||
|
coords.Longitude,
|
||||||
|
).As("distance_sq"),
|
||||||
|
).
|
||||||
|
Order(
|
||||||
|
goqu.I("distance_sq").Asc(),
|
||||||
|
goqu.I("population").Desc(),
|
||||||
|
).
|
||||||
|
Limit(50)
|
||||||
|
|
||||||
|
var rows []locationRow
|
||||||
|
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query locations by coordinates from cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
locations := make([]prayer.Location, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
locations = append(locations, prayer.Location{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
ASCIIName: row.ASCIIName,
|
||||||
|
AlternateNames: row.AlternateNames,
|
||||||
|
CountryCode: row.CountryCode,
|
||||||
|
Timezone: row.Timezone,
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package dbtimesprovider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/doug-martin/goqu/v9"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"prayertimes/pkg/prayer"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed schema.sql
|
|
||||||
var schema string
|
|
||||||
|
|
||||||
//go:embed locations.jsonl
|
|
||||||
var locationsJSON string
|
|
||||||
|
|
||||||
type Provider struct {
|
|
||||||
db *goqu.Database
|
|
||||||
provider prayer.TimesProvider
|
|
||||||
clockFunc func() time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *goqu.Database, provider prayer.TimesProvider) Provider {
|
|
||||||
return Provider{
|
|
||||||
provider: provider,
|
|
||||||
clockFunc: time.Now,
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Provider) Name() string {
|
|
||||||
return "db:" + p.provider.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Provider) Get(ctx context.Context, location string) ([]prayer.Times, error) {
|
|
||||||
times, err := p.loadTimes(ctx, location)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load prayer times from db: %w", err)
|
|
||||||
}
|
|
||||||
if len(times) > 0 {
|
|
||||||
return times, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
times, err = p.provider.Get(ctx, location)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get prayer times: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(times) > 0 {
|
|
||||||
if err := p.saveTimes(ctx, location, times); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save times to db: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return times, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Migrate(con *sql.DB) error {
|
|
||||||
db := goqu.New("sqlite3", con)
|
|
||||||
if _, err := db.Exec(schema); err != nil {
|
|
||||||
return fmt.Errorf("failed to migrate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, _ := db.From("locations").Count()
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
ID int `json:"id" db:"id"`
|
|
||||||
Country string `json:"country" db:"country"`
|
|
||||||
Region string `json:"region" db:"region"`
|
|
||||||
City string `json:"city" db:"city"`
|
|
||||||
}
|
|
||||||
s := bufio.NewScanner(strings.NewReader(locationsJSON))
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to begin tx: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.Wrap(func() error {
|
|
||||||
for s.Scan() {
|
|
||||||
var e entry
|
|
||||||
if err := json.Unmarshal(s.Bytes(), &e); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse as json: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := tx.Insert("locations").
|
|
||||||
OnConflict(goqu.DoNothing()).
|
|
||||||
Rows(e)
|
|
||||||
|
|
||||||
if _, err := q.Executor().Exec(); err != nil {
|
|
||||||
return fmt.Errorf("failed to insert location: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type prayerTimesRow struct {
|
|
||||||
ProviderID int64 `db:"provider_id"`
|
|
||||||
LocationID string `db:"location_id"`
|
|
||||||
Date string `db:"date"`
|
|
||||||
Fajr string `db:"fajr"`
|
|
||||||
Sunrise string `db:"sunrise"`
|
|
||||||
Dhuhr string `db:"dhuhr"`
|
|
||||||
Asr string `db:"asr"`
|
|
||||||
Maghrib string `db:"maghrib"`
|
|
||||||
Isha string `db:"isha"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r prayerTimesRow) toDomain() prayer.Times {
|
|
||||||
return prayer.Times{
|
|
||||||
Date: r.Date,
|
|
||||||
Fajr: r.Fajr,
|
|
||||||
Sunrise: r.Sunrise,
|
|
||||||
Dhuhr: r.Dhuhr,
|
|
||||||
Asr: r.Asr,
|
|
||||||
Maghrib: r.Maghrib,
|
|
||||||
Isha: r.Isha,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Provider) saveTimes(ctx context.Context, locationID string, times []prayer.Times) error {
|
|
||||||
providerID, err := p.saveProvider(ctx, p.provider.Name())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := lo.Map(times, func(item prayer.Times, _ int) prayerTimesRow {
|
|
||||||
return prayerTimesRow{
|
|
||||||
ProviderID: providerID,
|
|
||||||
LocationID: locationID,
|
|
||||||
Date: item.Date,
|
|
||||||
Fajr: item.Fajr,
|
|
||||||
Sunrise: item.Sunrise,
|
|
||||||
Dhuhr: item.Dhuhr,
|
|
||||||
Asr: item.Asr,
|
|
||||||
Maghrib: item.Maghrib,
|
|
||||||
Isha: item.Isha,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
q := p.db.
|
|
||||||
Insert("prayer_times").
|
|
||||||
OnConflict(goqu.DoNothing()).
|
|
||||||
Rows(rows)
|
|
||||||
|
|
||||||
if _, err := q.Executor().ExecContext(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to save times: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Provider) loadTimes(ctx context.Context, locationID string) ([]prayer.Times, error) {
|
|
||||||
now := p.clockFunc()
|
|
||||||
today := now.UTC().Truncate(time.Hour * 24)
|
|
||||||
|
|
||||||
q := p.db.
|
|
||||||
From(goqu.T("prayer_times").As("pt")).
|
|
||||||
Join(goqu.T("providers").As("p"), goqu.On(goqu.I("p.id").Eq(goqu.I("pt.provider_id")))).
|
|
||||||
Where(
|
|
||||||
goqu.I("p.name").Eq(p.provider.Name()),
|
|
||||||
goqu.I("pt.location_id").Eq(locationID),
|
|
||||||
goqu.I("pt.date").Gte(today.Format(time.DateOnly)),
|
|
||||||
).
|
|
||||||
Limit(100)
|
|
||||||
|
|
||||||
var rows []prayerTimesRow
|
|
||||||
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan times: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lo.Map(rows, func(row prayerTimesRow, _ int) prayer.Times {
|
|
||||||
return row.toDomain()
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Provider) saveProvider(ctx context.Context, name string) (int64, error) {
|
|
||||||
q := p.db.Insert("providers").
|
|
||||||
OnConflict(goqu.DoUpdate("name", goqu.Record{"name": name})).
|
|
||||||
Rows(goqu.Record{"name": name}).
|
|
||||||
Returning("id")
|
|
||||||
var id int64
|
|
||||||
_, err := q.Executor().ScanValContext(ctx, &id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to insert provider: %w", err)
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package dbtimesprovider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/doug-martin/goqu/v9"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
|
|
||||||
"prayertimes/internal/database"
|
|
||||||
"prayertimes/pkg/prayer"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testDB(t *testing.T) *goqu.Database {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := database.NewSqliteDB(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = Migrate(db.Db.(*sql.DB))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = db.Insert("locations").Rows(goqu.Record{"id": 1}).Executor().Exec()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
db.Db.(*sql.DB).Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockProvider func() ([]prayer.Times, error)
|
|
||||||
|
|
||||||
func (m mockProvider) Get(ctx context.Context, location string) ([]prayer.Times, error) { return m() }
|
|
||||||
|
|
||||||
func (m mockProvider) Name() string { return "mock" }
|
|
||||||
|
|
||||||
func TestProvider_Get(t *testing.T) {
|
|
||||||
then := time.Date(2023, 3, 5, 0, 0, 0, 0, time.UTC)
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setupDB func(t *testing.T, db *goqu.Database)
|
|
||||||
provider prayer.TimesProvider
|
|
||||||
clock time.Time
|
|
||||||
assertRes func(t *testing.T, db *goqu.Database, times []prayer.Times, err error)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "provider succeeds, empty db",
|
|
||||||
provider: mockProvider(func() ([]prayer.Times, error) {
|
|
||||||
return []prayer.Times{
|
|
||||||
{Date: "2023-03-04"},
|
|
||||||
{Date: "2023-03-05"},
|
|
||||||
}, nil
|
|
||||||
}),
|
|
||||||
clock: then,
|
|
||||||
assertRes: func(t *testing.T, db *goqu.Database, times []prayer.Times, err error) {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, times, 2)
|
|
||||||
|
|
||||||
cnt, err := db.From("prayer_times").Count()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(2), cnt)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "provider fails, empty db",
|
|
||||||
provider: mockProvider(func() ([]prayer.Times, error) {
|
|
||||||
return nil, fmt.Errorf("no")
|
|
||||||
}),
|
|
||||||
clock: then,
|
|
||||||
assertRes: func(t *testing.T, db *goqu.Database, times []prayer.Times, err error) {
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Empty(t, times)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "provider fails, populated db",
|
|
||||||
setupDB: func(t *testing.T, db *goqu.Database) {
|
|
||||||
_, err := db.Insert("providers").Rows(goqu.Record{"id": 1, "name": "mock"}).Executor().Exec()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = db.Insert("prayer_times").Rows(
|
|
||||||
prayerTimesRow{ProviderID: 1, LocationID: "1", Date: "2023-03-04", Fajr: "01:00", Sunrise: "02:00", Dhuhr: "03:00", Asr: "04:00", Maghrib: "05:00", Isha: "06:00"},
|
|
||||||
prayerTimesRow{ProviderID: 1, LocationID: "1", Date: "2023-03-05", Fajr: "01:00", Sunrise: "02:00", Dhuhr: "03:00", Asr: "04:00", Maghrib: "05:00", Isha: "06:00"},
|
|
||||||
prayerTimesRow{ProviderID: 1, LocationID: "1", Date: "2023-03-06", Fajr: "01:00", Sunrise: "02:00", Dhuhr: "03:00", Asr: "04:00", Maghrib: "05:00", Isha: "06:00"},
|
|
||||||
).Executor().Exec()
|
|
||||||
require.NoError(t, err)
|
|
||||||
},
|
|
||||||
provider: mockProvider(func() ([]prayer.Times, error) {
|
|
||||||
return nil, fmt.Errorf("no")
|
|
||||||
}),
|
|
||||||
clock: then,
|
|
||||||
assertRes: func(t *testing.T, db *goqu.Database, times []prayer.Times, err error) {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, times, 2)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
tt := tt
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
p := Provider{
|
|
||||||
db: db,
|
|
||||||
provider: tt.provider,
|
|
||||||
clockFunc: func() time.Time { return tt.clock },
|
|
||||||
}
|
|
||||||
if tt.setupDB != nil {
|
|
||||||
tt.setupDB(t, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual, err := p.Get(context.Background(), "1")
|
|
||||||
tt.assertRes(t, db, actual, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS locations
|
|
||||||
(
|
|
||||||
id text PRIMARY KEY,
|
|
||||||
country text,
|
|
||||||
city text,
|
|
||||||
region text
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS providers
|
|
||||||
(
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS prayer_times
|
|
||||||
(
|
|
||||||
provider_id integer NOT NULL REFERENCES providers (id),
|
|
||||||
location_id text NOT NULL REFERENCES locations (id),
|
|
||||||
date datetime NOT NULL,
|
|
||||||
fajr text NOT NULL,
|
|
||||||
sunrise text NOT NULL,
|
|
||||||
dhuhr text NOT NULL,
|
|
||||||
asr text NOT NULL,
|
|
||||||
maghrib text NOT NULL,
|
|
||||||
isha text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS prayer_times__provider__location ON prayer_times (provider_id, location_id, date);
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package diyanet
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
|
|
||||||
"prayertimes/pkg/prayer"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Fetcher interface {
|
|
||||||
FetchParsed(ctx context.Context, url string) (*goquery.Document, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Provider struct {
|
|
||||||
FetcherFunc func(ctx context.Context, url string) (*goquery.Document, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(fetcherFunc func(ctx context.Context, url string) (*goquery.Document, error)) *Provider {
|
|
||||||
return &Provider{FetcherFunc: fetcherFunc}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reNumeric = regexp.MustCompile(`\d+`)
|
|
||||||
|
|
||||||
func validateLocation(location string) error {
|
|
||||||
if !reNumeric.MatchString(location) {
|
|
||||||
return fmt.Errorf("invalid location id")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Provider) Get(ctx context.Context, location string) ([]prayer.Times, error) {
|
|
||||||
if err := validateLocation(location); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", prayer.ErrInvalidLocation, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
u := fmt.Sprintf("https://namazvakitleri.diyanet.gov.tr/en-US/%s", location)
|
|
||||||
doc, err := d.FetcherFunc(ctx, u)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch location %q: %w", location, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var times []prayer.Times
|
|
||||||
|
|
||||||
doc.Find("#tab-1 .vakit-table tbody tr").Each(func(_ int, el *goquery.Selection) {
|
|
||||||
date := el.Find("td:first-of-type").Text()
|
|
||||||
parsedDate, err := time.Parse("02.01.2006", date)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
row := prayer.Times{
|
|
||||||
Date: parsedDate.Format(time.DateOnly),
|
|
||||||
Fajr: el.Find("td:nth-of-type(3)").Text(),
|
|
||||||
Sunrise: el.Find("td:nth-of-type(4)").Text(),
|
|
||||||
Dhuhr: el.Find("td:nth-of-type(5)").Text(),
|
|
||||||
Asr: el.Find("td:nth-of-type(6)").Text(),
|
|
||||||
Maghrib: el.Find("td:nth-of-type(7)").Text(),
|
|
||||||
Isha: el.Find("td:nth-of-type(8)").Text(),
|
|
||||||
}
|
|
||||||
times = append(times, row)
|
|
||||||
})
|
|
||||||
|
|
||||||
return times, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Provider) Name() string {
|
|
||||||
return "diyanetweb"
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package diyanet
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"prayertimes/internal/net"
|
|
||||||
"prayertimes/pkg/prayer"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mockFetcher string
|
|
||||||
|
|
||||||
func (f mockFetcher) Fetch(_ context.Context, _ string) (*goquery.Document, error) {
|
|
||||||
return goquery.NewDocumentFromReader(strings.NewReader(string(f)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockHtml = `
|
|
||||||
<div id='tab-1'>
|
|
||||||
<div class='table-responsive'>
|
|
||||||
<table class='table vakit-table'>
|
|
||||||
<caption>Monthly Prayer Times for Some Location</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Gregorian Calendar Date</th>
|
|
||||||
<th>Hijri Date</th>
|
|
||||||
<th>Fajr</th>
|
|
||||||
<th>Sun</th>
|
|
||||||
<th>Dhuhr</th>
|
|
||||||
<th>Asr</th>
|
|
||||||
<th>Maghrib</th>
|
|
||||||
<th>Isha</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>04.03.2023</td>
|
|
||||||
<td>...</td>
|
|
||||||
<td>05:48</td>
|
|
||||||
<td>07:11</td>
|
|
||||||
<td>13:05</td>
|
|
||||||
<td>16:15</td>
|
|
||||||
<td>18:49</td>
|
|
||||||
<td>20:06</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>05.03.2023</td>
|
|
||||||
<td>...</td>
|
|
||||||
<td>05:46</td>
|
|
||||||
<td>07:09</td>
|
|
||||||
<td>13:04</td>
|
|
||||||
<td>16:15</td>
|
|
||||||
<td>18:50</td>
|
|
||||||
<td>20:07</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
func TestDiyanet_Get(t *testing.T) {
|
|
||||||
t.Run("validates location", func(t *testing.T) {
|
|
||||||
d := Provider{}
|
|
||||||
_, err := d.Get(context.Background(), " not numeric ")
|
|
||||||
assert.ErrorIs(t, err, prayer.ErrInvalidLocation)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extracts prayer times", func(t *testing.T) {
|
|
||||||
d := Provider{
|
|
||||||
FetcherFunc: mockFetcher(mockHtml).Fetch,
|
|
||||||
}
|
|
||||||
actual, err := d.Get(context.Background(), "1234")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
expected := []prayer.Times{
|
|
||||||
{
|
|
||||||
Date: "2023-03-04",
|
|
||||||
Fajr: "05:48",
|
|
||||||
Sunrise: "07:11",
|
|
||||||
Dhuhr: "13:05",
|
|
||||||
Asr: "16:15",
|
|
||||||
Maghrib: "18:49",
|
|
||||||
Isha: "20:06",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Date: "2023-03-05",
|
|
||||||
Fajr: "05:46",
|
|
||||||
Sunrise: "07:09",
|
|
||||||
Dhuhr: "13:04",
|
|
||||||
Asr: "16:15",
|
|
||||||
Maghrib: "18:50",
|
|
||||||
Isha: "20:07",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.Equal(t, expected, actual)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("real endpoint", func(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip()
|
|
||||||
}
|
|
||||||
|
|
||||||
d := Provider{
|
|
||||||
FetcherFunc: net.GetParsed,
|
|
||||||
}
|
|
||||||
times, err := d.Get(context.Background(), "11104")
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
|
|
||||||
}
|
|
||||||
if len(times) == 0 {
|
|
||||||
t.Skip("skipping live endpoint test because upstream returned no times")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Greater(t, len(times), 0)
|
|
||||||
assert.NotZero(t, times[0].Date)
|
|
||||||
|
|
||||||
for _, it := range times {
|
|
||||||
t.Logf("%+v", it)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+83
-30
@@ -2,11 +2,13 @@ package diyanetapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/imroc/req/v3"
|
"github.com/imroc/req/v3"
|
||||||
|
|
||||||
|
"prayertimes/pkg/hijricalendar"
|
||||||
"prayertimes/pkg/prayer"
|
"prayertimes/pkg/prayer"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,34 +22,72 @@ func New(c *req.Client) Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) {
|
func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
|
||||||
|
locationID, err := d.getLocationIDByCoords(ctx, coords)
|
||||||
|
if err != nil {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to resolve location by coordinates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := d.Get(ctx, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by coordinates: %w", err)
|
||||||
|
}
|
||||||
|
result.Location.Latitude = coords.Latitude
|
||||||
|
result.Location.Longitude = coords.Longitude
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coordinates) (string, error) {
|
||||||
res, err := d.http.NewRequest().
|
res, err := d.http.NewRequest().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"latitude": fmt.Sprintf("%f", coords.Latitude),
|
"latitude": fmt.Sprintf("%f", coords.Latitude),
|
||||||
"longitude": fmt.Sprintf("%f", coords.Longitude),
|
"longitude": fmt.Sprintf("%f", coords.Longitude),
|
||||||
}).
|
}).
|
||||||
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik")
|
Get("https://namazvakti.diyanet.gov.tr/api/ilce/GetByCoordinat")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get prayer times by coords: %w", err)
|
return "", fmt.Errorf("failed to get location by coords: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.parseResponse(res)
|
var response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ResultObject []struct {
|
||||||
|
ID int `json:"cityID"`
|
||||||
|
} `json:"resultObject"`
|
||||||
|
}
|
||||||
|
if err := res.Unmarshal(&response); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unmarshal city response: %w", err)
|
||||||
|
}
|
||||||
|
if !response.Success {
|
||||||
|
return "", fmt.Errorf("failed to get location by coordinates from upstream: %w", errors.New(res.String()))
|
||||||
|
}
|
||||||
|
if len(response.ResultObject) == 0 {
|
||||||
|
return "", fmt.Errorf("failed to resolve location by coordinates: %w", errors.New("empty location result"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", response.ResultObject[0].ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) {
|
func (d Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
|
||||||
res, err := d.http.NewRequest().
|
res, err := d.http.NewRequest().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
SetQueryParam("ilceId", locationID).
|
SetQueryParam("ilceId", locationID).
|
||||||
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik")
|
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get prayer times by location id: %w", err)
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.parseResponse(res)
|
result, err := d.parseResponse(res)
|
||||||
|
if err != nil {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to parse prayer times response: %w", err)
|
||||||
|
}
|
||||||
|
result.Location.ID = parseIntOrZero(locationID)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
|
func (d Provider) parseResponse(res *req.Response) (prayer.TimesResult, error) {
|
||||||
var response struct {
|
var response struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ResultObject struct {
|
ResultObject struct {
|
||||||
@@ -56,48 +96,61 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
} `json:"konum"`
|
} `json:"konum"`
|
||||||
PrayerTimes []struct {
|
PrayerTimes []struct {
|
||||||
Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
|
Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
|
||||||
Fajr time.Time `json:"imsak"`
|
DateIslamic string `json:"hicri_tarih_uzun"`
|
||||||
Sunrise time.Time `json:"gunes"`
|
Fajr time.Time `json:"imsak"`
|
||||||
Dhuhr time.Time `json:"ogle"`
|
Sunrise time.Time `json:"gunes"`
|
||||||
Asr time.Time `json:"ikindi"`
|
Dhuhr time.Time `json:"ogle"`
|
||||||
Maghrib time.Time `json:"aksam"`
|
Asr time.Time `json:"ikindi"`
|
||||||
Isha time.Time `json:"yatsi"`
|
Sunset time.Time `json:"gunes_batis"`
|
||||||
|
Maghrib time.Time `json:"aksam"`
|
||||||
|
Isha time.Time `json:"yatsi"`
|
||||||
} `json:"namazVakti"`
|
} `json:"namazVakti"`
|
||||||
} `json:"resultObject"`
|
} `json:"resultObject"`
|
||||||
}
|
}
|
||||||
if err := res.Unmarshal(&response); err != nil {
|
if err := res.Unmarshal(&response); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal as json: %w", err)
|
return prayer.TimesResult{}, fmt.Errorf("failed to unmarshal as json: %w", err)
|
||||||
}
|
}
|
||||||
if !response.Success {
|
if !response.Success {
|
||||||
return nil, fmt.Errorf("received error: %s", res.String())
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from upstream: %w", errors.New(res.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(response.ResultObject.PrayerTimes) == 0 {
|
if len(response.ResultObject.PrayerTimes) == 0 {
|
||||||
return nil, nil
|
return prayer.TimesResult{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var times []prayer.Times
|
var times []prayer.Times
|
||||||
today := time.Now().UTC().Truncate(time.Hour * 24)
|
today := time.Now().UTC().Truncate(time.Hour * 24)
|
||||||
for _, pt := range response.ResultObject.PrayerTimes {
|
for _, pt := range response.ResultObject.PrayerTimes {
|
||||||
then := prayer.Date(pt.Date).Time()
|
then := pt.Date.UTC().Truncate(time.Hour * 24)
|
||||||
if then.Before(today) {
|
if then.Before(today) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
times = append(times, prayer.Times{
|
times = append(times, prayer.Times{
|
||||||
Date: pt.Date.Format(time.DateOnly),
|
Date: pt.Date.UTC(),
|
||||||
Fajr: pt.Fajr.Format(time.TimeOnly),
|
DateHijri: hijricalendar.ToISODate(pt.Date.UTC()),
|
||||||
Sunrise: pt.Sunrise.Format(time.TimeOnly),
|
Fajr: pt.Fajr.UTC(),
|
||||||
Dhuhr: pt.Dhuhr.Format(time.TimeOnly),
|
Sunrise: pt.Sunrise.UTC(),
|
||||||
Asr: pt.Asr.Format(time.TimeOnly),
|
Dhuhr: pt.Dhuhr.UTC(),
|
||||||
Maghrib: pt.Maghrib.Format(time.TimeOnly),
|
Asr: pt.Asr.UTC(),
|
||||||
Isha: pt.Isha.Format(time.TimeOnly),
|
Sunset: pt.Sunset.UTC(),
|
||||||
|
Maghrib: pt.Maghrib.UTC(),
|
||||||
|
Isha: pt.Isha.UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return times, nil
|
|
||||||
|
return prayer.TimesResult{
|
||||||
|
Location: prayer.Location{
|
||||||
|
ID: response.ResultObject.Location.ID,
|
||||||
|
Timezone: response.ResultObject.Location.Timezone,
|
||||||
|
},
|
||||||
|
Times: times,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Provider) Name() string {
|
func parseIntOrZero(value string) int {
|
||||||
return "diyanetapi"
|
var out int
|
||||||
|
_, _ = fmt.Sscanf(value, "%d", &out)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,23 +15,37 @@ func TestDiyanetAPI_GetByCoords(t *testing.T) {
|
|||||||
t.Run("by coords", func(t *testing.T) {
|
t.Run("by coords", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
times, err := p.GetByCoords(context.Background(), prayer.Coordinates{
|
result, err := p.GetByCoords(context.Background(), prayer.Coordinates{
|
||||||
Latitude: 52.5100846,
|
Latitude: 52.5100846,
|
||||||
Longitude: 13.4518284,
|
Longitude: 13.4518284,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Times) == 0 {
|
||||||
|
t.Skip("skipping live endpoint test because upstream returned no times")
|
||||||
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, times)
|
assert.NotEmpty(t, result.Times)
|
||||||
t.Logf("%#+v", times[0])
|
t.Logf("location=%+v first=%+v", result.Location, result.Times[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("by id", func(t *testing.T) {
|
t.Run("by id", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
times, err := p.Get(context.Background(), "11104")
|
result, err := p.Get(context.Background(), "11104")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Times) == 0 {
|
||||||
|
t.Skip("skipping live endpoint test because upstream returned no times")
|
||||||
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, times)
|
assert.NotEmpty(t, result.Times)
|
||||||
for _, time := range times {
|
for _, item := range result.Times {
|
||||||
t.Log(time)
|
t.Log(item)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package diyanetcalc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"prayertimes/pkg/prayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotSupported = errors.New("not supported in calculation provider")
|
||||||
|
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
func New() Provider {
|
||||||
|
return Provider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Provider) SearchLocations(_ context.Context, _ string) ([]prayer.Location, error) {
|
||||||
|
return nil, fmt.Errorf("failed to search locations: %w", errNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Provider) Get(_ context.Context, _ string) (prayer.TimesResult, error) {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
|
||||||
|
seq := CalculatePrayerTimes(CalculateParams{
|
||||||
|
Latitude: coords.Latitude,
|
||||||
|
Longitude: coords.Longitude,
|
||||||
|
StartingDay: time.Now().UTC().Truncate(24 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
times := make([]prayer.Times, 0, daysToGenerate)
|
||||||
|
for item := range seq {
|
||||||
|
times = append(times, prayer.Times{
|
||||||
|
Date: item.Date,
|
||||||
|
DateHijri: item.DateHijri,
|
||||||
|
Fajr: item.Fajr,
|
||||||
|
Sunrise: item.Sunrise,
|
||||||
|
Dhuhr: item.Dhuhr,
|
||||||
|
Asr: item.Asr,
|
||||||
|
Sunset: item.Sunset,
|
||||||
|
Maghrib: item.Maghrib,
|
||||||
|
Isha: item.Isha,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(times) == daysToGenerate {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prayer.TimesResult{
|
||||||
|
Location: prayer.Location{
|
||||||
|
Latitude: coords.Latitude,
|
||||||
|
Longitude: coords.Longitude,
|
||||||
|
Timezone: fmt.Sprintf("UTC%+d", int(math.Round(coords.Longitude/degPerHour))),
|
||||||
|
},
|
||||||
|
Times: times,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package diyanetcalc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"prayertimes/pkg/hijricalendar"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
daysToGenerate = 30
|
||||||
|
degPerHour = 15.0
|
||||||
|
j2000 = 2451545.0 // Julian Date of J2000.0 epoch (Jan 1.5, 2000)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diyanet angular criteria (post-1983 reform)
|
||||||
|
//
|
||||||
|
// imsakAngle: Sun 18° below horizon = start of astronomical twilight (Fajr).
|
||||||
|
// Pre-1983 used -19° + temkin buffer; now -18° with zero temkin.
|
||||||
|
const imsakAngle = -18.0
|
||||||
|
|
||||||
|
// ishaAngle: Sun 17° below horizon = shafaq al-ahmar (red twilight) gone.
|
||||||
|
const ishaAngle = -17.0
|
||||||
|
|
||||||
|
// sunAngle: combined refraction (~0.567°) + solar semi-diameter (~0.267°)
|
||||||
|
// correction applied at sunrise/sunset. Equivalent to 50 arcmin.
|
||||||
|
const sunAngle = -0.833
|
||||||
|
|
||||||
|
// Temkin - precautionary time buffers (minutes, post-1983 standardized values)
|
||||||
|
//
|
||||||
|
// Ensures a single published time remains valid across the full geographical
|
||||||
|
// extent of a city. Pre-1983 values were 10-20 min; the reform moderated them.
|
||||||
|
//
|
||||||
|
// Imsak: 0 - no buffer; avoids starting Fajr too early / fast too late
|
||||||
|
// Sunrise: -7 - subtracted, ensuring the Sun has fully cleared the horizon
|
||||||
|
// Dhuhr: +5 - Sun has clearly begun its descent
|
||||||
|
// Asr: +4 - accounts for local elevation and horizon obstacles
|
||||||
|
// Maghrib: +7 - Sun has completely set before breaking fast
|
||||||
|
// Isha: 0 - no buffer needed at this twilight stage
|
||||||
|
var temkin = struct{ Imsak, Sunrise, Dhuhr, Asr, Maghrib, Isha float64 }{
|
||||||
|
Imsak: 0, Sunrise: -7, Dhuhr: 5, Asr: 4, Maghrib: 7, Isha: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalculateParams struct {
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
StartingDay time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Times struct {
|
||||||
|
Date time.Time
|
||||||
|
DateHijri string
|
||||||
|
Fajr time.Time
|
||||||
|
Sunrise time.Time
|
||||||
|
Dhuhr time.Time
|
||||||
|
Asr time.Time
|
||||||
|
Sunset time.Time
|
||||||
|
Maghrib time.Time
|
||||||
|
Isha time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func CalculatePrayerTimes(params CalculateParams) iter.Seq[Times] {
|
||||||
|
startingDay := params.StartingDay.UTC().Truncate(24 * time.Hour)
|
||||||
|
if startingDay.IsZero() {
|
||||||
|
startingDay = time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(yield func(Times) bool) {
|
||||||
|
for day := startingDay; ; day = day.AddDate(0, 0, 1) {
|
||||||
|
c := prayerTimes(params.Latitude, params.Longitude, day)
|
||||||
|
if !yield(Times{
|
||||||
|
Date: day,
|
||||||
|
DateHijri: hijricalendar.ToISODate(day),
|
||||||
|
Fajr: lo.FromPtr(c.Imsak),
|
||||||
|
Sunrise: lo.FromPtr(c.Sunrise),
|
||||||
|
Dhuhr: lo.FromPtr(c.Dhuhr),
|
||||||
|
Asr: lo.FromPtr(c.Asr),
|
||||||
|
Sunset: lo.FromPtr(c.Sunset),
|
||||||
|
Maghrib: lo.FromPtr(c.Maghrib),
|
||||||
|
Isha: lo.FromPtr(c.Isha),
|
||||||
|
}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// julianDay converts a calendar date to a Julian Day Number.
|
||||||
|
//
|
||||||
|
// JDN is a continuous day count from Jan 1, 4713 BC, used in astronomy to
|
||||||
|
// avoid calendar-system ambiguities. January/February are treated as months
|
||||||
|
// 13/14 of the prior year. The Gregorian correction b = 2 - a + a/4 accounts
|
||||||
|
// for century-year leap-day rules introduced in 1582.
|
||||||
|
func julianDay(d time.Time) float64 {
|
||||||
|
y, m, day := d.Date()
|
||||||
|
if m <= 2 {
|
||||||
|
y--
|
||||||
|
m += 12
|
||||||
|
}
|
||||||
|
a := y / 100
|
||||||
|
b := 2 - a + a/4
|
||||||
|
return math.Floor(365.25*float64(y+4716)) +
|
||||||
|
math.Floor(30.6001*float64(m+1)) +
|
||||||
|
float64(day+b) - 1524.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// sunParams computes solar declination and equation of time via Meeus Ch.25
|
||||||
|
// (~0.0003° accuracy - ~30x better than the USNO 6-term approximation).
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - delta: solar declination in degrees (Sun's angular distance north/south
|
||||||
|
// of the celestial equator; drives seasonal day length and noon altitude).
|
||||||
|
// - eot: equation of time in minutes (difference between apparent solar time
|
||||||
|
// and mean solar time; caused by orbital eccentricity + axial tilt; +-16 min).
|
||||||
|
//
|
||||||
|
// Source: Jean Meeus, "Astronomical Algorithms" 2nd ed., Chapter 25.
|
||||||
|
//
|
||||||
|
// Variables (degrees unless noted):
|
||||||
|
//
|
||||||
|
// T - Julian centuries since J2000.0
|
||||||
|
// L0 - geometric mean longitude of the Sun (Meeus eq. 25.2)
|
||||||
|
// M - mean anomaly (Meeus eq. 25.3)
|
||||||
|
// e - orbital eccentricity (Meeus eq. 25.4)
|
||||||
|
// C - equation of centre: true - mean anomaly (Meeus eq. 25.4)
|
||||||
|
// omega - Moon's ascending node longitude, used for nutation (Meeus eq. 25.11)
|
||||||
|
// lam - apparent longitude: true lon + nutation - aberration (Meeus eq. 25.9)
|
||||||
|
// eps - true obliquity of the ecliptic incl. nutation (Meeus eq. 22.2 / 25.8)
|
||||||
|
// EoT - Spencer/Meeus y-series (Meeus p.185), accurate to ~0.5 s
|
||||||
|
func sunParams(jd float64) (delta, eot float64) {
|
||||||
|
T := (jd - j2000) / 36525.0
|
||||||
|
|
||||||
|
L0 := math.Mod(280.46646+T*(36000.76983+T*0.0003032), 360)
|
||||||
|
M := math.Mod(357.52911+T*(35999.05029-T*0.0001537), 360)
|
||||||
|
Mr := rad(M)
|
||||||
|
e := 0.016708634 - T*(0.000042037+T*0.0000001267)
|
||||||
|
|
||||||
|
// Equation of centre: corrects uniform circular -> true elliptical motion.
|
||||||
|
C := (1.914602-T*(0.004817+T*0.000014))*math.Sin(Mr) +
|
||||||
|
(0.019993-T*0.000101)*math.Sin(2*Mr) +
|
||||||
|
0.000289*math.Sin(3*Mr)
|
||||||
|
|
||||||
|
omega := 125.04 - 1934.136*T // Moon's ascending node: drives nutation
|
||||||
|
|
||||||
|
// Apparent longitude: add nutation, subtract aberration (-0.00569°).
|
||||||
|
lam := rad(L0 + C - 0.00569 - 0.00478*math.Sin(rad(omega)))
|
||||||
|
|
||||||
|
// True obliquity: Laskar (1986) mean obliquity + nutation in obliquity.
|
||||||
|
eps0 := 84381.448 - T*(46.8150+T*(0.00059-T*0.001813)) // arcseconds
|
||||||
|
eps := rad(eps0/3600 + 0.00256*math.Cos(rad(omega)))
|
||||||
|
|
||||||
|
delta = deg(math.Asin(math.Sin(eps) * math.Sin(lam)))
|
||||||
|
|
||||||
|
// EoT via y-series (Spencer 1971 / Meeus p.185).
|
||||||
|
// y = tan²(e/2); multiply degrees by 4 to get minutes (1° = 4 min).
|
||||||
|
y, L0r := math.Pow(math.Tan(eps/2), 2), rad(L0)
|
||||||
|
eot = deg(y*math.Sin(2*L0r)-
|
||||||
|
2*e*math.Sin(Mr)+
|
||||||
|
4*e*y*math.Sin(Mr)*math.Cos(2*L0r)-
|
||||||
|
0.5*y*y*math.Sin(4*L0r)-
|
||||||
|
1.25*e*e*math.Sin(2*Mr)) * 4
|
||||||
|
|
||||||
|
return delta, eot
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourAngle solves for the hour angle H (hours) at which the Sun reaches
|
||||||
|
// the given altitude, using the spherical law of cosines:
|
||||||
|
//
|
||||||
|
// cos H = (sin a - sin phi*sin delta) / (cos phi*cos delta)
|
||||||
|
//
|
||||||
|
// Returns (0, false) when |cos H| > 1 - the Sun never reaches that altitude
|
||||||
|
// (midnight sun / polar night). Diyanet resolves these via the Takdir method.
|
||||||
|
func hourAngle(altDeg, lat, delta float64) (float64, bool) {
|
||||||
|
cosH := (math.Sin(rad(altDeg)) - math.Sin(rad(lat))*math.Sin(rad(delta))) /
|
||||||
|
(math.Cos(rad(lat)) * math.Cos(rad(delta)))
|
||||||
|
if math.Abs(cosH) > 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return deg(math.Acos(cosH)) / degPerHour, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// asrAltitude returns the solar altitude at which Asr-i Avval begins.
|
||||||
|
//
|
||||||
|
// Diyanet (majority school): Asr starts when shadow length = object height +
|
||||||
|
// its shortest noon shadow (fey-i zeval). Shadow factor = 1 (Hanafi uses 2).
|
||||||
|
//
|
||||||
|
// cot a = 1 + tan|phi - delta| -> a = atan(1 / (1 + tan|phi - delta|))
|
||||||
|
func asrAltitude(lat, delta float64) float64 {
|
||||||
|
return deg(math.Atan(1 / (1 + math.Tan(rad(math.Abs(lat-delta))))))
|
||||||
|
}
|
||||||
|
|
||||||
|
type computedTimes struct {
|
||||||
|
Imsak, Sunrise, Dhuhr, Asr, Sunset, Maghrib, Isha *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// prayerTimes computes all Diyanet prayer times for the given date, returned
|
||||||
|
// as UTC-aware values. Solar noon is the central reference:
|
||||||
|
//
|
||||||
|
// T_noon(UTC) = 12 - lambda/15 - EoT/60
|
||||||
|
//
|
||||||
|
// Morning times (Imsak, Sunrise) = noon - H + temkin
|
||||||
|
// Afternoon/evening times = noon + H + temkin
|
||||||
|
func prayerTimes(lat, lon float64, d time.Time) computedTimes {
|
||||||
|
delta, eot := sunParams(julianDay(d))
|
||||||
|
noon := 12 - lon/degPerHour - eot/60
|
||||||
|
|
||||||
|
// offset converts a noon-relative hour angle to a UTC *time.Time,
|
||||||
|
// applying the given temkin (minutes). Returns nil for polar night/day.
|
||||||
|
offset := func(h float64, ok bool, sign int, tk float64) *time.Time {
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := utcTime(d, noon+float64(sign)*h+tk/60)
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
hSun, okSun := hourAngle(sunAngle, lat, delta)
|
||||||
|
hAsr, okAsr := hourAngle(asrAltitude(lat, delta), lat, delta)
|
||||||
|
hImsak, okImsak := hourAngle(imsakAngle, lat, delta)
|
||||||
|
hIsha, okIsha := hourAngle(ishaAngle, lat, delta)
|
||||||
|
|
||||||
|
tDhuhr := utcTime(d, noon+temkin.Dhuhr/60)
|
||||||
|
|
||||||
|
return computedTimes{
|
||||||
|
Imsak: offset(hImsak, okImsak, -1, temkin.Imsak),
|
||||||
|
Sunrise: offset(hSun, okSun, -1, temkin.Sunrise),
|
||||||
|
Dhuhr: &tDhuhr,
|
||||||
|
Asr: offset(hAsr, okAsr, +1, temkin.Asr),
|
||||||
|
Sunset: offset(hSun, okSun, +1, 0), // geometric sunset, no temkin
|
||||||
|
Maghrib: offset(hSun, okSun, +1, temkin.Maghrib),
|
||||||
|
Isha: offset(hIsha, okIsha, +1, temkin.Isha),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// utcTime converts decimal hours (e.g. 10.5 = 10:30) to a UTC time.Time.
|
||||||
|
func utcTime(d time.Time, hours float64) time.Time {
|
||||||
|
base := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
return base.Add(time.Duration(hours * float64(time.Hour)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rad(d float64) float64 { return d * math.Pi / 180 }
|
||||||
|
func deg(r float64) float64 { return r * 180 / math.Pi }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package diyanetcitydb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/doug-martin/goqu/v9"
|
||||||
|
|
||||||
|
"prayertimes/pkg/prayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrLocationNotFound = errors.New("location not found")
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
db *goqu.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(conn *sql.DB) Provider {
|
||||||
|
return Provider{db: goqu.New("sqlite3", conn)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type locationRow struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Country string `db:"country"`
|
||||||
|
City string `db:"city"`
|
||||||
|
Region string `db:"region"`
|
||||||
|
Latitude float64 `db:"latitude"`
|
||||||
|
Longitude float64 `db:"longitude"`
|
||||||
|
Timezone string `db:"timezone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error) {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := "%" + query + "%"
|
||||||
|
|
||||||
|
q := p.db.
|
||||||
|
From("diyanet_cities").
|
||||||
|
Select(
|
||||||
|
goqu.I("id"),
|
||||||
|
goqu.I("country"),
|
||||||
|
goqu.I("city"),
|
||||||
|
goqu.COALESCE(goqu.I("region"), "").As("region"),
|
||||||
|
goqu.I("latitude"),
|
||||||
|
goqu.I("longitude"),
|
||||||
|
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
|
||||||
|
).
|
||||||
|
Where(
|
||||||
|
goqu.Or(
|
||||||
|
goqu.L("country LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
goqu.L("city LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
goqu.L("COALESCE(region, '') LIKE ? COLLATE NOCASE", pattern),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Order(
|
||||||
|
goqu.I("country").Asc(),
|
||||||
|
goqu.I("city").Asc(),
|
||||||
|
goqu.I("region").Asc(),
|
||||||
|
goqu.I("id").Asc(),
|
||||||
|
).
|
||||||
|
Limit(100)
|
||||||
|
|
||||||
|
rows := make([]locationRow, 0)
|
||||||
|
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query diyanet locations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
locations := make([]prayer.LegacyLocation, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
locations = append(locations, prayer.LegacyLocation{
|
||||||
|
ID: row.ID,
|
||||||
|
Country: row.Country,
|
||||||
|
City: row.City,
|
||||||
|
Region: row.Region,
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
Timezone: row.Timezone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, error) {
|
||||||
|
q := p.db.
|
||||||
|
From("diyanet_cities").
|
||||||
|
Select(
|
||||||
|
goqu.I("id"),
|
||||||
|
goqu.I("country"),
|
||||||
|
goqu.I("city"),
|
||||||
|
goqu.COALESCE(goqu.I("region"), "").As("region"),
|
||||||
|
goqu.I("latitude"),
|
||||||
|
goqu.I("longitude"),
|
||||||
|
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
|
||||||
|
).
|
||||||
|
Where(goqu.I("id").Eq(id)).
|
||||||
|
Limit(1)
|
||||||
|
|
||||||
|
var rows []locationRow
|
||||||
|
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
||||||
|
return prayer.LegacyLocation{}, fmt.Errorf("failed to query location by id: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return prayer.LegacyLocation{}, ErrLocationNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
row := rows[0]
|
||||||
|
location := prayer.LegacyLocation{
|
||||||
|
ID: row.ID,
|
||||||
|
Country: row.Country,
|
||||||
|
City: row.City,
|
||||||
|
Region: row.Region,
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
Timezone: row.Timezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package diyanetprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"prayertimes/pkg/prayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIProvider interface {
|
||||||
|
Get(ctx context.Context, locationID string) (prayer.TimesResult, error)
|
||||||
|
GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FallbackProvider interface {
|
||||||
|
GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
api APIProvider
|
||||||
|
fallback FallbackProvider
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(api APIProvider, fallback FallbackProvider) Provider {
|
||||||
|
return Provider{
|
||||||
|
api: api,
|
||||||
|
fallback: fallback,
|
||||||
|
timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrEmptyTimes = errors.New("diyanet did not return any prayer times")
|
||||||
|
|
||||||
|
func (p Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
|
||||||
|
ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := p.api.Get(ctxWithTimeout, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from api provider: %w", err)
|
||||||
|
}
|
||||||
|
if len(result.Times) == 0 {
|
||||||
|
return prayer.TimesResult{}, ErrEmptyTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
|
||||||
|
ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := p.api.GetByCoords(ctxWithTimeout, coords)
|
||||||
|
if err == nil && len(result.Times) > 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = p.fallback.GetByCoords(ctx, coords)
|
||||||
|
if err != nil {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from fallback provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Times) == 0 {
|
||||||
|
return prayer.TimesResult{}, fmt.Errorf("fallback provider did not return any prayer times")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package hijricalendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JulianToIslamic converts a Julian calendar date to the Islamic (Hijri) calendar.
|
||||||
|
// Algorithm from Jean Meeus, "Astronomical Algorithms", 2nd ed.
|
||||||
|
func JulianToIslamic(year, month, day int) (iYear, iMonth, iDay int) {
|
||||||
|
jdn := julianToJDN(year, month, day)
|
||||||
|
return jdnToIslamic(jdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GregorianToIslamic converts a Gregorian calendar date to the Islamic (Hijri)
|
||||||
|
// calendar.
|
||||||
|
func GregorianToIslamic(year, month, day int) (iYear, iMonth, iDay int) {
|
||||||
|
jdn := gregorianToJDN(year, month, day)
|
||||||
|
return jdnToIslamic(jdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// julianToJDN returns the Julian Day Number for a date on the Julian calendar.
|
||||||
|
func julianToJDN(year, month, day int) int {
|
||||||
|
y, m := year, month
|
||||||
|
if m <= 2 {
|
||||||
|
y--
|
||||||
|
m += 12
|
||||||
|
}
|
||||||
|
return int(math.Floor(365.25*float64(y+4716))) +
|
||||||
|
int(math.Floor(30.6001*float64(m+1))) +
|
||||||
|
day - 1524
|
||||||
|
}
|
||||||
|
|
||||||
|
func gregorianToJDN(year, month, day int) int {
|
||||||
|
a := (14 - month) / 12
|
||||||
|
y := year + 4800 - a
|
||||||
|
m := month + 12*a - 3
|
||||||
|
|
||||||
|
return day + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
|
||||||
|
}
|
||||||
|
|
||||||
|
// jdnToIslamic converts a Julian Day Number to an Islamic (Hijri) calendar date.
|
||||||
|
// Source: F. de Moya, as cited in Meeus Ch. 9.
|
||||||
|
func jdnToIslamic(jd int) (year, month, day int) {
|
||||||
|
l := jd - 1948440 + 10632
|
||||||
|
n := (l - 1) / 10631
|
||||||
|
l = l - 10631*n + 354
|
||||||
|
j := (10985-l)/5316*(50*l/17719) + (l/5670)*(43*l/15238)
|
||||||
|
l = l - (30-j)/15*(17719*j/50) - (j/16)*(15238*j/43) + 29
|
||||||
|
year = 30*n + j - 30
|
||||||
|
month = (24 * l) / 709
|
||||||
|
day = l - (709*month)/24
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToISODate(t time.Time) string {
|
||||||
|
year, month, day := t.UTC().Date()
|
||||||
|
hYear, hMonth, hDay := GregorianToIslamic(year, int(month), day)
|
||||||
|
return fmt.Sprintf("%04d-%02d-%02d", hYear, hMonth, hDay)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package hijricalendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToISODate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
date time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ramadan 3 1447",
|
||||||
|
date: time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC),
|
||||||
|
want: "1447-09-03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ramadan 1 1447",
|
||||||
|
date: time.Date(2026, 2, 18, 0, 0, 0, 0, time.UTC),
|
||||||
|
want: "1447-09-01",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ToISODate(tt.date)
|
||||||
|
require.NotEmpty(t, got)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGregorianToIslamicRange(t *testing.T) {
|
||||||
|
y, m, d := GregorianToIslamic(2026, 2, 20)
|
||||||
|
assert.Positive(t, y)
|
||||||
|
assert.GreaterOrEqual(t, m, 1)
|
||||||
|
assert.LessOrEqual(t, m, 12)
|
||||||
|
assert.GreaterOrEqual(t, d, 1)
|
||||||
|
assert.LessOrEqual(t, d, 30)
|
||||||
|
}
|
||||||
+33
-67
@@ -1,85 +1,51 @@
|
|||||||
package prayer
|
package prayer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidLocation = errors.New("invalid location")
|
var ErrInvalidLocation = errors.New("invalid location")
|
||||||
|
|
||||||
type TimesProvider interface {
|
|
||||||
Get(ctx context.Context, location string) ([]Times, error)
|
|
||||||
Name() string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Coordinates struct {
|
type Coordinates struct {
|
||||||
Latitude float64
|
Latitude float64
|
||||||
Longitude float64
|
Longitude float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationTimesProvider interface {
|
type Location struct {
|
||||||
GetByCoords(ctx context.Context, coords Coordinates) ([]Times, error)
|
ID int `json:"id"`
|
||||||
Name() string
|
Name string `json:"name"`
|
||||||
}
|
ASCIIName string `json:"ascii_name"`
|
||||||
|
AlternateNames string `json:"alternate_names"`
|
||||||
type Date time.Time
|
CountryCode string `json:"country_code"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
func (d Date) String() string {
|
Longitude float64 `json:"longitude"`
|
||||||
return time.Time(d).Format(time.DateOnly)
|
Timezone string `json:"timezone,omitempty"`
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Date) Scan(src any) error {
|
|
||||||
switch v := src.(type) {
|
|
||||||
case []byte:
|
|
||||||
return json.Unmarshal(v, d)
|
|
||||||
case string:
|
|
||||||
return json.Unmarshal([]byte(v), d)
|
|
||||||
case time.Time:
|
|
||||||
*d = Date(v)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Date) Value() (driver.Value, error) {
|
|
||||||
return json.Marshal(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Date) UnmarshalJSON(bytes []byte) error {
|
|
||||||
var t time.Time
|
|
||||||
if err := json.Unmarshal(bytes, &t); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, offset := t.Zone()
|
|
||||||
t = t.Add(time.Duration(offset * int(time.Second)))
|
|
||||||
t = t.UTC()
|
|
||||||
*d = Date(t)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Date) MarshalJSON() ([]byte, error) {
|
|
||||||
t := time.Time(d)
|
|
||||||
return json.Marshal(t.Format(time.DateOnly))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Date) Time() time.Time {
|
|
||||||
t := time.Time(d)
|
|
||||||
_, offset := t.Zone()
|
|
||||||
t = t.Add(time.Duration(offset * int(time.Second)))
|
|
||||||
t = t.UTC()
|
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Times struct {
|
type Times struct {
|
||||||
Date string `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Fajr string `json:"fajr"`
|
DateHijri string `json:"date_hijri"`
|
||||||
Sunrise string `json:"sunrise"`
|
Fajr time.Time `json:"fajr"`
|
||||||
Dhuhr string `json:"dhuhr"`
|
Sunrise time.Time `json:"sunrise"`
|
||||||
Asr string `json:"asr"`
|
Dhuhr time.Time `json:"dhuhr"`
|
||||||
Maghrib string `json:"maghrib"`
|
Asr time.Time `json:"asr"`
|
||||||
Isha string `json:"isha"`
|
Sunset time.Time `json:"sunset,omitempty"`
|
||||||
|
Maghrib time.Time `json:"maghrib"`
|
||||||
|
Isha time.Time `json:"isha"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimesResult struct {
|
||||||
|
Location Location `json:"location"`
|
||||||
|
Times []Times `json:"prayertimes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegacyLocation struct {
|
||||||
|
ID int
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
|
Region string
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download GeoNames cities5000 from https://download.geonames.org/export/dump/ and seed a SQLite database."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOURCE_URL = "https://download.geonames.org/export/dump/cities5000.zip"
|
||||||
|
ARCHIVE_ENTRY = "cities5000.txt"
|
||||||
|
|
||||||
|
CREATE_TABLE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
|
geoname_id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ascii_name TEXT NOT NULL,
|
||||||
|
alternate_names TEXT,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
feature_class TEXT,
|
||||||
|
feature_code TEXT,
|
||||||
|
country_code TEXT,
|
||||||
|
cc2 TEXT,
|
||||||
|
admin1_code TEXT,
|
||||||
|
admin2_code TEXT,
|
||||||
|
admin3_code TEXT,
|
||||||
|
admin4_code TEXT,
|
||||||
|
population INTEGER,
|
||||||
|
elevation INTEGER,
|
||||||
|
dem INTEGER,
|
||||||
|
timezone TEXT,
|
||||||
|
modification_date TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_INDEXES_SQL = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cities_name ON cities(name)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cities_ascii_name ON cities(ascii_name)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cities_alternate_names ON cities(alternate_names)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cities_country_code ON cities(country_code)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cities_population ON cities(population DESC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
INSERT_SQL = """
|
||||||
|
INSERT OR REPLACE INTO cities (
|
||||||
|
geoname_id,
|
||||||
|
name,
|
||||||
|
ascii_name,
|
||||||
|
alternate_names,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
feature_class,
|
||||||
|
feature_code,
|
||||||
|
country_code,
|
||||||
|
cc2,
|
||||||
|
admin1_code,
|
||||||
|
admin2_code,
|
||||||
|
admin3_code,
|
||||||
|
admin4_code,
|
||||||
|
population,
|
||||||
|
elevation,
|
||||||
|
dem,
|
||||||
|
timezone,
|
||||||
|
modification_date
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Seed GeoNames cities5000 into SQLite")
|
||||||
|
parser.add_argument("--db", required=True, help="Target SQLite database path")
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size", type=int, default=1000, help="Insert batch size"
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def to_int(value: str) -> int | None:
|
||||||
|
value = value.strip()
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def to_float(value: str) -> float:
|
||||||
|
return float(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def row_from_columns(columns: list[str]) -> tuple:
|
||||||
|
return (
|
||||||
|
int(columns[0]),
|
||||||
|
columns[1],
|
||||||
|
columns[2],
|
||||||
|
columns[3],
|
||||||
|
to_float(columns[4]),
|
||||||
|
to_float(columns[5]),
|
||||||
|
columns[6],
|
||||||
|
columns[7],
|
||||||
|
columns[8],
|
||||||
|
columns[9],
|
||||||
|
columns[10],
|
||||||
|
columns[11],
|
||||||
|
columns[12],
|
||||||
|
columns[13],
|
||||||
|
to_int(columns[14]),
|
||||||
|
to_int(columns[15]),
|
||||||
|
to_int(columns[16]),
|
||||||
|
columns[17],
|
||||||
|
columns[18],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_archive() -> bytes:
|
||||||
|
with urllib.request.urlopen(SOURCE_URL) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def stream_city_rows(archive_bytes: bytes):
|
||||||
|
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
|
||||||
|
with zf.open(ARCHIVE_ENTRY) as raw_file:
|
||||||
|
for raw_line in raw_file:
|
||||||
|
line = raw_line.decode("utf-8").rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
columns = line.split("\t")
|
||||||
|
if len(columns) != 19:
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield row_from_columns(columns)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_database(db_path: Path, batch_size: int) -> int:
|
||||||
|
archive = download_archive()
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute(CREATE_TABLE_SQL)
|
||||||
|
for sql in CREATE_INDEXES_SQL:
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
for row in stream_city_rows(archive):
|
||||||
|
batch.append(row)
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
cursor.executemany(INSERT_SQL, batch)
|
||||||
|
inserted += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
cursor.executemany(INSERT_SQL, batch)
|
||||||
|
inserted += len(batch)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
inserted = seed_database(Path(args.db), args.batch_size)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"failed to seed cities database: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"seeded {inserted} city rows into {args.db}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+72
-18
@@ -35,8 +35,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<input type='text'
|
<div class='location-search'>
|
||||||
x-model='locationId'>
|
<input type='search'
|
||||||
|
class='location-search__input'
|
||||||
|
placeholder='Search location...'
|
||||||
|
x-model='searchQuery'
|
||||||
|
@input='onSearchInput'
|
||||||
|
@focus='searchOpen = searchResults.length > 0'
|
||||||
|
@click.outside='searchOpen = false'>
|
||||||
|
|
||||||
|
<template x-if='searchOpen'>
|
||||||
|
<div class='location-search__dropdown'>
|
||||||
|
<template x-if='searchLoading'>
|
||||||
|
<div class='location-search__state'>Searching...</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if='!searchLoading && searchError'>
|
||||||
|
<div class='location-search__state'
|
||||||
|
x-text='searchError'></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
|
||||||
|
<div class='location-search__state'>No results</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
|
||||||
|
<ul class='location-search__results'>
|
||||||
|
<template x-for='location in searchResults'
|
||||||
|
:key='location.id'>
|
||||||
|
<li>
|
||||||
|
<button type='button'
|
||||||
|
class='location-search__item'
|
||||||
|
@click='selectLocation(location)'>
|
||||||
|
<span x-text='formatLocationLabel(location)'></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template x-if='todayTimes'>
|
<template x-if='todayTimes'>
|
||||||
<div class='current-salath text--center'>
|
<div class='current-salath text--center'>
|
||||||
@@ -45,16 +84,21 @@
|
|||||||
x-text='formatDate(userNow)'></time>
|
x-text='formatDate(userNow)'></time>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class='salath-name'
|
<div class='salath-name-group salath-name-group--center'>
|
||||||
lang='tr'
|
<span class='salath-name salath-name--primary'
|
||||||
x-text='todayTimes.currentSalath.name("tr")'></span>
|
:class="{'text--arabic': salathNameDisplay(todayTimes.currentSalath.salath).primary.lang === 'ar'}"
|
||||||
<span class='salath-name'
|
:lang='salathNameDisplay(todayTimes.currentSalath.salath).primary.lang'
|
||||||
lang='de'
|
x-text='salathNameDisplay(todayTimes.currentSalath.salath).primary.text'></span>
|
||||||
x-text='todayTimes.currentSalath.name("de")'></span>
|
<div class="salath-name-alternatives">
|
||||||
<span class='salath-name text--arabic'
|
<template x-for='item in salathNameDisplay(todayTimes.currentSalath.salath).secondary'
|
||||||
lang='ar'
|
:key='item.lang'>
|
||||||
x-text='todayTimes.currentSalath.name("ar")'></span>
|
<span class='salath-name salath-name--secondary'
|
||||||
</p>
|
:class="{'text--arabic': item.lang === 'ar'}"
|
||||||
|
:lang='item.lang'
|
||||||
|
x-text='item.text'></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<time :datetime='todayTimes.nextSalath.startsAt'
|
<time :datetime='todayTimes.nextSalath.startsAt'
|
||||||
class='text--numeric text--time'
|
class='text--numeric text--time'
|
||||||
@@ -71,12 +115,22 @@
|
|||||||
<tr class='salath'
|
<tr class='salath'
|
||||||
:class='{current: it.salath === todayTimes.currentSalath.salath}'>
|
:class='{current: it.salath === todayTimes.currentSalath.salath}'>
|
||||||
<td class='cell-names'>
|
<td class='cell-names'>
|
||||||
<span class='salath-name'
|
<div class='salath-name-group'>
|
||||||
x-text='it.name("tr")'></span>
|
<span class='salath-name salath-name--primary'
|
||||||
<span class='salath-name'
|
:class="{'text--arabic': salathNameDisplay(it.salath).primary.lang === 'ar'}"
|
||||||
x-text='it.name("de")'></span>
|
:lang='salathNameDisplay(it.salath).primary.lang'
|
||||||
<span class='salath-name text--arabic'
|
x-text='salathNameDisplay(it.salath).primary.text'></span>
|
||||||
x-text='it.name("ar")'></span>
|
<div class="salath-name-alternatives">
|
||||||
|
|
||||||
|
<template x-for='item in salathNameDisplay(it.salath).secondary'
|
||||||
|
:key='item.lang'>
|
||||||
|
<span class='salath-name salath-name--secondary'
|
||||||
|
:class="{'text--arabic': item.lang === 'ar'}"
|
||||||
|
:lang='item.lang'
|
||||||
|
x-text='item.text'></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class='cell-times'>
|
<td class='cell-times'>
|
||||||
<span class='salath-time text--time text--numeric'
|
<span class='salath-time text--time text--numeric'
|
||||||
|
|||||||
+255
-94
@@ -1,29 +1,129 @@
|
|||||||
const app = () => ({
|
const app = () => ({
|
||||||
futureTimes: [],
|
futureTimes: [],
|
||||||
|
selectedLocation: Alpine.$persist(null),
|
||||||
locationId: Alpine.$persist('11104'),
|
|
||||||
lastUpdated: Alpine.$persist(null),
|
lastUpdated: Alpine.$persist(null),
|
||||||
|
searchQuery: "",
|
||||||
|
searchResults: [],
|
||||||
|
searchOpen: false,
|
||||||
|
searchLoading: false,
|
||||||
|
searchError: "",
|
||||||
|
searchDebounceTimer: null,
|
||||||
|
supportedSalathLangs: ["tr", "de", "en", "ar"],
|
||||||
userMinutes: 0,
|
userMinutes: 0,
|
||||||
now: new Date(),
|
now: new Date(),
|
||||||
debug: location.hash === '#debug',
|
debug: hasDebugHashFlag(),
|
||||||
geolocation: null,
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.refreshIfStale();
|
const urlCoords = getURLCoords();
|
||||||
|
if (urlCoords) {
|
||||||
|
await this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
||||||
|
} else if (this.selectedLocation) {
|
||||||
|
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
|
||||||
|
await this.refreshPrayerTimes();
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.now = new Date();
|
this.now = new Date();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
getUserLocation()
|
if (!urlCoords) {
|
||||||
.then(loc => {
|
try {
|
||||||
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude};
|
const coords = await getUserLocation();
|
||||||
this.refreshIfStale();
|
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
|
||||||
})
|
} catch (_error) {
|
||||||
.catch(() => this.geolocation = null)
|
// Ignore geolocation errors and rely on manual search.
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onHash() {
|
onHash() {
|
||||||
this.debug = location.hash === '#debug'
|
this.debug = hasDebugHashFlag();
|
||||||
|
|
||||||
|
const urlCoords = getURLCoords();
|
||||||
|
if (urlCoords) {
|
||||||
|
this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSearchInput() {
|
||||||
|
this.searchError = "";
|
||||||
|
if (this.searchDebounceTimer) {
|
||||||
|
clearTimeout(this.searchDebounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = this.searchQuery.trim();
|
||||||
|
if (query === "") {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchDebounceTimer = setTimeout(() => {
|
||||||
|
this.searchLocations(query);
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchLocations(query) {
|
||||||
|
this.searchLoading = true;
|
||||||
|
this.searchOpen = true;
|
||||||
|
try {
|
||||||
|
const response = await fetchJSON(`/api/v1/diyanet/location?query=${encodeURIComponent(query)}`);
|
||||||
|
this.searchResults = response.locations ?? [];
|
||||||
|
} catch (_error) {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchError = "Failed to search locations.";
|
||||||
|
} finally {
|
||||||
|
this.searchLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectNearestLocation(latitude, longitude, {updateURL = false} = {}) {
|
||||||
|
const response = await fetchJSON(`/api/v1/diyanet/location?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
||||||
|
const first = (response.locations ?? [])[0];
|
||||||
|
if (!first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.selectLocation(first, {updateURL});
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectLocation(location, {updateURL = true} = {}) {
|
||||||
|
this.selectedLocation = location;
|
||||||
|
this.searchQuery = this.formatLocationLabel(location);
|
||||||
|
this.searchOpen = false;
|
||||||
|
this.searchResults = [];
|
||||||
|
if (updateURL) {
|
||||||
|
setURLCoords(location.latitude, location.longitude);
|
||||||
|
}
|
||||||
|
await this.refreshPrayerTimes();
|
||||||
|
},
|
||||||
|
|
||||||
|
formatLocationLabel(location) {
|
||||||
|
if (!location) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const country = (location.country_code || "").trim();
|
||||||
|
const name = (location.name || "").trim();
|
||||||
|
const asciiName = (location.ascii_name || "").trim();
|
||||||
|
if (name && asciiName && name !== asciiName) {
|
||||||
|
return `${name} (${asciiName})${country ? ` - ${country}` : ""}`;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
return `${name}${country ? ` - ${country}` : ""}`;
|
||||||
|
}
|
||||||
|
return `${asciiName}${country ? ` - ${country}` : ""}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshPrayerTimes() {
|
||||||
|
if (!this.selectedLocation) {
|
||||||
|
this.futureTimes = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = this.selectedLocation.latitude;
|
||||||
|
const longitude = this.selectedLocation.longitude;
|
||||||
|
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
||||||
|
this.futureTimes = response.prayertimes ?? [];
|
||||||
|
this.lastUpdated = new Date().toISOString();
|
||||||
},
|
},
|
||||||
|
|
||||||
get userNow() {
|
get userNow() {
|
||||||
@@ -40,20 +140,6 @@ const app = () => ({
|
|||||||
return formatTime(this.userNow);
|
return formatTime(this.userNow);
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshIfStale() {
|
|
||||||
const updatedAt = new Date(this.lastUpdated);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const elapsedSeconds = (now - updatedAt) / 1000;
|
|
||||||
|
|
||||||
if (this.geolocation !== null) {
|
|
||||||
this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${this.geolocation.latitude}&longitude=${this.geolocation.longitude}`);
|
|
||||||
} else {
|
|
||||||
this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?location_id=${this.locationId}`);
|
|
||||||
}
|
|
||||||
this.lastUpdated = now.toISOString();
|
|
||||||
},
|
|
||||||
|
|
||||||
get todayTimes() {
|
get todayTimes() {
|
||||||
if (this.futureTimes.length === 0) {
|
if (this.futureTimes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -61,67 +147,114 @@ const app = () => ({
|
|||||||
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
||||||
},
|
},
|
||||||
|
|
||||||
translate(key, lang) {
|
get preferredSalathLangs() {
|
||||||
return translations[key][lang] ?? key
|
const browserLanguages = (navigator.languages && navigator.languages.length > 0)
|
||||||
|
? navigator.languages
|
||||||
|
: [navigator.language];
|
||||||
|
|
||||||
|
const preferred = browserLanguages
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tag) => normalizeLanguageTag(tag))
|
||||||
|
.filter((lang) => this.supportedSalathLangs.includes(lang));
|
||||||
|
|
||||||
|
return Array.from(new Set([...preferred, ...this.supportedSalathLangs]));
|
||||||
|
},
|
||||||
|
|
||||||
|
salathNameDisplay(salath) {
|
||||||
|
const names = this.preferredSalathLangs.map((lang) => ({
|
||||||
|
lang,
|
||||||
|
text: PrayerTimes.translations[salath]?.[lang] ?? salath
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: names[0],
|
||||||
|
secondary: names.slice(1)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
class PrayerTimes {
|
class PrayerTimes {
|
||||||
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'];
|
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||||
static translations = {
|
static translations = {
|
||||||
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'},
|
fajr: {
|
||||||
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'},
|
tr: "İmsak",
|
||||||
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'},
|
en: "Fajr",
|
||||||
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'},
|
de: "Fruehgebet",
|
||||||
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'},
|
ar: "صلاة الفجر"
|
||||||
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'},
|
},
|
||||||
}
|
sunrise: {
|
||||||
|
tr: "Güneş",
|
||||||
|
en: "Sunrise",
|
||||||
|
de: "Sonnenaufgang",
|
||||||
|
ar: "الشروق"
|
||||||
|
},
|
||||||
|
dhuhr: {
|
||||||
|
tr: "Öğle",
|
||||||
|
en: "Dhuhr",
|
||||||
|
de: "Mittagsgebet",
|
||||||
|
ar: "صلاة الظهر"
|
||||||
|
},
|
||||||
|
asr: {
|
||||||
|
tr: "İkindi",
|
||||||
|
en: "Asr",
|
||||||
|
de: "Nachmittagsgebet",
|
||||||
|
ar: "صلاة العصر"
|
||||||
|
},
|
||||||
|
maghrib: {
|
||||||
|
tr: "Akşam",
|
||||||
|
en: "Maghrib",
|
||||||
|
de: "Abendgebet",
|
||||||
|
ar: "صلاة المغرب"
|
||||||
|
},
|
||||||
|
isha: {
|
||||||
|
tr: "Yatsı",
|
||||||
|
en: "Isha",
|
||||||
|
de: "Nachtgebet",
|
||||||
|
ar: "صلاة العشاء"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
constructor({date, ...rest}, clock = () => new Date()) {
|
constructor({date, ...rest}, clock = () => new Date()) {
|
||||||
this.date = date;
|
this.date = date;
|
||||||
this.clock = clock
|
this.clock = clock;
|
||||||
this.salathTimes = rest
|
this.salathTimes = rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
get times() {
|
get times() {
|
||||||
const now = this.clock()
|
const now = this.clock();
|
||||||
return PrayerTimes.salaths.map(k => {
|
return PrayerTimes.salaths.map((k) => {
|
||||||
// "2023-03-05T00:00:00Z"
|
const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
|
||||||
const startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, ''));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
salath: k,
|
salath: k,
|
||||||
name: lang => PrayerTimes.translations[k][lang] ?? '??',
|
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
||||||
startsAt,
|
startsAt,
|
||||||
timeLocal: this.salathTimes[k],
|
timeLocal: this.salathTimes[k],
|
||||||
get untilSeconds() {
|
get untilSeconds() {
|
||||||
let untilSeconds = (startsAt - now) / 1000;
|
const untilSeconds = (startsAt - now) / 1000;
|
||||||
return now > startsAt ? 0 : untilSeconds;
|
return now > startsAt ? 0 : untilSeconds;
|
||||||
},
|
},
|
||||||
get untilHuman() {
|
get untilHuman() {
|
||||||
return formatDuration(this.untilSeconds)
|
return formatDuration(this.untilSeconds);
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentSalath() {
|
get currentSalath() {
|
||||||
let current = this.times.filter(it => it.untilSeconds === 0).at(-1);
|
let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
|
||||||
const prevDay = new Date(this.date);
|
const prevDay = new Date(this.date);
|
||||||
prevDay.setDate(prevDay.getDate() - 1);
|
prevDay.setDate(prevDay.getDate() - 1);
|
||||||
|
|
||||||
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
||||||
}
|
}
|
||||||
return current
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
get nextSalath() {
|
get nextSalath() {
|
||||||
let next = this.times
|
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
||||||
.filter(it => it.untilSeconds > 0)[0]
|
|
||||||
if (next === undefined) {
|
if (next === undefined) {
|
||||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
|
||||||
const nextDay = new Date(this.date);
|
const nextDay = new Date(this.date);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
@@ -129,24 +262,20 @@ class PrayerTimes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...next,
|
...next
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} seconds
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
const d = new Date(0, 0, 0, 0, 0, seconds);
|
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||||
return formatTime(d);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const secs = totalSeconds % 60;
|
||||||
|
|
||||||
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Date} then
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatTime(then) {
|
function formatTime(then) {
|
||||||
return new Intl.DateTimeFormat(navigator.language, {
|
return new Intl.DateTimeFormat(navigator.language, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
@@ -155,10 +284,6 @@ function formatTime(then) {
|
|||||||
}).format(then);
|
}).format(then);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Date} then
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatDate(then) {
|
function formatDate(then) {
|
||||||
return new Intl.DateTimeFormat(navigator.language, {
|
return new Intl.DateTimeFormat(navigator.language, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -167,42 +292,78 @@ function formatDate(then) {
|
|||||||
}).format(then);
|
}).format(then);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Date} then
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatDateHijri(then) {
|
|
||||||
return new Intl.DateTimeFormat("en-u-ca-islamic-umalqura-nu-latn", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(then);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} url
|
|
||||||
* @param {RequestInit} req
|
|
||||||
* */
|
|
||||||
async function fetchJSON(url, req = {}) {
|
async function fetchJSON(url, req = {}) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...req,
|
...req
|
||||||
})
|
});
|
||||||
return res.json()
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`request failed with status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return {Promise<GeolocationCoordinates>}
|
|
||||||
* */
|
|
||||||
function getUserLocation() {
|
function getUserLocation() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject("Geolocation is not supported by this browser.");
|
reject(new Error("Geolocation is not supported by this browser."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => resolve(position.coords),
|
(position) => resolve(position.coords),
|
||||||
(error) => reject(error.message)
|
(error) => reject(error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLanguageTag(tag) {
|
||||||
|
return String(tag).toLowerCase().split("-")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCoordinate(value) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCoordsFromSearchParams(params) {
|
||||||
|
const latitude = parseCoordinate(params.get("latitude") ?? params.get("lat"));
|
||||||
|
const longitude = parseCoordinate(params.get("longitude") ?? params.get("lon"));
|
||||||
|
if (latitude === null || longitude === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {latitude, longitude};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHashSearchParams() {
|
||||||
|
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
||||||
|
if (hash === "") {
|
||||||
|
return new URLSearchParams();
|
||||||
|
}
|
||||||
|
return new URLSearchParams(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDebugHashFlag() {
|
||||||
|
const params = getHashSearchParams();
|
||||||
|
return params.has("debug") || location.hash === "#debug";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getURLCoords() {
|
||||||
|
const queryCoords = parseCoordsFromSearchParams(new URLSearchParams(location.search));
|
||||||
|
if (queryCoords) {
|
||||||
|
return queryCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCoordsFromSearchParams(getHashSearchParams());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setURLCoords(latitude, longitude) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("latitude", String(latitude));
|
||||||
|
url.searchParams.set("longitude", String(longitude));
|
||||||
|
url.searchParams.delete("lat");
|
||||||
|
url.searchParams.delete("lon");
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,60 @@ body {
|
|||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-search {
|
||||||
|
position: relative;
|
||||||
|
max-width: 38rem;
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__item:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__state {
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -39,6 +93,21 @@ p + p {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salath-table {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.salath-table {
|
.salath-table {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -63,14 +132,37 @@ td {
|
|||||||
.salath-name {
|
.salath-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.current {
|
.salath-name-group {
|
||||||
color: tomato;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.salath-name-alternatives {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.salath-name-alternatives span + span:before {
|
||||||
|
content: " / ";
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.salath-name + .salath-name:before {
|
.salath-name-group--center {
|
||||||
content: '/';
|
align-items: center;
|
||||||
opacity: 0.25;
|
}
|
||||||
margin: 0 0.5em;
|
|
||||||
|
.salath-name--primary {
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salath-name--secondary {
|
||||||
|
font-size: 0.58em;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
color: tomato;
|
||||||
}
|
}
|
||||||
|
|
||||||
.salath-time {
|
.salath-time {
|
||||||
|
|||||||
Reference in New Issue
Block a user