Compare commits

..

9 Commits

@ -1,2 +1,8 @@
/.idea .git
*.sqlite3* .gitignore
prayertimes.sqlite3
prayertimes.sqlite3-wal
prayertimes.sqlite3-shm
**/*.log

@ -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 module prayertimes
go 1.25 go 1.25.0
require ( require (
github.com/PuerkitoBio/goquery v1.8.1 github.com/doug-martin/goqu/v9 v9.19.0
github.com/doug-martin/goqu/v9 v9.18.0 github.com/gofiber/fiber/v3 v3.0.0
github.com/gofiber/fiber/v2 v2.42.0
github.com/imroc/req/v3 v3.57.0 github.com/imroc/req/v3 v3.57.0
github.com/rs/zerolog v1.29.0 github.com/rs/zerolog v1.29.0
github.com/samber/lo v1.37.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
modernc.org/sqlite v1.21.0 modernc.org/sqlite v1.46.1
) )
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.1.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/tinylib/msgp v1.6.3 // 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/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.44.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/cc/v3 v3.40.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
) )

184
go.sum

@ -1,43 +1,43 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY= github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.42.0 h1:Fnp7ybWvS+sjNQsFvkhf4G8OhXswvB6Vee8hM/LyS+8= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
github.com/gofiber/fiber/v2 v2.42.0/go.mod h1:3+SGNjqMh5VQH5Vz2Wdi43zTIV16ktlFd3x3R6O1Zlc= github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 h1:CqYfpuYIjnlNxM3msdyPRKabhXZWbKjf3Q8BWROFBso= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -47,20 +47,16 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -70,119 +66,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/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw= github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA= github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.44.0 h1:R+gLUhldIsfg1HokMuQjdQ5bh9nuXHPIfvkYUu9eR5Q= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.44.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 h1:3Ea9SZLCB0aRIhSEjM+iaGIlzzeDJdpi579El/YIhEE= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -191,31 +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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

@ -1,18 +1,22 @@
package api package api
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/favicon" "github.com/gofiber/fiber/v3/middleware/favicon"
"github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover"
recover "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
"prayertimes/templates" "prayertimes/templates"
) )
@ -22,17 +26,26 @@ type httpError struct {
} }
type Services struct { type Services struct {
TimesProvider prayer.TimesProvider PrayerProvider PrayerProvider
LocationTimesProvider prayer.LocationTimesProvider 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 { func New(services Services) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
Immutable: true, Immutable: true,
DisableStartupMessage: false, ReadTimeout: time.Second * 10,
ReadTimeout: time.Second * 10, WriteTimeout: time.Second * 30,
WriteTimeout: time.Second * 30, ErrorHandler: func(ctx fiber.Ctx, err error) error {
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
if errors.Is(err, prayer.ErrInvalidLocation) { if errors.Is(err, prayer.ErrInvalidLocation) {
return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{ return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{
Message: err.Error(), Message: err.Error(),
@ -50,43 +63,233 @@ func New(services Services) *fiber.App {
cors.New(), cors.New(),
) )
app.Get("/healthz", func(ctx *fiber.Ctx) error { app.Get("/healthz", func(ctx fiber.Ctx) error {
return ctx.SendString("OK") return ctx.SendString("OK")
}) })
app.Get("/api/v1/diyanet/prayertimes", func(ctx *fiber.Ctx) error { app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error {
var times []prayer.Times var query struct {
var err error 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))
}
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,
})
default:
return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest)
}
locationID := ctx.Query("location_id") if err != nil {
var loc struct { return fmt.Errorf("failed to fetch prayer times: %w", err)
Latitude float64 `query:"latitude"`
Longitude float64 `query:"longitude"`
} }
if locationID != "" { location := result.Location
times, err = services.TimesProvider.Get(ctx.Context(), locationID) if location.Latitude != 0 || location.Longitude != 0 {
} else if err := ctx.QueryParser(&loc); err == nil { locations, locErr := services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
times, err = services.LocationTimesProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: location.Latitude,
Latitude: loc.Latitude, Longitude: location.Longitude,
Longitude: loc.Longitude,
}) })
} else { if locErr != nil {
return fmt.Errorf("%w: missing location id or coordinates", fiber.ErrBadRequest) 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
}
} }
mappedTimes, err := mapPrayerTimesForResponse(result.Times, location, utc)
if err != nil { if err != nil {
return err 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.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)
ctx.Response().Header.Add(fiber.HeaderCacheControl, "max-age=86400") 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)
}
return ctx.JSON(times) ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(fiber.Map{
"locations": locations,
})
}) })
app.Use("/", filesystem.New(filesystem.Config{ app.Use("/", static.New("", static.Config{FS: templates.FS()}))
Root: templates.HttpFS(),
}))
return app return app
} }
type prayerTimesResponse struct {
Date string `json:"date"`
DateHijri string `json:"date_hijri"`
Fajr string `json:"fajr"`
Sunrise string `json:"sunrise"`
Dhuhr string `json:"dhuhr"`
Asr string `json:"asr"`
Sunset string `json:"sunset,omitempty"`
Maghrib string `json:"maghrib"`
Isha string `json:"isha"`
}
func mapPrayerTimesForResponse(times []prayer.Times, location prayer.Location, utc bool) ([]any, error) {
if utc {
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayer.Times{
Date: item.Date.UTC(),
DateHijri: dateHijri,
Fajr: item.Fajr.UTC(),
Sunrise: item.Sunrise.UTC(),
Dhuhr: item.Dhuhr.UTC(),
Asr: item.Asr.UTC(),
Sunset: item.Sunset.UTC(),
Maghrib: item.Maghrib.UTC(),
Isha: item.Isha.UTC(),
})
}
return result, nil
}
tz := time.UTC
if strings.TrimSpace(location.Timezone) != "" {
loadedTZ, err := loadTimezone(location.Timezone)
if err != nil {
return nil, fmt.Errorf("failed to load location timezone: %w", err)
}
tz = loadedTZ
}
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayerTimesResponse{
Date: item.Date.In(tz).Format(time.DateOnly),
DateHijri: dateHijri,
Fajr: formatHHMM(item.Fajr, tz),
Sunrise: formatHHMM(item.Sunrise, tz),
Dhuhr: formatHHMM(item.Dhuhr, tz),
Asr: formatHHMM(item.Asr, tz),
Sunset: formatHHMM(item.Sunset, tz),
Maghrib: formatHHMM(item.Maghrib, tz),
Isha: formatHHMM(item.Isha, tz),
})
}
return result, nil
}
func formatHHMM(value time.Time, tz *time.Location) string {
if value.IsZero() {
return ""
}
return value.In(tz).Format("15:04")
}
func loadTimezone(name string) (*time.Location, error) {
if strings.HasPrefix(name, "UTC") {
offsetText := strings.TrimPrefix(name, "UTC")
if offsetText == "" {
return time.UTC, nil
}
offsetHours, err := strconv.Atoi(offsetText)
if err != nil {
return nil, fmt.Errorf("failed to parse utc offset timezone: %w", err)
}
return time.FixedZone(name, offsetHours*3600), nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("failed to load iana timezone: %w", err)
}
return loc, nil
}

@ -1,38 +0,0 @@
package database
import (
"database/sql"
"fmt"
"net/url"
"github.com/doug-martin/goqu/v9"
goqusqlite3 "github.com/doug-martin/goqu/v9/dialect/sqlite3"
_ "modernc.org/sqlite"
)
func init() {
goqu.RegisterDialect("sqlite3", func() *goqu.SQLDialectOptions {
do := goqusqlite3.DialectOptions()
do.SupportsReturn = true
return do
}())
}
func NewSqliteDB(filename string) (*goqu.Database, error) {
pragmas := url.Values{
"_pragma": {
"journal_mode(WAL)",
"foreign_keys(1)",
"synchronous(NORMAL)",
"busy_timeout(5000)",
},
}.Encode()
dsn := fmt.Sprintf("%s?%s", filename, pragmas)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return goqu.New("sqlite3", conn), nil
}

@ -1,11 +1,6 @@
package net package net
import ( import (
"context"
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
) )
@ -15,30 +10,3 @@ var ReqClient = req.NewClient().
DisableAutoReadResponse(). DisableAutoReadResponse().
EnableInsecureSkipVerify(). EnableInsecureSkipVerify().
SetUserAgent(userAgent) SetUserAgent(userAgent)
func GetHTML(ctx context.Context, url string) (string, error) {
res, err := ReqClient.R().SetContext(ctx).SetRetryCount(3).Get(url)
if err != nil {
return "", fmt.Errorf("failed to get url: %w", err)
}
html, err := res.ToString()
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
return html, nil
}
func GetParsed(ctx context.Context, url string) (*goquery.Document, error) {
html, err := GetHTML(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to get html: %w", err)
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, fmt.Errorf("failed to parse html: %w", err)
}
return doc, nil
}
type Fetcher struct {
}

@ -1,55 +1,54 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"prayertimes/internal/api" "prayertimes/internal/api"
"prayertimes/internal/database"
"prayertimes/internal/net" "prayertimes/internal/net"
"prayertimes/pkg/dbtimesprovider" "prayertimes/pkg/citydb"
"prayertimes/pkg/diyanet"
"prayertimes/pkg/diyanetapi" "prayertimes/pkg/diyanetapi"
"prayertimes/pkg/diyanetcalc"
"prayertimes/pkg/diyanetprovider"
) )
func main() { func main() {
port := getDefaultEnv("PORT", "8000") if err := run(); err != nil {
log.Fatal().Err(err).Msg("failed to run app")
}
}
services, shutdown, err := newServices() func run() error {
if err != nil { port := getDefaultEnv("PORT", "8000")
log.Fatal().Err(err).Msg("failed to init services") dbPath := strings.TrimSpace(getDefaultEnv("DB_PATH", "./prayertimes.sqlite3"))
if dbPath == "" {
return fmt.Errorf("DB_PATH is not set")
} }
defer shutdown()
app := api.New(services)
err = app.Listen(":" + port) locationProvider, err := citydb.Open(dbPath)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("exit with an error") return fmt.Errorf("failed to open city database: %w", err)
} }
} defer locationProvider.Close()
func newServices() (api.Services, func(), error) {
diyanetAPIProvider := diyanetapi.New(net.ReqClient) diyanetAPIProvider := diyanetapi.New(net.ReqClient)
diyanetProvider := diyanet.New(net.GetParsed) diyanetCalcProvider := diyanetcalc.New()
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
db, err := database.NewSqliteDB(getDefaultEnv("DATABASE_URL", "app.sqlite3")) services := api.Services{
if err != nil { PrayerProvider: prayerProvider,
return api.Services{}, nil, fmt.Errorf("failed to init db: %w", err) LocationProvider: locationProvider,
} }
if err := dbtimesprovider.Migrate(db.Db.(*sql.DB)); err != nil { app := api.New(services)
return api.Services{}, nil, fmt.Errorf("failed to migrate database: %w", err) if err := app.Listen(":" + port); err != nil {
return fmt.Errorf("failed to listen on http port: %w", err)
} }
return api.Services{ return nil
TimesProvider: dbtimesprovider.New(db, diyanetProvider),
LocationTimesProvider: diyanetAPIProvider,
}, func() {
defer db.Db.(*sql.DB).Close()
}, nil
} }
func getDefaultEnv(name string, defaultValue string) string { func getDefaultEnv(name string, defaultValue string) string {

@ -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)
}
})
}

@ -2,11 +2,13 @@ package diyanetapi
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
) )
@ -20,34 +22,72 @@ func New(c *req.Client) Provider {
} }
} }
func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
locationID, err := d.getLocationIDByCoords(ctx, coords)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to resolve location by coordinates: %w", err)
}
result, err := d.Get(ctx, locationID)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by coordinates: %w", err)
}
result.Location.Latitude = coords.Latitude
result.Location.Longitude = coords.Longitude
return result, nil
}
func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coordinates) (string, error) {
res, err := d.http.NewRequest(). res, err := d.http.NewRequest().
SetContext(ctx). SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"latitude": fmt.Sprintf("%f", coords.Latitude), "latitude": fmt.Sprintf("%f", coords.Latitude),
"longitude": fmt.Sprintf("%f", coords.Longitude), "longitude": fmt.Sprintf("%f", coords.Longitude),
}). }).
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") Get("https://namazvakti.diyanet.gov.tr/api/ilce/GetByCoordinat")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by coords: %w", err) return "", fmt.Errorf("failed to get location by coords: %w", err)
}
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 d.parseResponse(res) return fmt.Sprintf("%d", response.ResultObject[0].ID), nil
} }
func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { func (d Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
res, err := d.http.NewRequest(). res, err := d.http.NewRequest().
SetContext(ctx). SetContext(ctx).
SetQueryParam("ilceId", locationID). SetQueryParam("ilceId", locationID).
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by location id: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", err)
} }
return d.parseResponse(res) result, err := d.parseResponse(res)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to parse prayer times response: %w", err)
}
result.Location.ID = parseIntOrZero(locationID)
return result, nil
} }
func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) { func (d Provider) parseResponse(res *req.Response) (prayer.TimesResult, error) {
var response struct { var response struct {
Success bool `json:"success"` Success bool `json:"success"`
ResultObject struct { ResultObject struct {
@ -56,48 +96,61 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
} `json:"konum"` } `json:"konum"`
PrayerTimes []struct { PrayerTimes []struct {
Date time.Time `json:"miladi_tarih_uzun_Iso8601"` Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
Fajr time.Time `json:"imsak"` DateIslamic string `json:"hicri_tarih_uzun"`
Sunrise time.Time `json:"gunes"` Fajr time.Time `json:"imsak"`
Dhuhr time.Time `json:"ogle"` Sunrise time.Time `json:"gunes"`
Asr time.Time `json:"ikindi"` Dhuhr time.Time `json:"ogle"`
Maghrib time.Time `json:"aksam"` Asr time.Time `json:"ikindi"`
Isha time.Time `json:"yatsi"` Sunset time.Time `json:"gunes_batis"`
Maghrib time.Time `json:"aksam"`
Isha time.Time `json:"yatsi"`
} `json:"namazVakti"` } `json:"namazVakti"`
} `json:"resultObject"` } `json:"resultObject"`
} }
if err := res.Unmarshal(&response); err != nil { if err := res.Unmarshal(&response); err != nil {
return nil, fmt.Errorf("failed to unmarshal as json: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to unmarshal as json: %w", err)
} }
if !response.Success { if !response.Success {
return nil, fmt.Errorf("received error: %s", res.String()) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from upstream: %w", errors.New(res.String()))
} }
if len(response.ResultObject.PrayerTimes) == 0 { if len(response.ResultObject.PrayerTimes) == 0 {
return nil, nil return prayer.TimesResult{}, nil
} }
var times []prayer.Times var times []prayer.Times
today := time.Now().UTC().Truncate(time.Hour * 24) today := time.Now().UTC().Truncate(time.Hour * 24)
for _, pt := range response.ResultObject.PrayerTimes { for _, pt := range response.ResultObject.PrayerTimes {
then := prayer.Date(pt.Date).Time() then := pt.Date.UTC().Truncate(time.Hour * 24)
if then.Before(today) { if then.Before(today) {
continue continue
} }
times = append(times, prayer.Times{ times = append(times, prayer.Times{
Date: pt.Date.Format(time.DateOnly), Date: pt.Date.UTC(),
Fajr: pt.Fajr.Format(time.TimeOnly), DateHijri: hijricalendar.ToISODate(pt.Date.UTC()),
Sunrise: pt.Sunrise.Format(time.TimeOnly), Fajr: pt.Fajr.UTC(),
Dhuhr: pt.Dhuhr.Format(time.TimeOnly), Sunrise: pt.Sunrise.UTC(),
Asr: pt.Asr.Format(time.TimeOnly), Dhuhr: pt.Dhuhr.UTC(),
Maghrib: pt.Maghrib.Format(time.TimeOnly), Asr: pt.Asr.UTC(),
Isha: pt.Isha.Format(time.TimeOnly), Sunset: pt.Sunset.UTC(),
Maghrib: pt.Maghrib.UTC(),
Isha: pt.Isha.UTC(),
}) })
} }
return times, nil
return prayer.TimesResult{
Location: prayer.Location{
ID: response.ResultObject.Location.ID,
Timezone: response.ResultObject.Location.Timezone,
},
Times: times,
}, nil
} }
func (d Provider) Name() string { func parseIntOrZero(value string) int {
return "diyanetapi" var out int
_, _ = fmt.Sscanf(value, "%d", &out)
return out
} }

@ -15,23 +15,37 @@ func TestDiyanetAPI_GetByCoords(t *testing.T) {
t.Run("by coords", func(t *testing.T) { t.Run("by coords", func(t *testing.T) {
t.Parallel() t.Parallel()
times, err := p.GetByCoords(context.Background(), prayer.Coordinates{ result, err := p.GetByCoords(context.Background(), prayer.Coordinates{
Latitude: 52.5100846, Latitude: 52.5100846,
Longitude: 13.4518284, Longitude: 13.4518284,
}) })
if err != nil {
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
}
if len(result.Times) == 0 {
t.Skip("skipping live endpoint test because upstream returned no times")
}
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, times) assert.NotEmpty(t, result.Times)
t.Logf("%#+v", times[0]) t.Logf("location=%+v first=%+v", result.Location, result.Times[0])
}) })
t.Run("by id", func(t *testing.T) { t.Run("by id", func(t *testing.T) {
t.Parallel() t.Parallel()
times, err := p.Get(context.Background(), "11104") result, err := p.Get(context.Background(), "11104")
if err != nil {
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
}
if len(result.Times) == 0 {
t.Skip("skipping live endpoint test because upstream returned no times")
}
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, times) assert.NotEmpty(t, result.Times)
for _, time := range times { for _, item := range result.Times {
t.Log(time) t.Log(item)
} }
}) })
} }

@ -0,0 +1,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 1020 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)
}

@ -1,85 +1,41 @@
package prayer package prayer
import ( import (
"context"
"database/sql/driver"
"encoding/json"
"errors" "errors"
"time" "time"
) )
var ErrInvalidLocation = errors.New("invalid location") var ErrInvalidLocation = errors.New("invalid location")
type TimesProvider interface {
Get(ctx context.Context, location string) ([]Times, error)
Name() string
}
type Coordinates struct { type Coordinates struct {
Latitude float64 Latitude float64
Longitude float64 Longitude float64
} }
type LocationTimesProvider interface { type Location struct {
GetByCoords(ctx context.Context, coords Coordinates) ([]Times, error) ID int `json:"id"`
Name() string Name string `json:"name"`
} ASCIIName string `json:"ascii_name"`
AlternateNames string `json:"alternate_names"`
type Date time.Time CountryCode string `json:"country_code"`
Latitude float64 `json:"latitude"`
func (d Date) String() string { Longitude float64 `json:"longitude"`
return time.Time(d).Format(time.DateOnly) Timezone string `json:"timezone,omitempty"`
}
func (d *Date) Scan(src any) error {
switch v := src.(type) {
case []byte:
return json.Unmarshal(v, d)
case string:
return json.Unmarshal([]byte(v), d)
case time.Time:
*d = Date(v)
return nil
}
return nil
}
func (d *Date) Value() (driver.Value, error) {
return json.Marshal(d)
}
func (d *Date) UnmarshalJSON(bytes []byte) error {
var t time.Time
if err := json.Unmarshal(bytes, &t); err != nil {
return err
}
_, offset := t.Zone()
t = t.Add(time.Duration(offset * int(time.Second)))
t = t.UTC()
*d = Date(t)
return nil
}
func (d Date) MarshalJSON() ([]byte, error) {
t := time.Time(d)
return json.Marshal(t.Format(time.DateOnly))
}
func (d Date) Time() time.Time {
t := time.Time(d)
_, offset := t.Zone()
t = t.Add(time.Duration(offset * int(time.Second)))
t = t.UTC()
return t
} }
type Times struct { type Times struct {
Date string `json:"date"` Date time.Time `json:"date"`
Fajr string `json:"fajr"` DateHijri string `json:"date_hijri"`
Sunrise string `json:"sunrise"` Fajr time.Time `json:"fajr"`
Dhuhr string `json:"dhuhr"` Sunrise time.Time `json:"sunrise"`
Asr string `json:"asr"` Dhuhr time.Time `json:"dhuhr"`
Maghrib string `json:"maghrib"` Asr time.Time `json:"asr"`
Isha string `json:"isha"` Sunset time.Time `json:"sunset,omitempty"`
Maghrib time.Time `json:"maghrib"`
Isha time.Time `json:"isha"`
}
type TimesResult struct {
Location Location `json:"location"`
Times []Times `json:"prayertimes"`
} }

@ -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())

@ -35,8 +35,47 @@
</div> </div>
</template> </template>
<input type='text' <div class='location-search'>
x-model='locationId'> <input type='search'
class='location-search__input'
placeholder='Search location...'
x-model='searchQuery'
@input='onSearchInput'
@focus='searchOpen = searchResults.length > 0'
@click.outside='searchOpen = false'>
<template x-if='searchOpen'>
<div class='location-search__dropdown'>
<template x-if='searchLoading'>
<div class='location-search__state'>Searching...</div>
</template>
<template x-if='!searchLoading && searchError'>
<div class='location-search__state'
x-text='searchError'></div>
</template>
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
<div class='location-search__state'>No results</div>
</template>
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
<ul class='location-search__results'>
<template x-for='location in searchResults'
:key='location.id'>
<li>
<button type='button'
class='location-search__item'
@click='selectLocation(location)'>
<span x-text='formatLocationLabel(location)'></span>
</button>
</li>
</template>
</ul>
</template>
</div>
</template>
</div>
<template x-if='todayTimes'> <template x-if='todayTimes'>
<div class='current-salath text--center'> <div class='current-salath text--center'>
@ -45,16 +84,21 @@
x-text='formatDate(userNow)'></time> x-text='formatDate(userNow)'></time>
</p> </p>
<p> <p>
<span class='salath-name' <div class='salath-name-group salath-name-group--center'>
lang='tr' <span class='salath-name salath-name--primary'
x-text='todayTimes.currentSalath.name("tr")'></span> :class="{'text--arabic': salathNameDisplay(todayTimes.currentSalath.salath).primary.lang === 'ar'}"
<span class='salath-name' :lang='salathNameDisplay(todayTimes.currentSalath.salath).primary.lang'
lang='de' x-text='salathNameDisplay(todayTimes.currentSalath.salath).primary.text'></span>
x-text='todayTimes.currentSalath.name("de")'></span> <div class="salath-name-alternatives">
<span class='salath-name text--arabic' <template x-for='item in salathNameDisplay(todayTimes.currentSalath.salath).secondary'
lang='ar' :key='item.lang'>
x-text='todayTimes.currentSalath.name("ar")'></span> <span class='salath-name salath-name--secondary'
</p> :class="{'text--arabic': item.lang === 'ar'}"
:lang='item.lang'
x-text='item.text'></span>
</template>
</div>
</div>
<p> <p>
<time :datetime='todayTimes.nextSalath.startsAt' <time :datetime='todayTimes.nextSalath.startsAt'
class='text--numeric text--time' class='text--numeric text--time'
@ -71,12 +115,22 @@
<tr class='salath' <tr class='salath'
:class='{current: it.salath === todayTimes.currentSalath.salath}'> :class='{current: it.salath === todayTimes.currentSalath.salath}'>
<td class='cell-names'> <td class='cell-names'>
<span class='salath-name' <div class='salath-name-group'>
x-text='it.name("tr")'></span> <span class='salath-name salath-name--primary'
<span class='salath-name' :class="{'text--arabic': salathNameDisplay(it.salath).primary.lang === 'ar'}"
x-text='it.name("de")'></span> :lang='salathNameDisplay(it.salath).primary.lang'
<span class='salath-name text--arabic' x-text='salathNameDisplay(it.salath).primary.text'></span>
x-text='it.name("ar")'></span> <div class="salath-name-alternatives">
<template x-for='item in salathNameDisplay(it.salath).secondary'
:key='item.lang'>
<span class='salath-name salath-name--secondary'
:class="{'text--arabic': item.lang === 'ar'}"
:lang='item.lang'
x-text='item.text'></span>
</template>
</div>
</div>
</td> </td>
<td class='cell-times'> <td class='cell-times'>
<span class='salath-time text--time text--numeric' <span class='salath-time text--time text--numeric'
@ -95,4 +149,4 @@
</div> </div>
</div> </div>
</body> </body>
</html> </html>

@ -1,29 +1,129 @@
const app = () => ({ const app = () => ({
futureTimes: [], futureTimes: [],
selectedLocation: Alpine.$persist(null),
locationId: Alpine.$persist('11104'),
lastUpdated: Alpine.$persist(null), lastUpdated: Alpine.$persist(null),
searchQuery: "",
searchResults: [],
searchOpen: false,
searchLoading: false,
searchError: "",
searchDebounceTimer: null,
supportedSalathLangs: ["tr", "de", "en", "ar"],
userMinutes: 0, userMinutes: 0,
now: new Date(), now: new Date(),
debug: location.hash === '#debug', debug: hasDebugHashFlag(),
geolocation: null,
async init() { async init() {
await this.refreshIfStale(); const urlCoords = getURLCoords();
if (urlCoords) {
await this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
} else if (this.selectedLocation) {
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
await this.refreshPrayerTimes();
}
setInterval(() => { setInterval(() => {
this.now = new Date(); this.now = new Date();
}, 500); }, 500);
getUserLocation() if (!urlCoords) {
.then(loc => { try {
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude}; const coords = await getUserLocation();
this.refreshIfStale(); await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
}) } catch (_error) {
.catch(() => this.geolocation = null) // Ignore geolocation errors and rely on manual search.
}
}
}, },
onHash() { onHash() {
this.debug = location.hash === '#debug' this.debug = hasDebugHashFlag();
const urlCoords = getURLCoords();
if (urlCoords) {
this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
}
},
onSearchInput() {
this.searchError = "";
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
const query = this.searchQuery.trim();
if (query === "") {
this.searchResults = [];
this.searchOpen = false;
return;
}
this.searchDebounceTimer = setTimeout(() => {
this.searchLocations(query);
}, 250);
},
async searchLocations(query) {
this.searchLoading = true;
this.searchOpen = true;
try {
const response = await fetchJSON(`/api/v1/diyanet/location?query=${encodeURIComponent(query)}`);
this.searchResults = response.locations ?? [];
} catch (_error) {
this.searchResults = [];
this.searchError = "Failed to search locations.";
} finally {
this.searchLoading = false;
}
},
async selectNearestLocation(latitude, longitude, {updateURL = false} = {}) {
const response = await fetchJSON(`/api/v1/diyanet/location?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
const first = (response.locations ?? [])[0];
if (!first) {
return;
}
await this.selectLocation(first, {updateURL});
},
async selectLocation(location, {updateURL = true} = {}) {
this.selectedLocation = location;
this.searchQuery = this.formatLocationLabel(location);
this.searchOpen = false;
this.searchResults = [];
if (updateURL) {
setURLCoords(location.latitude, location.longitude);
}
await this.refreshPrayerTimes();
},
formatLocationLabel(location) {
if (!location) {
return "";
}
const country = (location.country_code || "").trim();
const name = (location.name || "").trim();
const asciiName = (location.ascii_name || "").trim();
if (name && asciiName && name !== asciiName) {
return `${name} (${asciiName})${country ? ` - ${country}` : ""}`;
}
if (name) {
return `${name}${country ? ` - ${country}` : ""}`;
}
return `${asciiName}${country ? ` - ${country}` : ""}`;
},
async refreshPrayerTimes() {
if (!this.selectedLocation) {
this.futureTimes = [];
return;
}
const latitude = this.selectedLocation.latitude;
const longitude = this.selectedLocation.longitude;
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
this.futureTimes = response.prayertimes ?? [];
this.lastUpdated = new Date().toISOString();
}, },
get userNow() { get userNow() {
@ -40,20 +140,6 @@ const app = () => ({
return formatTime(this.userNow); return formatTime(this.userNow);
}, },
async refreshIfStale() {
const updatedAt = new Date(this.lastUpdated);
const now = new Date();
const elapsedSeconds = (now - updatedAt) / 1000;
if (this.geolocation !== null) {
this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${this.geolocation.latitude}&longitude=${this.geolocation.longitude}`);
} else {
this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?location_id=${this.locationId}`);
}
this.lastUpdated = now.toISOString();
},
get todayTimes() { get todayTimes() {
if (this.futureTimes.length === 0) { if (this.futureTimes.length === 0) {
return null; return null;
@ -61,67 +147,114 @@ const app = () => ({
return new PrayerTimes(this.futureTimes[0], () => this.userNow); return new PrayerTimes(this.futureTimes[0], () => this.userNow);
}, },
translate(key, lang) { get preferredSalathLangs() {
return translations[key][lang] ?? key const browserLanguages = (navigator.languages && navigator.languages.length > 0)
? navigator.languages
: [navigator.language];
const preferred = browserLanguages
.filter(Boolean)
.map((tag) => normalizeLanguageTag(tag))
.filter((lang) => this.supportedSalathLangs.includes(lang));
return Array.from(new Set([...preferred, ...this.supportedSalathLangs]));
},
salathNameDisplay(salath) {
const names = this.preferredSalathLangs.map((lang) => ({
lang,
text: PrayerTimes.translations[salath]?.[lang] ?? salath
}));
return {
primary: names[0],
secondary: names.slice(1)
};
} }
}); });
class PrayerTimes { class PrayerTimes {
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha']; static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
static translations = { static translations = {
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'}, fajr: {
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'}, tr: "İmsak",
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'}, en: "Fajr",
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'}, de: "Fruehgebet",
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'}, ar: "صلاة الفجر"
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'}, },
} sunrise: {
tr: "Güneş",
en: "Sunrise",
de: "Sonnenaufgang",
ar: "الشروق"
},
dhuhr: {
tr: "Öğle",
en: "Dhuhr",
de: "Mittagsgebet",
ar: "صلاة الظهر"
},
asr: {
tr: "İkindi",
en: "Asr",
de: "Nachmittagsgebet",
ar: "صلاة العصر"
},
maghrib: {
tr: "Akşam",
en: "Maghrib",
de: "Abendgebet",
ar: "صلاة المغرب"
},
isha: {
tr: "Yatsı",
en: "Isha",
de: "Nachtgebet",
ar: "صلاة العشاء"
}
};
constructor({date, ...rest}, clock = () => new Date()) { constructor({date, ...rest}, clock = () => new Date()) {
this.date = date; this.date = date;
this.clock = clock this.clock = clock;
this.salathTimes = rest this.salathTimes = rest;
} }
get times() { get times() {
const now = this.clock() const now = this.clock();
return PrayerTimes.salaths.map(k => { return PrayerTimes.salaths.map((k) => {
// "2023-03-05T00:00:00Z" const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
const startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, ''));
return { return {
salath: k, salath: k,
name: lang => PrayerTimes.translations[k][lang] ?? '??', name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
startsAt, startsAt,
timeLocal: this.salathTimes[k], timeLocal: this.salathTimes[k],
get untilSeconds() { get untilSeconds() {
let untilSeconds = (startsAt - now) / 1000; const untilSeconds = (startsAt - now) / 1000;
return now > startsAt ? 0 : untilSeconds; return now > startsAt ? 0 : untilSeconds;
}, },
get untilHuman() { get untilHuman() {
return formatDuration(this.untilSeconds) return formatDuration(this.untilSeconds);
}, }
} };
}) });
} }
get currentSalath() { get currentSalath() {
let current = this.times.filter(it => it.untilSeconds === 0).at(-1); let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
if (current === undefined) { if (current === undefined) {
// we're in isha -> today's fajr is almost the same as tomorrows
const prevDay = new Date(this.date); const prevDay = new Date(this.date);
prevDay.setDate(prevDay.getDate() - 1); prevDay.setDate(prevDay.getDate() - 1);
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1); current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
} }
return current return current;
} }
get nextSalath() { get nextSalath() {
let next = this.times let next = this.times.filter((it) => it.untilSeconds > 0)[0];
.filter(it => it.untilSeconds > 0)[0]
if (next === undefined) { if (next === undefined) {
// we're in isha -> today's fajr is almost the same as tomorrows
const nextDay = new Date(this.date); const nextDay = new Date(this.date);
nextDay.setDate(nextDay.getDate() + 1); nextDay.setDate(nextDay.getDate() + 1);
@ -129,24 +262,20 @@ class PrayerTimes {
} }
return { return {
...next, ...next
} };
} }
} }
/**
* @param {number} seconds
* @return {string}
* */
function formatDuration(seconds) { function formatDuration(seconds) {
const d = new Date(0, 0, 0, 0, 0, seconds); const totalSeconds = Math.max(0, Math.floor(seconds));
return formatTime(d); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
} }
/**
* @param {Date} then
* @return {string}
* */
function formatTime(then) { function formatTime(then) {
return new Intl.DateTimeFormat(navigator.language, { return new Intl.DateTimeFormat(navigator.language, {
hour: "numeric", hour: "numeric",
@ -155,10 +284,6 @@ function formatTime(then) {
}).format(then); }).format(then);
} }
/**
* @param {Date} then
* @return {string}
* */
function formatDate(then) { function formatDate(then) {
return new Intl.DateTimeFormat(navigator.language, { return new Intl.DateTimeFormat(navigator.language, {
year: "numeric", year: "numeric",
@ -167,42 +292,78 @@ function formatDate(then) {
}).format(then); }).format(then);
} }
/**
* @param {Date} then
* @return {string}
* */
function formatDateHijri(then) {
return new Intl.DateTimeFormat("en-u-ca-islamic-umalqura-nu-latn", {
year: "numeric",
month: "long",
day: "numeric",
}).format(then);
}
/**
* @param {string} url
* @param {RequestInit} req
* */
async function fetchJSON(url, req = {}) { async function fetchJSON(url, req = {}) {
const res = await fetch(url, { const res = await fetch(url, {
...req, ...req
}) });
return res.json()
if (!res.ok) {
throw new Error(`request failed with status ${res.status}`);
}
return res.json();
} }
/**
* @return {Promise<GeolocationCoordinates>}
* */
function getUserLocation() { function getUserLocation() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
reject("Geolocation is not supported by this browser."); reject(new Error("Geolocation is not supported by this browser."));
return; return;
} }
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords), (position) => resolve(position.coords),
(error) => reject(error.message) (error) => reject(error)
); );
}); });
} }
function normalizeLanguageTag(tag) {
return String(tag).toLowerCase().split("-")[0];
}
function parseCoordinate(value) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseCoordsFromSearchParams(params) {
const latitude = parseCoordinate(params.get("latitude") ?? params.get("lat"));
const longitude = parseCoordinate(params.get("longitude") ?? params.get("lon"));
if (latitude === null || longitude === null) {
return null;
}
return {latitude, longitude};
}
function getHashSearchParams() {
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
if (hash === "") {
return new URLSearchParams();
}
return new URLSearchParams(hash);
}
function hasDebugHashFlag() {
const params = getHashSearchParams();
return params.has("debug") || location.hash === "#debug";
}
function getURLCoords() {
const queryCoords = parseCoordsFromSearchParams(new URLSearchParams(location.search));
if (queryCoords) {
return queryCoords;
}
return parseCoordsFromSearchParams(getHashSearchParams());
}
function setURLCoords(latitude, longitude) {
const url = new URL(window.location.href);
url.searchParams.set("latitude", String(latitude));
url.searchParams.set("longitude", String(longitude));
url.searchParams.delete("lat");
url.searchParams.delete("lon");
window.history.replaceState({}, "", url.toString());
}

