Compare commits
9
Commits
43d4560fbb
...
45100d4a68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45100d4a68 | ||
|
|
960e6f713a | ||
|
|
c106d57fe6 | ||
|
|
07a9703a89 | ||
|
|
a53b200c21 | ||
|
|
26af8af8a9 | ||
|
|
f520473ed3 | ||
|
|
92568ad4e2 | ||
|
|
73316d4e62 |
+8
-2
@@ -1,2 +1,8 @@
|
||||
/.idea
|
||||
*.sqlite3*
|
||||
.git
|
||||
.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,46 @@
|
||||
module prayertimes
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/doug-martin/goqu/v9 v9.18.0
|
||||
github.com/gofiber/fiber/v2 v2.42.0
|
||||
github.com/doug-martin/goqu/v9 v9.19.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0
|
||||
github.com/imroc/req/v3 v3.57.0
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/samber/lo v1.37.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
modernc.org/sqlite v1.21.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/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/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.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/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/philhofer/fwd v1.1.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
||||
github.com/tinylib/msgp v1.1.8 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.44.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // 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
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=
|
||||
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
|
||||
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
|
||||
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/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/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/v2 v2.42.0/go.mod h1:3+SGNjqMh5VQH5Vz2Wdi43zTIV16ktlFd3x3R6O1Zlc=
|
||||
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
||||
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/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/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/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 h1:CqYfpuYIjnlNxM3msdyPRKabhXZWbKjf3Q8BWROFBso=
|
||||
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
|
||||
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/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
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.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
||||
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -70,119 +66,61 @@ 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/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/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/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/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw=
|
||||
github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA=
|
||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
|
||||
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
|
||||
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/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
|
||||
github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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/fasthttp v1.44.0 h1:R+gLUhldIsfg1HokMuQjdQ5bh9nuXHPIfvkYUu9eR5Q=
|
||||
github.com/valyala/fasthttp v1.44.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
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/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/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-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-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/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-20230304125523-9ff063c70017/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
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/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
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/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/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/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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/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.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/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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/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/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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -191,31 +129,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
|
||||
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
|
||||
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
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/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=
|
||||
|
||||
+239
-36
@@ -1,18 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/favicon"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
recover "github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
"github.com/gofiber/fiber/v3/middleware/favicon"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
|
||||
"prayertimes/pkg/hijricalendar"
|
||||
"prayertimes/pkg/prayer"
|
||||
"prayertimes/templates"
|
||||
)
|
||||
@@ -22,17 +26,26 @@ type httpError struct {
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
TimesProvider prayer.TimesProvider
|
||||
LocationTimesProvider prayer.LocationTimesProvider
|
||||
PrayerProvider PrayerProvider
|
||||
LocationProvider LocationProvider
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func New(services Services) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
Immutable: true,
|
||||
DisableStartupMessage: false,
|
||||
ReadTimeout: time.Second * 10,
|
||||
WriteTimeout: time.Second * 30,
|
||||
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
|
||||
Immutable: true,
|
||||
ReadTimeout: time.Second * 10,
|
||||
WriteTimeout: time.Second * 30,
|
||||
ErrorHandler: func(ctx fiber.Ctx, err error) error {
|
||||
if errors.Is(err, prayer.ErrInvalidLocation) {
|
||||
return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{
|
||||
Message: err.Error(),
|
||||
@@ -50,43 +63,233 @@ func New(services Services) *fiber.App {
|
||||
cors.New(),
|
||||
)
|
||||
|
||||
app.Get("/healthz", func(ctx *fiber.Ctx) error {
|
||||
app.Get("/healthz", func(ctx fiber.Ctx) error {
|
||||
return ctx.SendString("OK")
|
||||
})
|
||||
|
||||
app.Get("/api/v1/diyanet/prayertimes", func(ctx *fiber.Ctx) error {
|
||||
var times []prayer.Times
|
||||
var err error
|
||||
|
||||
locationID := ctx.Query("location_id")
|
||||
var loc struct {
|
||||
Latitude float64 `query:"latitude"`
|
||||
Longitude float64 `query:"longitude"`
|
||||
app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error {
|
||||
var query struct {
|
||||
LocationID string `query:"location_id"`
|
||||
Latitude string `query:"latitude"`
|
||||
Longitude string `query:"longitude"`
|
||||
UTC string `query:"utc"`
|
||||
}
|
||||
if err := ctx.Bind().Query(&query); err != nil {
|
||||
return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||
}
|
||||
|
||||
if locationID != "" {
|
||||
times, err = services.TimesProvider.Get(ctx.Context(), locationID)
|
||||
} else if err := ctx.QueryParser(&loc); err == nil {
|
||||
times, err = services.LocationTimesProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
||||
Latitude: loc.Latitude,
|
||||
Longitude: loc.Longitude,
|
||||
locationID := strings.TrimSpace(query.LocationID)
|
||||
latitude := strings.TrimSpace(query.Latitude)
|
||||
longitude := strings.TrimSpace(query.Longitude)
|
||||
utc := strings.TrimSpace(query.UTC) == "1"
|
||||
|
||||
var (
|
||||
result prayer.TimesResult
|
||||
err error
|
||||
)
|
||||
|
||||
switch {
|
||||
case locationID != "":
|
||||
result, err = services.PrayerProvider.Get(ctx.Context(), locationID)
|
||||
case latitude != "" && longitude != "":
|
||||
lat, latErr := strconv.ParseFloat(latitude, 64)
|
||||
if latErr != nil {
|
||||
return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr))
|
||||
}
|
||||
lng, lngErr := strconv.ParseFloat(longitude, 64)
|
||||
if lngErr != nil {
|
||||
return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr))
|
||||
}
|
||||
|
||||
result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
|
||||
Latitude: lat,
|
||||
Longitude: lng,
|
||||
})
|
||||
} else {
|
||||
return fmt.Errorf("%w: missing location id or coordinates", fiber.ErrBadRequest)
|
||||
default:
|
||||
return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest)
|
||||
}
|
||||
|
||||
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{
|
||||
Root: templates.HttpFS(),
|
||||
}))
|
||||
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
|
||||
var query struct {
|
||||
Text string `query:"query"`
|
||||
Latitude string `query:"latitude"`
|
||||
Longitude string `query:"longitude"`
|
||||
}
|
||||
if err := ctx.Bind().Query(&query); err != nil {
|
||||
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||
}
|
||||
|
||||
query.Text = strings.TrimSpace(query.Text)
|
||||
query.Latitude = strings.TrimSpace(query.Latitude)
|
||||
query.Longitude = strings.TrimSpace(query.Longitude)
|
||||
|
||||
var (
|
||||
locations []prayer.Location
|
||||
err error
|
||||
)
|
||||
|
||||
switch {
|
||||
case query.Text != "" && query.Latitude == "" && query.Longitude == "":
|
||||
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), query.Text)
|
||||
case query.Text == "" && query.Latitude != "" && query.Longitude != "":
|
||||
lat, latErr := strconv.ParseFloat(query.Latitude, 64)
|
||||
if latErr != nil {
|
||||
return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr))
|
||||
}
|
||||
lng, lngErr := strconv.ParseFloat(query.Longitude, 64)
|
||||
if lngErr != nil {
|
||||
return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr))
|
||||
}
|
||||
|
||||
locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
|
||||
Latitude: lat,
|
||||
Longitude: lng,
|
||||
})
|
||||
default:
|
||||
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.Use("/", static.New("", static.Config{FS: templates.FS()}))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
@@ -15,30 +10,3 @@ var ReqClient = req.NewClient().
|
||||
DisableAutoReadResponse().
|
||||
EnableInsecureSkipVerify().
|
||||
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 {
|
||||
}
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"prayertimes/internal/api"
|
||||
"prayertimes/internal/database"
|
||||
"prayertimes/internal/net"
|
||||
"prayertimes/pkg/dbtimesprovider"
|
||||
"prayertimes/pkg/diyanet"
|
||||
"prayertimes/pkg/citydb"
|
||||
"prayertimes/pkg/diyanetapi"
|
||||
"prayertimes/pkg/diyanetcalc"
|
||||
"prayertimes/pkg/diyanetprovider"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := getDefaultEnv("PORT", "8000")
|
||||
|
||||
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")
|
||||
if err := run(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to run app")
|
||||
}
|
||||
}
|
||||
|
||||
func newServices() (api.Services, func(), error) {
|
||||
diyanetAPIProvider := diyanetapi.New(net.ReqClient)
|
||||
diyanetProvider := diyanet.New(net.GetParsed)
|
||||
func run() error {
|
||||
port := getDefaultEnv("PORT", "8000")
|
||||
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"))
|
||||
locationProvider, err := citydb.Open(dbPath)
|
||||
if err != nil {
|
||||
return api.Services{}, nil, fmt.Errorf("failed to init db: %w", err)
|
||||
return fmt.Errorf("failed to open city database: %w", err)
|
||||
}
|
||||
defer locationProvider.Close()
|
||||
|
||||
diyanetAPIProvider := diyanetapi.New(net.ReqClient)
|
||||
diyanetCalcProvider := diyanetcalc.New()
|
||||
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
|
||||
|
||||
services := api.Services{
|
||||
PrayerProvider: prayerProvider,
|
||||
LocationProvider: locationProvider,
|
||||
}
|
||||
|
||||
if err := dbtimesprovider.Migrate(db.Db.(*sql.DB)); err != nil {
|
||||
return api.Services{}, nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
app := api.New(services)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
return fmt.Errorf("failed to listen on http port: %w", err)
|
||||
}
|
||||
|
||||
return api.Services{
|
||||
TimesProvider: dbtimesprovider.New(db, diyanetProvider),
|
||||
LocationTimesProvider: diyanetAPIProvider,
|
||||
}, func() {
|
||||
defer db.Db.(*sql.DB).Close()
|
||||
}, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDefaultEnv(name string, defaultValue string) string {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
|
||||
func New(db *goqu.Database) Provider {
|
||||
return Provider{db: db}
|
||||
}
|
||||
|
||||
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 New(goqu.New("sqlite3", conn)), nil
|
||||
}
|
||||
|
||||
func (p Provider) Close() error {
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
|
||||
"prayertimes/pkg/hijricalendar"
|
||||
"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().
|
||||
SetContext(ctx).
|
||||
SetQueryParams(map[string]string{
|
||||
"latitude": fmt.Sprintf("%f", coords.Latitude),
|
||||
"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 {
|
||||
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().
|
||||
SetContext(ctx).
|
||||
SetQueryParam("ilceId", locationID).
|
||||
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik")
|
||||
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk")
|
||||
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 {
|
||||
Success bool `json:"success"`
|
||||
ResultObject struct {
|
||||
@@ -56,48 +96,61 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"konum"`
|
||||
PrayerTimes []struct {
|
||||
Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
|
||||
Fajr time.Time `json:"imsak"`
|
||||
Sunrise time.Time `json:"gunes"`
|
||||
Dhuhr time.Time `json:"ogle"`
|
||||
Asr time.Time `json:"ikindi"`
|
||||
Maghrib time.Time `json:"aksam"`
|
||||
Isha time.Time `json:"yatsi"`
|
||||
Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
|
||||
DateIslamic string `json:"hicri_tarih_uzun"`
|
||||
Fajr time.Time `json:"imsak"`
|
||||
Sunrise time.Time `json:"gunes"`
|
||||
Dhuhr time.Time `json:"ogle"`
|
||||
Asr time.Time `json:"ikindi"`
|
||||
Sunset time.Time `json:"gunes_batis"`
|
||||
Maghrib time.Time `json:"aksam"`
|
||||
Isha time.Time `json:"yatsi"`
|
||||
} `json:"namazVakti"`
|
||||
} `json:"resultObject"`
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return nil, nil
|
||||
return prayer.TimesResult{}, nil
|
||||
}
|
||||
|
||||
var times []prayer.Times
|
||||
today := time.Now().UTC().Truncate(time.Hour * 24)
|
||||
for _, pt := range response.ResultObject.PrayerTimes {
|
||||
then := prayer.Date(pt.Date).Time()
|
||||
then := pt.Date.UTC().Truncate(time.Hour * 24)
|
||||
if then.Before(today) {
|
||||
continue
|
||||
}
|
||||
|
||||
times = append(times, prayer.Times{
|
||||
Date: pt.Date.Format(time.DateOnly),
|
||||
Fajr: pt.Fajr.Format(time.TimeOnly),
|
||||
Sunrise: pt.Sunrise.Format(time.TimeOnly),
|
||||
Dhuhr: pt.Dhuhr.Format(time.TimeOnly),
|
||||
Asr: pt.Asr.Format(time.TimeOnly),
|
||||
Maghrib: pt.Maghrib.Format(time.TimeOnly),
|
||||
Isha: pt.Isha.Format(time.TimeOnly),
|
||||
Date: pt.Date.UTC(),
|
||||
DateHijri: hijricalendar.ToISODate(pt.Date.UTC()),
|
||||
Fajr: pt.Fajr.UTC(),
|
||||
Sunrise: pt.Sunrise.UTC(),
|
||||
Dhuhr: pt.Dhuhr.UTC(),
|
||||
Asr: pt.Asr.UTC(),
|
||||
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 {
|
||||
return "diyanetapi"
|
||||
func parseIntOrZero(value string) int {
|
||||
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.Parallel()
|
||||
|
||||
times, err := p.GetByCoords(context.Background(), prayer.Coordinates{
|
||||
result, err := p.GetByCoords(context.Background(), prayer.Coordinates{
|
||||
Latitude: 52.5100846,
|
||||
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.NotEmpty(t, times)
|
||||
t.Logf("%#+v", times[0])
|
||||
assert.NotEmpty(t, result.Times)
|
||||
t.Logf("location=%+v first=%+v", result.Location, result.Times[0])
|
||||
})
|
||||
|
||||
t.Run("by id", func(t *testing.T) {
|
||||
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.NotEmpty(t, times)
|
||||
for _, time := range times {
|
||||
t.Log(time)
|
||||
assert.NotEmpty(t, result.Times)
|
||||
for _, item := range result.Times {
|
||||
t.Log(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package diyanetcalc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"prayertimes/pkg/hijricalendar"
|
||||
"prayertimes/pkg/prayer"
|
||||
)
|
||||
|
||||
const (
|
||||
daysToGenerate = 30
|
||||
degPerHour = 15.0
|
||||
)
|
||||
|
||||
var errNotSupported = errors.New("not supported in calculation provider")
|
||||
|
||||
// Diyanet prayer times calculator.
|
||||
//
|
||||
// Implements the Turkish Presidency of Religious Affairs (Diyanet) methodology,
|
||||
// standardized in the 1983 reform. Prayer times are indexed to the Sun's apparent
|
||||
// altitude angle at the observer's location, solved via spherical trigonometry.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diyanet angular criteria (post-1983 reform)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Imsak (Fajr): Sun is 18deg below the horizon = start of astronomical twilight.
|
||||
// Pre-1983 used -19deg plus a temkin buffer; now -18deg with zero temkin.
|
||||
const imsakAngle = -18.0
|
||||
|
||||
// Isha (Yatsi): Sun is 17deg below the horizon = shafaq al-ahmar (red twilight)
|
||||
// has fully disappeared from the western sky.
|
||||
const ishaAngle = -17.0
|
||||
|
||||
// Sunrise / Sunset (Tulu / Gurup): geometric horizon alone is insufficient.
|
||||
// Two physical corrections are combined into a single -0.833deg value (50 arcmin):
|
||||
// - Atmospheric refraction: ~0.567deg — air bends sunlight over the horizon
|
||||
// - Solar semi-diameter: ~0.267deg — Sun is "up" when its upper limb clears
|
||||
const sunAngle = -0.833
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temkin — precautionary time buffers (minutes, post-1983 standardized values)
|
||||
//
|
||||
// Temkin ensures a single published time remains valid across the full
|
||||
// geographical extent of a city (highest peak to lowest valley, east to west).
|
||||
// Pre-1983 values were often 10–20 min; the reform moderated them.
|
||||
//
|
||||
// Imsak: 0 min — no buffer; avoids starting Fajr too early / fast too late
|
||||
// Sunrise: -7 min — subtracted, ensuring the Sun has fully cleared the horizon
|
||||
// Dhuhr: +5 min — added, ensuring the Sun has clearly begun its descent
|
||||
// Asr: +4 min — accounts for local elevation and horizon obstacles
|
||||
// Maghrib:+7 min — ensures the Sun has completely set before breaking fast
|
||||
// Isha: 0 min — no buffer needed at this twilight stage
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
type temkinMinutes struct {
|
||||
Imsak float64
|
||||
Sunrise float64
|
||||
Dhuhr float64
|
||||
Asr float64
|
||||
Maghrib float64
|
||||
Isha float64
|
||||
}
|
||||
|
||||
var temkin = temkinMinutes{
|
||||
Imsak: 0,
|
||||
Sunrise: -7,
|
||||
Dhuhr: 5,
|
||||
Asr: 4,
|
||||
Maghrib: 7,
|
||||
Isha: 0,
|
||||
}
|
||||
|
||||
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) {
|
||||
offset := estimateUTCOffsetHours(coords.Longitude)
|
||||
todayUTC := time.Now().UTC().Truncate(24 * time.Hour)
|
||||
|
||||
results := make([]prayer.Times, 0, daysToGenerate)
|
||||
for i := 0; i < daysToGenerate; i++ {
|
||||
day := todayUTC.AddDate(0, 0, i)
|
||||
calculated := prayerTimes(coords.Latitude, coords.Longitude, day)
|
||||
|
||||
results = append(results, prayer.Times{
|
||||
Date: day,
|
||||
DateHijri: hijricalendar.ToISODate(day),
|
||||
Fajr: derefOrZero(calculated.Imsak),
|
||||
Sunrise: derefOrZero(calculated.Sunrise),
|
||||
Dhuhr: derefOrZero(calculated.Dhuhr),
|
||||
Asr: derefOrZero(calculated.Asr),
|
||||
Sunset: derefOrZero(calculated.Sunset),
|
||||
Maghrib: derefOrZero(calculated.Maghrib),
|
||||
Isha: derefOrZero(calculated.Isha),
|
||||
})
|
||||
}
|
||||
|
||||
return prayer.TimesResult{
|
||||
Location: prayer.Location{
|
||||
Latitude: coords.Latitude,
|
||||
Longitude: coords.Longitude,
|
||||
Timezone: formatUTCOffsetTimezone(offset),
|
||||
},
|
||||
Times: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Convert 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. The -1524.5 offset shifts the epoch to
|
||||
// noon UT, Jan 1, 4713 BC (the standard astronomical Julian Date epoch).
|
||||
// The Gregorian calendar correction term b = 2 - a + a//4 accounts for
|
||||
// the century-year leap-day rules introduced in 1582.
|
||||
func julianDay(d time.Time) float64 {
|
||||
y, m, day := d.Date()
|
||||
if m <= 2 {
|
||||
// January/February treated as months 13/14 of the prior year
|
||||
y--
|
||||
m += 12
|
||||
}
|
||||
a := y / 100
|
||||
b := 2 - a + a/4 // Gregorian correction
|
||||
|
||||
return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + float64(day+b) - 1524.5
|
||||
}
|
||||
|
||||
// Compute solar declination and equation of time for a given Julian Day.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// delta — solar declination in degrees: the Sun's angular distance
|
||||
// north (+) or south (-) of the celestial equator. Controls
|
||||
// seasonal day length and the Sun's maximum altitude.
|
||||
// eot — equation of time in minutes: difference between apparent solar
|
||||
// time (sundial) and mean solar time (clock). Caused by Earth's
|
||||
// elliptical orbit and axial tilt; ranges roughly ±16 min.
|
||||
//
|
||||
// Algorithm uses low-precision USNO solar coordinates (~0.01deg accuracy):
|
||||
//
|
||||
// d — days since J2000.0 epoch (Jan 1.5, 2000 = JD 2451545.0)
|
||||
// g — mean anomaly: Sun's angular position in its elliptical orbit
|
||||
// q — mean longitude of the Sun
|
||||
// L — ecliptic longitude: corrected for orbital eccentricity via the
|
||||
// equation of centre (1.915deg*sin g + 0.020deg*sin 2g)
|
||||
// e — obliquity of the ecliptic: Earth's axial tilt (~23.44deg, slowly
|
||||
// decreasing at 0.00000036deg/day)
|
||||
func sunParams(jd float64) (delta float64, eot float64) {
|
||||
d := jd - 2451545.0 // days since J2000.0
|
||||
|
||||
g := toRadians(357.529 + 0.98560028*d) // mean anomaly
|
||||
q := toRadians(280.459 + 0.98564736*d) // mean longitude
|
||||
|
||||
// Ecliptic longitude: equation of centre adds up to ~1.9deg correction for
|
||||
// the difference between uniform circular and actual elliptical motion.
|
||||
L := q + toRadians(1.915*math.Sin(g)+0.020*math.Sin(2*g))
|
||||
e := toRadians(23.439 - 0.00000036*d) // obliquity of ecliptic
|
||||
|
||||
// Declination: project ecliptic longitude onto the celestial equator.
|
||||
delta = toDegrees(math.Asin(math.Sin(e) * math.Sin(L)))
|
||||
|
||||
// Right ascension in hours (atan2 handles all four quadrants correctly).
|
||||
ra := toDegrees(math.Atan2(math.Cos(e)*math.Sin(L), math.Cos(L))) / degPerHour
|
||||
|
||||
// EoT = mean sun hour angle minus apparent sun RA, normalized to ±30 min.
|
||||
// round() removes the large integer offset (q accumulates many full rotations)
|
||||
// before converting to minutes; without it the raw difference is ~600 hours.
|
||||
diff := toDegrees(q)/degPerHour - ra
|
||||
eot = (diff - math.Round(diff)) * 60
|
||||
|
||||
return delta, eot
|
||||
}
|
||||
|
||||
// Solve for the hour angle H (hours) at which the Sun reaches a given altitude.
|
||||
//
|
||||
// Derived from the spherical law of cosines for the astronomical triangle:
|
||||
//
|
||||
// sin(a) = sin(phi)*sin(delta) + cos(phi)*cos(delta)*cos(H)
|
||||
//
|
||||
// Rearranged:
|
||||
//
|
||||
// cos(H) = (sin(a) − sin(phi)*sin(delta)) / (cos(phi)*cos(delta))
|
||||
//
|
||||
// H is converted from degrees to hours by dividing by 15 (360deg/24h = 15deg/h).
|
||||
// Returns false when |cos H| > 1, i.e. the Sun never reaches that altitude
|
||||
// (midnight sun or polar night) — Diyanet handles these with the Takdir method.
|
||||
func hourAngle(altitudeDeg, lat, delta float64) (hours float64, ok bool) {
|
||||
cosH := (math.Sin(toRadians(altitudeDeg)) - math.Sin(toRadians(lat))*math.Sin(toRadians(delta))) /
|
||||
(math.Cos(toRadians(lat)) * math.Cos(toRadians(delta)))
|
||||
|
||||
if math.Abs(cosH) > 1 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return toDegrees(math.Acos(cosH)) / degPerHour, true
|
||||
}
|
||||
|
||||
// Compute the solar altitude at which Asr begins (Asr-i Avval / First Asr).
|
||||
//
|
||||
// Diyanet follows the majority-school definition: Asr starts when an object's
|
||||
// shadow length equals the object's height plus its shortest noon shadow (fey-i zeval).
|
||||
// The shadow factor is 1 (Asr-i Avval; Hanafi uses 2 for Asr-i Sani).
|
||||
//
|
||||
// The required altitude satisfies: cot(a) = 1 + tan(|phi − delta|)
|
||||
// which is: a = atan(1 / (1 + tan(|phi − delta|)))
|
||||
// where |phi − delta| is the Sun's angular distance from the zenith at solar noon.
|
||||
func asrAltitude(lat, delta float64) float64 {
|
||||
return toDegrees(math.Atan(1.0 / (1.0 + math.Tan(toRadians(math.Abs(lat-delta))))))
|
||||
}
|
||||
|
||||
// Convert a decimal hour value (e.g. 10.5 = 10:30) to a UTC-aware datetime.
|
||||
func decimalHoursToUTC(hours float64, d time.Time) time.Time {
|
||||
dayUTC := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return dayUTC.Add(time.Duration(hours * float64(time.Hour)))
|
||||
}
|
||||
|
||||
type computedTimes struct {
|
||||
Imsak *time.Time
|
||||
Sunrise *time.Time
|
||||
Dhuhr *time.Time
|
||||
Asr *time.Time
|
||||
Sunset *time.Time
|
||||
Maghrib *time.Time
|
||||
Isha *time.Time
|
||||
}
|
||||
|
||||
// Compute Diyanet prayer times, returning UTC-aware datetimes.
|
||||
//
|
||||
// Solar noon (Dhuhr) is the central reference. All other times are offsets:
|
||||
//
|
||||
// Morning times (Imsak, Sunrise): noon − H + temkin
|
||||
// Afternoon/evening times (Asr, Maghrib, Isha): noon + H + temkin
|
||||
//
|
||||
// Internally computes solar noon at UTC (tz=0), so results are in UTC.
|
||||
//
|
||||
// Solar noon formula: T_noon = 12 + TZ − lambda/15 − EoT/60
|
||||
//
|
||||
// lambda/15 converts longitude to hours (15deg/h)
|
||||
// EoT corrects the gap between mean solar time and apparent solar time
|
||||
func prayerTimes(lat, lon float64, d time.Time) computedTimes {
|
||||
jd := julianDay(d)
|
||||
delta, eot := sunParams(jd)
|
||||
|
||||
// UTC solar noon: tz=0, so T_noon = 12 − lambda/15 − EoT/60
|
||||
noonUTC := 12 - lon/degPerHour - eot/60.0
|
||||
|
||||
compute := func(base, angle float64, sign int, temkinMinutes float64) *time.Time {
|
||||
h, ok := hourAngle(angle, lat, delta)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t := decimalHoursToUTC(base+float64(sign)*h+temkinMinutes/60.0, d)
|
||||
return &t
|
||||
}
|
||||
|
||||
hAsr, hasAsr := hourAngle(asrAltitude(lat, delta), lat, delta)
|
||||
var asr *time.Time
|
||||
if hasAsr {
|
||||
t := decimalHoursToUTC(noonUTC+hAsr+temkin.Asr/60.0, d)
|
||||
asr = &t
|
||||
}
|
||||
|
||||
// Sunset for output is the geometric sunset without temkin offset.
|
||||
geometricSunset := func(base, angle float64) *time.Time {
|
||||
h, ok := hourAngle(angle, lat, delta)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t := decimalHoursToUTC(base+h, d)
|
||||
return &t
|
||||
}
|
||||
|
||||
tDhuhr := decimalHoursToUTC(noonUTC+temkin.Dhuhr/60.0, d)
|
||||
|
||||
return computedTimes{
|
||||
Imsak: compute(noonUTC, imsakAngle, -1, temkin.Imsak),
|
||||
Sunrise: compute(noonUTC, sunAngle, -1, temkin.Sunrise),
|
||||
Dhuhr: &tDhuhr,
|
||||
Asr: asr,
|
||||
Sunset: geometricSunset(noonUTC, sunAngle),
|
||||
Maghrib: compute(noonUTC, sunAngle, +1, temkin.Maghrib),
|
||||
Isha: compute(noonUTC, ishaAngle, +1, temkin.Isha),
|
||||
}
|
||||
}
|
||||
|
||||
func derefOrZero(dt *time.Time) time.Time {
|
||||
if dt == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return dt.UTC()
|
||||
}
|
||||
|
||||
func formatUTCOffsetTimezone(offset float64) string {
|
||||
return fmt.Sprintf("UTC%+d", int(offset))
|
||||
}
|
||||
|
||||
func estimateUTCOffsetHours(longitude float64) float64 {
|
||||
offset := math.Round(longitude / degPerHour)
|
||||
if offset < -12 {
|
||||
return -12
|
||||
}
|
||||
if offset > 14 {
|
||||
return 14
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
func toRadians(deg float64) float64 {
|
||||
return deg * math.Pi / 180.0
|
||||
}
|
||||
|
||||
func toDegrees(rad float64) float64 {
|
||||
return rad * 180.0 / math.Pi
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+23
-67
@@ -1,85 +1,41 @@
|
||||
package prayer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidLocation = errors.New("invalid location")
|
||||
|
||||
type TimesProvider interface {
|
||||
Get(ctx context.Context, location string) ([]Times, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
type Coordinates struct {
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
type LocationTimesProvider interface {
|
||||
GetByCoords(ctx context.Context, coords Coordinates) ([]Times, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
type Date time.Time
|
||||
|
||||
func (d Date) String() string {
|
||||
return time.Time(d).Format(time.DateOnly)
|
||||
}
|
||||
|
||||
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 Location struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ASCIIName string `json:"ascii_name"`
|
||||
AlternateNames string `json:"alternate_names"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type Times struct {
|
||||
Date string `json:"date"`
|
||||
Fajr string `json:"fajr"`
|
||||
Sunrise string `json:"sunrise"`
|
||||
Dhuhr string `json:"dhuhr"`
|
||||
Asr string `json:"asr"`
|
||||
Maghrib string `json:"maghrib"`
|
||||
Isha string `json:"isha"`
|
||||
Date time.Time `json:"date"`
|
||||
DateHijri string `json:"date_hijri"`
|
||||
Fajr time.Time `json:"fajr"`
|
||||
Sunrise time.Time `json:"sunrise"`
|
||||
Dhuhr time.Time `json:"dhuhr"`
|
||||
Asr time.Time `json:"asr"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</template>
|
||||
|
||||
<input type='text'
|
||||
x-model='locationId'>
|
||||
<div class='location-search'>
|
||||
<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'>
|
||||
<div class='current-salath text--center'>
|
||||
@@ -45,16 +84,21 @@
|
||||
x-text='formatDate(userNow)'></time>
|
||||
</p>
|
||||
<p>
|
||||
<span class='salath-name'
|
||||
lang='tr'
|
||||
x-text='todayTimes.currentSalath.name("tr")'></span>
|
||||
<span class='salath-name'
|
||||
lang='de'
|
||||
x-text='todayTimes.currentSalath.name("de")'></span>
|
||||
<span class='salath-name text--arabic'
|
||||
lang='ar'
|
||||
x-text='todayTimes.currentSalath.name("ar")'></span>
|
||||
</p>
|
||||
<div class='salath-name-group salath-name-group--center'>
|
||||
<span class='salath-name salath-name--primary'
|
||||
:class="{'text--arabic': salathNameDisplay(todayTimes.currentSalath.salath).primary.lang === 'ar'}"
|
||||
:lang='salathNameDisplay(todayTimes.currentSalath.salath).primary.lang'
|
||||
x-text='salathNameDisplay(todayTimes.currentSalath.salath).primary.text'></span>
|
||||
<div class="salath-name-alternatives">
|
||||
<template x-for='item in salathNameDisplay(todayTimes.currentSalath.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>
|
||||
<p>
|
||||
<time :datetime='todayTimes.nextSalath.startsAt'
|
||||
class='text--numeric text--time'
|
||||
@@ -71,12 +115,22 @@
|
||||
<tr class='salath'
|
||||
:class='{current: it.salath === todayTimes.currentSalath.salath}'>
|
||||
<td class='cell-names'>
|
||||
<span class='salath-name'
|
||||
x-text='it.name("tr")'></span>
|
||||
<span class='salath-name'
|
||||
x-text='it.name("de")'></span>
|
||||
<span class='salath-name text--arabic'
|
||||
x-text='it.name("ar")'></span>
|
||||
<div class='salath-name-group'>
|
||||
<span class='salath-name salath-name--primary'
|
||||
:class="{'text--arabic': salathNameDisplay(it.salath).primary.lang === 'ar'}"
|
||||
:lang='salathNameDisplay(it.salath).primary.lang'
|
||||
x-text='salathNameDisplay(it.salath).primary.text'></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 class='cell-times'>
|
||||
<span class='salath-time text--time text--numeric'
|
||||
|
||||
+255
-94
@@ -1,29 +1,129 @@
|
||||
const app = () => ({
|
||||
futureTimes: [],
|
||||
|
||||
locationId: Alpine.$persist('11104'),
|
||||
selectedLocation: Alpine.$persist(null),
|
||||
lastUpdated: Alpine.$persist(null),
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
searchOpen: false,
|
||||
searchLoading: false,
|
||||
searchError: "",
|
||||
searchDebounceTimer: null,
|
||||
supportedSalathLangs: ["tr", "de", "en", "ar"],
|
||||
userMinutes: 0,
|
||||
now: new Date(),
|
||||
debug: location.hash === '#debug',
|
||||
geolocation: null,
|
||||
debug: hasDebugHashFlag(),
|
||||
|
||||
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(() => {
|
||||
this.now = new Date();
|
||||
}, 500);
|
||||
|
||||
getUserLocation()
|
||||
.then(loc => {
|
||||
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude};
|
||||
this.refreshIfStale();
|
||||
})
|
||||
.catch(() => this.geolocation = null)
|
||||
if (!urlCoords) {
|
||||
try {
|
||||
const coords = await getUserLocation();
|
||||
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
|
||||
} catch (_error) {
|
||||
// Ignore geolocation errors and rely on manual search.
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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() {
|
||||
@@ -40,20 +140,6 @@ const app = () => ({
|
||||
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() {
|
||||
if (this.futureTimes.length === 0) {
|
||||
return null;
|
||||
@@ -61,67 +147,114 @@ const app = () => ({
|
||||
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
||||
},
|
||||
|
||||
translate(key, lang) {
|
||||
return translations[key][lang] ?? key
|
||||
get preferredSalathLangs() {
|
||||
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 {
|
||||
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'];
|
||||
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||
static translations = {
|
||||
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'},
|
||||
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'},
|
||||
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'},
|
||||
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'},
|
||||
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'},
|
||||
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'},
|
||||
}
|
||||
fajr: {
|
||||
tr: "İmsak",
|
||||
en: "Fajr",
|
||||
de: "Fruehgebet",
|
||||
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()) {
|
||||
this.date = date;
|
||||
this.clock = clock
|
||||
this.salathTimes = rest
|
||||
this.clock = clock;
|
||||
this.salathTimes = rest;
|
||||
}
|
||||
|
||||
get times() {
|
||||
const now = this.clock()
|
||||
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 now = this.clock();
|
||||
return PrayerTimes.salaths.map((k) => {
|
||||
const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
|
||||
|
||||
return {
|
||||
salath: k,
|
||||
name: lang => PrayerTimes.translations[k][lang] ?? '??',
|
||||
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
||||
startsAt,
|
||||
timeLocal: this.salathTimes[k],
|
||||
get untilSeconds() {
|
||||
let untilSeconds = (startsAt - now) / 1000;
|
||||
const untilSeconds = (startsAt - now) / 1000;
|
||||
return now > startsAt ? 0 : untilSeconds;
|
||||
},
|
||||
get untilHuman() {
|
||||
return formatDuration(this.untilSeconds)
|
||||
},
|
||||
}
|
||||
})
|
||||
return formatDuration(this.untilSeconds);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
||||
const prevDay = new Date(this.date);
|
||||
prevDay.setDate(prevDay.getDate() - 1);
|
||||
|
||||
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
||||
}
|
||||
return current
|
||||
return current;
|
||||
}
|
||||
|
||||
get nextSalath() {
|
||||
let next = this.times
|
||||
.filter(it => it.untilSeconds > 0)[0]
|
||||
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
||||
if (next === undefined) {
|
||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
||||
const nextDay = new Date(this.date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
@@ -129,24 +262,20 @@ class PrayerTimes {
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
}
|
||||
...next
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} seconds
|
||||
* @return {string}
|
||||
* */
|
||||
function formatDuration(seconds) {
|
||||
const d = new Date(0, 0, 0, 0, 0, seconds);
|
||||
return formatTime(d);
|
||||
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||
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) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
hour: "numeric",
|
||||
@@ -155,10 +284,6 @@ function formatTime(then) {
|
||||
}).format(then);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} then
|
||||
* @return {string}
|
||||
* */
|
||||
function formatDate(then) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
year: "numeric",
|
||||
@@ -167,42 +292,78 @@ function formatDate(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 = {}) {
|
||||
const res = await fetch(url, {
|
||||
...req,
|
||||
})
|
||||
return res.json()
|
||||
...req
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<GeolocationCoordinates>}
|
||||
* */
|
||||
function getUserLocation() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject("Geolocation is not supported by this browser.");
|
||||
reject(new Error("Geolocation is not supported by this browser."));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -39,6 +93,21 @@ p + p {
|
||||
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 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
@@ -63,14 +132,37 @@ td {
|
||||
.salath-name {
|
||||
}
|
||||
|
||||
.current {
|
||||
color: tomato;
|
||||
.salath-name-group {
|
||||
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 {
|
||||
content: '/';
|
||||
opacity: 0.25;
|
||||
margin: 0 0.5em;
|
||||
.salath-name-group--center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user