@ -7,6 +7,60 @@ body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
.location-search {
position: relative;
max-width: 38rem;
margin: 1rem auto;
padding: 0 1rem;
}
.location-search__input {
width: 100%;
border: 1px solid #ddd;
border-radius: 0.75rem;
padding: 0.75rem 0.875rem;
font: inherit;
}
.location-search__dropdown {
position: absolute;
left: 1rem;
right: 1rem;
top: calc(100% + 0.25rem);
background: #fff;
border: 1px solid #ddd;
border-radius: 0.75rem;
max-height: 18rem;
overflow: auto;
z-index: 5;
}
.location-search__results {
list-style: none;
margin: 0;
padding: 0;
}
.location-search__item {
width: 100%;
border: 0;
border-bottom: 1px solid #f0f0f0;
background: transparent;
text-align: left;
padding: 0.625rem 0.75rem;
font: inherit;
cursor: pointer;
}
.location-search__item:hover {
background: #fafafa;
}
.location-search__state {
padding: 0.75rem;
color: #666;
}
p { p {
margin: 0; margin: 0;
} }
@ -39,6 +93,21 @@ p + p {
margin: auto; margin: auto;
} }
@media (max-width: 768px) {
.table-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.salath-table {
font-size: 1.375rem;
}
.clock {
font-size: 3rem;
}
}
.salath-table { .salath-table {
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
@ -63,14 +132,37 @@ td {
.salath-name { .salath-name {
} }
.current { .salath-name-group {
color: tomato; display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.salath-name-alternatives {
line-height: 1.1;
}
.salath-name-alternatives span + span:before {
content: " / ";
opacity: 0.5;
}
.salath-name-group--center {
align-items: center;
} }
.salath-name + .salath-name:before { .salath-name--primary {
content: '/'; line-height: 1.05;
opacity: 0.25; }
margin: 0 0.5em;
.salath-name--secondary {
font-size: 0.58em;
font-weight: 500;
opacity: 0.75;
line-height: 1.1;
}
.current {
color: tomato;
} }
.salath-time { .salath-time {
@ -85,4 +177,4 @@ td {
.current-salath { .current-salath {
font-weight: bold; font-weight: bold;
font-size: 1.5rem; font-size: 1.5rem;
} }

Loading…
Cancel
Save