Compare commits

..
14 Commits
29 changed files with 2459 additions and 1067 deletions
+8 -2
View File
@@ -1,2 +1,8 @@
/.idea .git
*.sqlite3* .gitignore
prayertimes.sqlite3
prayertimes.sqlite3-wal
prayertimes.sqlite3-shm
**/*.log
+26
View File
@@ -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"]
+410
View File
@@ -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'
+16
View File
@@ -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
+18 -33
View File
@@ -1,62 +1,47 @@
module prayertimes module prayertimes
go 1.25 go 1.25.0
require ( require (
github.com/PuerkitoBio/goquery v1.8.1 github.com/doug-martin/goqu/v9 v9.19.0
github.com/doug-martin/goqu/v9 v9.18.0 github.com/gofiber/fiber/v3 v3.0.0
github.com/gofiber/fiber/v2 v2.42.0
github.com/imroc/req/v3 v3.57.0 github.com/imroc/req/v3 v3.57.0
github.com/rs/zerolog v1.29.0 github.com/rs/zerolog v1.29.0
github.com/samber/lo v1.37.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
modernc.org/sqlite v1.21.0 modernc.org/sqlite v1.46.1
) )
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.1.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/samber/lo v1.52.0 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect github.com/tinylib/msgp v1.6.3 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.44.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/cc/v3 v3.40.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
) )
+63 -123
View File
@@ -1,43 +1,43 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY= github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.42.0 h1:Fnp7ybWvS+sjNQsFvkhf4G8OhXswvB6Vee8hM/LyS+8= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
github.com/gofiber/fiber/v2 v2.42.0/go.mod h1:3+SGNjqMh5VQH5Vz2Wdi43zTIV16ktlFd3x3R6O1Zlc= github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 h1:CqYfpuYIjnlNxM3msdyPRKabhXZWbKjf3Q8BWROFBso= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -47,20 +47,16 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -70,119 +66,63 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.44.0 h1:R+gLUhldIsfg1HokMuQjdQ5bh9nuXHPIfvkYUu9eR5Q= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.44.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 h1:3Ea9SZLCB0aRIhSEjM+iaGIlzzeDJdpi579El/YIhEE= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20230304125523-9ff063c70017/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -191,31 +131,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
+345 -33
View File
@@ -1,18 +1,22 @@
package api package api
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/favicon" "github.com/gofiber/fiber/v3/middleware/favicon"
"github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover"
recover "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
"prayertimes/templates" "prayertimes/templates"
) )
@@ -22,17 +26,32 @@ type httpError struct {
} }
type Services struct { type Services struct {
TimesProvider prayer.TimesProvider PrayerProvider PrayerProvider
LocationTimesProvider prayer.LocationTimesProvider LocationProvider LocationProvider
LegacyLocationProvider LegacyLocationProvider
}
type PrayerProvider interface {
Get(ctx context.Context, locationID string) (prayer.TimesResult, error)
GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
}
type LocationProvider interface {
SearchLocations(ctx context.Context, query string) ([]prayer.Location, error)
SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error)
}
type LegacyLocationProvider interface {
SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error)
GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, error)
} }
func New(services Services) *fiber.App { func New(services Services) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
Immutable: true, Immutable: true,
DisableStartupMessage: false,
ReadTimeout: time.Second * 10, ReadTimeout: time.Second * 10,
WriteTimeout: time.Second * 30, WriteTimeout: time.Second * 30,
ErrorHandler: func(ctx *fiber.Ctx, err error) error { ErrorHandler: func(ctx fiber.Ctx, err error) error {
if errors.Is(err, prayer.ErrInvalidLocation) { if errors.Is(err, prayer.ErrInvalidLocation) {
return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{ return ctx.Status(http.StatusUnprocessableEntity).JSON(httpError{
Message: err.Error(), Message: err.Error(),
@@ -50,43 +69,336 @@ func New(services Services) *fiber.App {
cors.New(), cors.New(),
) )
app.Get("/healthz", func(ctx *fiber.Ctx) error { app.Get("/healthz", func(ctx fiber.Ctx) error {
return ctx.SendString("OK") return ctx.SendString("OK")
}) })
app.Get("/api/v1/diyanet/prayertimes", func(ctx *fiber.Ctx) error { app.Get("/api/v1/diyanet/prayertimes", func(ctx fiber.Ctx) error {
var times []prayer.Times var params struct {
var err error LocationID string `query:"location_id"`
Latitude *float64 `query:"latitude"`
locationID := ctx.Query("location_id") Longitude *float64 `query:"longitude"`
var loc struct { UTC bool `query:"utc"`
Latitude float64 `query:"latitude"` }
Longitude float64 `query:"longitude"` if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
} }
if locationID != "" { locationID := strings.TrimSpace(params.LocationID)
times, err = services.TimesProvider.Get(ctx.Context(), locationID) utc := params.UTC
} else if err := ctx.QueryParser(&loc); err == nil {
times, err = services.LocationTimesProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ var (
Latitude: loc.Latitude, result prayer.TimesResult
Longitude: loc.Longitude, err error
)
switch {
case locationID != "":
result, err = services.PrayerProvider.Get(ctx.Context(), locationID)
case params.Latitude != nil && params.Longitude != nil:
result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
Latitude: *params.Latitude,
Longitude: *params.Longitude,
}) })
} else { default:
return fmt.Errorf("%w: missing location id or coordinates", fiber.ErrBadRequest) return fmt.Errorf("failed to validate prayer times query parameters: %w", fiber.ErrBadRequest)
} }
if err != nil { if err != nil {
return err return fmt.Errorf("failed to fetch prayer times: %w", err)
} }
ctx.Response().Header.Add(fiber.HeaderCacheControl, "max-age=86400") location := result.Location
if location.Latitude != 0 || location.Longitude != 0 {
locations, locErr := services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
})
if locErr != nil {
return fmt.Errorf("failed to enrich prayer times location from database: %w", locErr)
}
if len(locations) > 0 {
dbLocation := locations[0]
if strings.TrimSpace(dbLocation.Timezone) == "" {
dbLocation.Timezone = location.Timezone
}
location = dbLocation
}
}
return ctx.JSON(times) mappedTimes, err := mapPrayerTimesForResponse(result.Times, location, utc)
if err != nil {
return fmt.Errorf("failed to map prayer times for response: %w", err)
}
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(fiber.Map{
"location": location,
"prayertimes": mappedTimes,
})
}) })
app.Use("/", filesystem.New(filesystem.Config{ app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
Root: templates.HttpFS(), var params struct {
})) Text *string `query:"query"`
Latitude *float64 `query:"latitude"`
Longitude *float64 `query:"longitude"`
}
if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
}
var (
locations []prayer.Location
err error
)
if params.Text != nil {
trimmed := strings.TrimSpace(*params.Text)
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), trimmed)
} else if params.Latitude != nil && params.Longitude != nil {
locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
Latitude: *params.Latitude,
Longitude: *params.Longitude,
})
} else {
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
}
if err != nil {
return fmt.Errorf("failed to search locations: %w", err)
}
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(fiber.Map{
"locations": locations,
})
})
app.Get("/api/diyanet/search", func(ctx fiber.Ctx) error {
setDeprecationHeaders(ctx, "/api/v1/diyanet/location")
var params struct {
Query string `query:"q"`
}
if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind legacy location search query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
}
params.Query = strings.TrimSpace(params.Query)
if params.Query == "" {
return fmt.Errorf("missing query parameter q: %w", fiber.ErrBadRequest)
}
locations, err := services.LegacyLocationProvider.SearchLocations(ctx.Context(), params.Query)
if err != nil {
return fmt.Errorf("failed to search legacy diyanet locations: %w", err)
}
resp := make([]fiber.Map, 0, len(locations))
for _, location := range locations {
resp = append(resp, fiber.Map{
"id": location.ID,
"country": location.Country,
"city": location.City,
"region": location.Region,
})
}
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(resp)
})
app.Get("/api/diyanet/countries", func(ctx fiber.Ctx) error {
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
})
app.Get("/api/diyanet/countries/:country/cities", func(ctx fiber.Ctx) error {
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
})
app.Get("/api/diyanet/locations", func(ctx fiber.Ctx) error {
return respondDeprecatedEndpoint(ctx, "/api/diyanet/search?q=")
})
app.Get("/api/diyanet/prayertimes", func(ctx fiber.Ctx) error {
setDeprecationHeaders(ctx, "/api/v1/diyanet/prayertimes")
var params struct {
LocationID int `query:"location_id"`
}
if err := ctx.Bind().Query(&params); err != nil {
return fmt.Errorf("failed to bind legacy prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
}
if params.LocationID <= 0 {
return fmt.Errorf("missing query parameter location_id: %w", fiber.ErrBadRequest)
}
legacyLocation, err := services.LegacyLocationProvider.GetLocationByID(ctx.Context(), params.LocationID)
if err != nil {
return fmt.Errorf("failed to resolve location by location_id: %w", errors.Join(fiber.ErrNotFound, err))
}
result, err := services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
Latitude: legacyLocation.Latitude,
Longitude: legacyLocation.Longitude,
})
if err != nil {
return fmt.Errorf("failed to fetch prayer times for location_id: %w", err)
}
location := result.Location
location.ID = legacyLocation.ID
if strings.TrimSpace(legacyLocation.Timezone) != "" {
location.Timezone = legacyLocation.Timezone
}
mapped, err := mapPrayerTimesForLegacyResponse(result.Times, location)
if err != nil {
return fmt.Errorf("failed to map legacy prayer times response: %w", err)
}
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(mapped)
})
app.Use("/", static.New("", static.Config{FS: templates.FS()}))
return app return app
} }
type prayerTimesResponse struct {
Date string `json:"date"`
DateHijri string `json:"date_hijri"`
Fajr string `json:"fajr"`
Sunrise string `json:"sunrise"`
Dhuhr string `json:"dhuhr"`
Asr string `json:"asr"`
Sunset string `json:"sunset,omitempty"`
Maghrib string `json:"maghrib"`
Isha string `json:"isha"`
}
func mapPrayerTimesForResponse(times []prayer.Times, location prayer.Location, utc bool) ([]any, error) {
if utc {
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayer.Times{
Date: item.Date.UTC(),
DateHijri: dateHijri,
Fajr: item.Fajr.UTC(),
Sunrise: item.Sunrise.UTC(),
Dhuhr: item.Dhuhr.UTC(),
Asr: item.Asr.UTC(),
Sunset: item.Sunset.UTC(),
Maghrib: item.Maghrib.UTC(),
Isha: item.Isha.UTC(),
})
}
return result, nil
}
tz := time.UTC
if strings.TrimSpace(location.Timezone) != "" {
loadedTZ, err := loadTimezone(location.Timezone)
if err != nil {
return nil, fmt.Errorf("failed to load location timezone: %w", err)
}
tz = loadedTZ
}
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayerTimesResponse{
Date: item.Date.In(tz).Format(time.DateOnly),
DateHijri: dateHijri,
Fajr: formatHHMM(item.Fajr, tz),
Sunrise: formatHHMM(item.Sunrise, tz),
Dhuhr: formatHHMM(item.Dhuhr, tz),
Asr: formatHHMM(item.Asr, tz),
Sunset: formatHHMM(item.Sunset, tz),
Maghrib: formatHHMM(item.Maghrib, tz),
Isha: formatHHMM(item.Isha, tz),
})
}
return result, nil
}
func formatHHMM(value time.Time, tz *time.Location) string {
if value.IsZero() {
return ""
}
return value.In(tz).Format("15:04")
}
func loadTimezone(name string) (*time.Location, error) {
if strings.HasPrefix(name, "UTC") {
offsetText := strings.TrimPrefix(name, "UTC")
if offsetText == "" {
return time.UTC, nil
}
offsetHours, err := strconv.Atoi(offsetText)
if err != nil {
return nil, fmt.Errorf("failed to parse utc offset timezone: %w", err)
}
return time.FixedZone(name, offsetHours*3600), nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("failed to load iana timezone: %w", err)
}
return loc, nil
}
func mapPrayerTimesForLegacyResponse(times []prayer.Times, location prayer.Location) ([]fiber.Map, error) {
tz := time.UTC
if strings.TrimSpace(location.Timezone) != "" {
loadedTZ, err := loadTimezone(location.Timezone)
if err != nil {
return nil, fmt.Errorf("failed to load location timezone: %w", err)
}
tz = loadedTZ
}
out := make([]fiber.Map, 0, len(times))
for _, item := range times {
out = append(out, fiber.Map{
"date": item.Date.In(tz).Format(time.RFC3339),
"fajr": formatHHMM(item.Fajr, tz),
"sun": formatHHMM(item.Sunrise, tz),
"dhuhr": formatHHMM(item.Dhuhr, tz),
"asr": formatHHMM(item.Asr, tz),
"maghrib": formatHHMM(item.Maghrib, tz),
"isha": formatHHMM(item.Isha, tz),
})
}
return out, nil
}
func respondDeprecatedEndpoint(ctx fiber.Ctx, replacementPath string) error {
setDeprecationHeaders(ctx, replacementPath)
return ctx.Status(http.StatusGone).JSON(httpError{
Message: "This endpoint has been removed. Use " + replacementPath,
})
}
func setDeprecationHeaders(ctx fiber.Ctx, replacementPath string) {
ctx.Response().Header.Set("Deprecation", "true")
ctx.Response().Header.Set("Sunset", "Wed, 30 Sep 2026 23:59:59 GMT")
ctx.Response().Header.Set("Link", "<"+replacementPath+">; rel=\"successor-version\"")
}
-38
View File
@@ -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
}
-32
View File
@@ -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 {
}
+49 -28
View File
@@ -4,52 +4,59 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
_ "modernc.org/sqlite"
"prayertimes/internal/api" "prayertimes/internal/api"
"prayertimes/internal/database"
"prayertimes/internal/net" "prayertimes/internal/net"
"prayertimes/pkg/dbtimesprovider" "prayertimes/pkg/citydb"
"prayertimes/pkg/diyanet"
"prayertimes/pkg/diyanetapi" "prayertimes/pkg/diyanetapi"
"prayertimes/pkg/diyanetcalc"
"prayertimes/pkg/diyanetcitydb"
"prayertimes/pkg/diyanetprovider"
) )
func main() { func main() {
port := getDefaultEnv("PORT", "8000") if err := run(); err != nil {
log.Fatal().Err(err).Msg("failed to run app")
services, shutdown, err := newServices()
if err != nil {
log.Fatal().Err(err).Msg("failed to init services")
}
defer shutdown()
app := api.New(services)
err = app.Listen(":" + port)
if err != nil {
log.Fatal().Err(err).Msg("exit with an error")
} }
} }
func newServices() (api.Services, func(), error) { func run() error {
diyanetAPIProvider := diyanetapi.New(net.ReqClient) port := getDefaultEnv("PORT", "8000")
diyanetProvider := diyanet.New(net.GetParsed) dbPath := strings.TrimSpace(getDefaultEnv("DB_PATH", "./prayertimes.sqlite3"))
if dbPath == "" {
return fmt.Errorf("DB_PATH is not set")
}
db, err := database.NewSqliteDB(getDefaultEnv("DATABASE_URL", "app.sqlite3")) conn, err := newDB(dbPath)
if err != nil { if err != nil {
return api.Services{}, nil, fmt.Errorf("failed to init db: %w", err) return fmt.Errorf("failed to initialize database connection: %w", err)
}
defer conn.Close()
locationProvider := citydb.NewWithConn(conn)
legacyLocationProvider := diyanetcitydb.New(conn)
diyanetAPIProvider := diyanetapi.New(net.ReqClient)
diyanetCalcProvider := diyanetcalc.New()
prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider)
services := api.Services{
PrayerProvider: prayerProvider,
LocationProvider: locationProvider,
LegacyLocationProvider: legacyLocationProvider,
} }
if err := dbtimesprovider.Migrate(db.Db.(*sql.DB)); err != nil { app := api.New(services)
return api.Services{}, nil, fmt.Errorf("failed to migrate database: %w", err) if err := app.Listen(":" + port); err != nil {
return fmt.Errorf("failed to listen on http port: %w", err)
} }
return api.Services{ return nil
TimesProvider: dbtimesprovider.New(db, diyanetProvider),
LocationTimesProvider: diyanetAPIProvider,
}, func() {
defer db.Db.(*sql.DB).Close()
}, nil
} }
func getDefaultEnv(name string, defaultValue string) string { func getDefaultEnv(name string, defaultValue string) string {
@@ -59,3 +66,17 @@ func getDefaultEnv(name string, defaultValue string) string {
} }
return v return v
} }
func newDB(path string) (*sql.DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
return conn, nil
}
+165
View File
@@ -0,0 +1,165 @@
package citydb
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/doug-martin/goqu/v9"
_ "modernc.org/sqlite"
"prayertimes/pkg/prayer"
)
type Provider struct {
db *goqu.Database
own bool
}
func New(db *goqu.Database) Provider {
return Provider{db: db, own: false}
}
func NewWithConn(conn *sql.DB) Provider {
return Provider{db: goqu.New("sqlite3", conn), own: false}
}
func Open(path string) (Provider, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return Provider{}, fmt.Errorf("failed to open cities database: %w", err)
}
if err := conn.Ping(); err != nil {
return Provider{}, fmt.Errorf("failed to connect to cities database: %w", err)
}
return Provider{db: goqu.New("sqlite3", conn), own: true}, nil
}
func (p Provider) Close() error {
if !p.own {
return nil
}
if err := p.db.Db.(*sql.DB).Close(); err != nil {
return fmt.Errorf("failed to close cities database: %w", err)
}
return nil
}
type locationRow struct {
ID int `db:"geoname_id"`
Name string `db:"name"`
ASCIIName string `db:"ascii_name"`
AlternateNames string `db:"alternate_names"`
CountryCode string `db:"country_code"`
Timezone string `db:"timezone"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
DistanceSq float64 `db:"distance_sq"`
}
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) {
query = strings.TrimSpace(query)
if query == "" {
return []prayer.Location{}, nil
}
pattern := "%" + query + "%"
q := p.db.
From("cities").
Select(
goqu.I("geoname_id"),
goqu.I("name"),
goqu.I("ascii_name"),
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
goqu.I("country_code"),
goqu.I("timezone"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.V(0).As("distance_sq"),
).
Where(
goqu.Or(
goqu.L("name LIKE ? COLLATE NOCASE", pattern),
goqu.L("ascii_name LIKE ? COLLATE NOCASE", pattern),
goqu.L("alternate_names LIKE ? COLLATE NOCASE", pattern),
),
).
Order(goqu.I("population").Desc(), goqu.I("name").Asc()).
Limit(50)
var rows []locationRow
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return nil, fmt.Errorf("failed to query locations from cities database: %w", err)
}
locations := make([]prayer.Location, 0, len(rows))
for _, row := range rows {
locations = append(locations, prayer.Location{
ID: row.ID,
Name: row.Name,
ASCIIName: row.ASCIIName,
AlternateNames: row.AlternateNames,
CountryCode: row.CountryCode,
Timezone: row.Timezone,
Latitude: row.Latitude,
Longitude: row.Longitude,
})
}
return locations, nil
}
func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error) {
q := p.db.
From("cities").
Select(
goqu.I("geoname_id"),
goqu.I("name"),
goqu.I("ascii_name"),
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
goqu.I("country_code"),
goqu.I("timezone"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.L(
"((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?))",
coords.Latitude,
coords.Latitude,
coords.Longitude,
coords.Longitude,
).As("distance_sq"),
).
Order(
goqu.I("distance_sq").Asc(),
goqu.I("population").Desc(),
).
Limit(50)
var rows []locationRow
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return nil, fmt.Errorf("failed to query locations by coordinates from cities database: %w", err)
}
locations := make([]prayer.Location, 0, len(rows))
for _, row := range rows {
locations = append(locations, prayer.Location{
ID: row.ID,
Name: row.Name,
ASCIIName: row.ASCIIName,
AlternateNames: row.AlternateNames,
CountryCode: row.CountryCode,
Timezone: row.Timezone,
Latitude: row.Latitude,
Longitude: row.Longitude,
})
}
return locations, nil
}
-203
View File
@@ -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
}
-122
View File
@@ -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)
})
}
}
-28
View File
@@ -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);
-72
View File
@@ -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"
}
-125
View File
@@ -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)
}
})
}
+76 -23
View File
@@ -2,11 +2,13 @@ package diyanetapi
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
) )
@@ -20,34 +22,72 @@ func New(c *req.Client) Provider {
} }
} }
func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
locationID, err := d.getLocationIDByCoords(ctx, coords)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to resolve location by coordinates: %w", err)
}
result, err := d.Get(ctx, locationID)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by coordinates: %w", err)
}
result.Location.Latitude = coords.Latitude
result.Location.Longitude = coords.Longitude
return result, nil
}
func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coordinates) (string, error) {
res, err := d.http.NewRequest(). res, err := d.http.NewRequest().
SetContext(ctx). SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"latitude": fmt.Sprintf("%f", coords.Latitude), "latitude": fmt.Sprintf("%f", coords.Latitude),
"longitude": fmt.Sprintf("%f", coords.Longitude), "longitude": fmt.Sprintf("%f", coords.Longitude),
}). }).
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") Get("https://namazvakti.diyanet.gov.tr/api/ilce/GetByCoordinat")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by coords: %w", err) return "", fmt.Errorf("failed to get location by coords: %w", err)
} }
return d.parseResponse(res) var response struct {
Success bool `json:"success"`
ResultObject []struct {
ID int `json:"cityID"`
} `json:"resultObject"`
}
if err := res.Unmarshal(&response); err != nil {
return "", fmt.Errorf("failed to unmarshal city response: %w", err)
}
if !response.Success {
return "", fmt.Errorf("failed to get location by coordinates from upstream: %w", errors.New(res.String()))
}
if len(response.ResultObject) == 0 {
return "", fmt.Errorf("failed to resolve location by coordinates: %w", errors.New("empty location result"))
}
return fmt.Sprintf("%d", response.ResultObject[0].ID), nil
} }
func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { func (d Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
res, err := d.http.NewRequest(). res, err := d.http.NewRequest().
SetContext(ctx). SetContext(ctx).
SetQueryParam("ilceId", locationID). SetQueryParam("ilceId", locationID).
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by location id: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", err)
} }
return d.parseResponse(res) result, err := d.parseResponse(res)
if err != nil {
return prayer.TimesResult{}, fmt.Errorf("failed to parse prayer times response: %w", err)
}
result.Location.ID = parseIntOrZero(locationID)
return result, nil
} }
func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) { func (d Provider) parseResponse(res *req.Response) (prayer.TimesResult, error) {
var response struct { var response struct {
Success bool `json:"success"` Success bool `json:"success"`
ResultObject struct { ResultObject struct {
@@ -57,47 +97,60 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
} `json:"konum"` } `json:"konum"`
PrayerTimes []struct { PrayerTimes []struct {
Date time.Time `json:"miladi_tarih_uzun_Iso8601"` Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
DateIslamic string `json:"hicri_tarih_uzun"`
Fajr time.Time `json:"imsak"` Fajr time.Time `json:"imsak"`
Sunrise time.Time `json:"gunes"` Sunrise time.Time `json:"gunes"`
Dhuhr time.Time `json:"ogle"` Dhuhr time.Time `json:"ogle"`
Asr time.Time `json:"ikindi"` Asr time.Time `json:"ikindi"`
Sunset time.Time `json:"gunes_batis"`
Maghrib time.Time `json:"aksam"` Maghrib time.Time `json:"aksam"`
Isha time.Time `json:"yatsi"` 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
} }
+21 -7
View File
@@ -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)
} }
}) })
} }
+63
View File
@@ -0,0 +1,63 @@
package diyanetcalc
import (
"context"
"errors"
"fmt"
"math"
"time"
"prayertimes/pkg/prayer"
)
var errNotSupported = errors.New("not supported in calculation provider")
type Provider struct{}
func New() Provider {
return Provider{}
}
func (Provider) SearchLocations(_ context.Context, _ string) ([]prayer.Location, error) {
return nil, fmt.Errorf("failed to search locations: %w", errNotSupported)
}
func (Provider) Get(_ context.Context, _ string) (prayer.TimesResult, error) {
return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported)
}
func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
seq := CalculatePrayerTimes(CalculateParams{
Latitude: coords.Latitude,
Longitude: coords.Longitude,
StartingDay: time.Now().UTC().Truncate(24 * time.Hour),
})
times := make([]prayer.Times, 0, daysToGenerate)
for item := range seq {
times = append(times, prayer.Times{
Date: item.Date,
DateHijri: item.DateHijri,
Fajr: item.Fajr,
Sunrise: item.Sunrise,
Dhuhr: item.Dhuhr,
Asr: item.Asr,
Sunset: item.Sunset,
Maghrib: item.Maghrib,
Isha: item.Isha,
})
if len(times) == daysToGenerate {
break
}
}
return prayer.TimesResult{
Location: prayer.Location{
Latitude: coords.Latitude,
Longitude: coords.Longitude,
Timezone: fmt.Sprintf("UTC%+d", int(math.Round(coords.Longitude/degPerHour))),
},
Times: times,
}, nil
}
+244
View File
@@ -0,0 +1,244 @@
package diyanetcalc
import (
"iter"
"math"
"time"
"prayertimes/pkg/hijricalendar"
"github.com/samber/lo"
)
const (
daysToGenerate = 30
degPerHour = 15.0
j2000 = 2451545.0 // Julian Date of J2000.0 epoch (Jan 1.5, 2000)
)
// Diyanet angular criteria (post-1983 reform)
//
// imsakAngle: Sun 18° below horizon = start of astronomical twilight (Fajr).
// Pre-1983 used -19° + temkin buffer; now -18° with zero temkin.
const imsakAngle = -18.0
// ishaAngle: Sun 17° below horizon = shafaq al-ahmar (red twilight) gone.
const ishaAngle = -17.0
// sunAngle: combined refraction (~0.567°) + solar semi-diameter (~0.267°)
// correction applied at sunrise/sunset. Equivalent to 50 arcmin.
const sunAngle = -0.833
// Temkin - precautionary time buffers (minutes, post-1983 standardized values)
//
// Ensures a single published time remains valid across the full geographical
// extent of a city. Pre-1983 values were 10-20 min; the reform moderated them.
//
// Imsak: 0 - no buffer; avoids starting Fajr too early / fast too late
// Sunrise: -7 - subtracted, ensuring the Sun has fully cleared the horizon
// Dhuhr: +5 - Sun has clearly begun its descent
// Asr: +4 - accounts for local elevation and horizon obstacles
// Maghrib: +7 - Sun has completely set before breaking fast
// Isha: 0 - no buffer needed at this twilight stage
var temkin = struct{ Imsak, Sunrise, Dhuhr, Asr, Maghrib, Isha float64 }{
Imsak: 0, Sunrise: -7, Dhuhr: 5, Asr: 4, Maghrib: 7, Isha: 0,
}
type CalculateParams struct {
Latitude float64
Longitude float64
StartingDay time.Time
}
type Times struct {
Date time.Time
DateHijri string
Fajr time.Time
Sunrise time.Time
Dhuhr time.Time
Asr time.Time
Sunset time.Time
Maghrib time.Time
Isha time.Time
}
func CalculatePrayerTimes(params CalculateParams) iter.Seq[Times] {
startingDay := params.StartingDay.UTC().Truncate(24 * time.Hour)
if startingDay.IsZero() {
startingDay = time.Now().UTC().Truncate(24 * time.Hour)
}
return func(yield func(Times) bool) {
for day := startingDay; ; day = day.AddDate(0, 0, 1) {
c := prayerTimes(params.Latitude, params.Longitude, day)
if !yield(Times{
Date: day,
DateHijri: hijricalendar.ToISODate(day),
Fajr: lo.FromPtr(c.Imsak),
Sunrise: lo.FromPtr(c.Sunrise),
Dhuhr: lo.FromPtr(c.Dhuhr),
Asr: lo.FromPtr(c.Asr),
Sunset: lo.FromPtr(c.Sunset),
Maghrib: lo.FromPtr(c.Maghrib),
Isha: lo.FromPtr(c.Isha),
}) {
return
}
}
}
}
// julianDay converts a calendar date to a Julian Day Number.
//
// JDN is a continuous day count from Jan 1, 4713 BC, used in astronomy to
// avoid calendar-system ambiguities. January/February are treated as months
// 13/14 of the prior year. The Gregorian correction b = 2 - a + a/4 accounts
// for century-year leap-day rules introduced in 1582.
func julianDay(d time.Time) float64 {
y, m, day := d.Date()
if m <= 2 {
y--
m += 12
}
a := y / 100
b := 2 - a + a/4
return math.Floor(365.25*float64(y+4716)) +
math.Floor(30.6001*float64(m+1)) +
float64(day+b) - 1524.5
}
// sunParams computes solar declination and equation of time via Meeus Ch.25
// (~0.0003° accuracy - ~30x better than the USNO 6-term approximation).
//
// Returns:
// - delta: solar declination in degrees (Sun's angular distance north/south
// of the celestial equator; drives seasonal day length and noon altitude).
// - eot: equation of time in minutes (difference between apparent solar time
// and mean solar time; caused by orbital eccentricity + axial tilt; +-16 min).
//
// Source: Jean Meeus, "Astronomical Algorithms" 2nd ed., Chapter 25.
//
// Variables (degrees unless noted):
//
// T - Julian centuries since J2000.0
// L0 - geometric mean longitude of the Sun (Meeus eq. 25.2)
// M - mean anomaly (Meeus eq. 25.3)
// e - orbital eccentricity (Meeus eq. 25.4)
// C - equation of centre: true - mean anomaly (Meeus eq. 25.4)
// omega - Moon's ascending node longitude, used for nutation (Meeus eq. 25.11)
// lam - apparent longitude: true lon + nutation - aberration (Meeus eq. 25.9)
// eps - true obliquity of the ecliptic incl. nutation (Meeus eq. 22.2 / 25.8)
// EoT - Spencer/Meeus y-series (Meeus p.185), accurate to ~0.5 s
func sunParams(jd float64) (delta, eot float64) {
T := (jd - j2000) / 36525.0
L0 := math.Mod(280.46646+T*(36000.76983+T*0.0003032), 360)
M := math.Mod(357.52911+T*(35999.05029-T*0.0001537), 360)
Mr := rad(M)
e := 0.016708634 - T*(0.000042037+T*0.0000001267)
// Equation of centre: corrects uniform circular -> true elliptical motion.
C := (1.914602-T*(0.004817+T*0.000014))*math.Sin(Mr) +
(0.019993-T*0.000101)*math.Sin(2*Mr) +
0.000289*math.Sin(3*Mr)
omega := 125.04 - 1934.136*T // Moon's ascending node: drives nutation
// Apparent longitude: add nutation, subtract aberration (-0.00569°).
lam := rad(L0 + C - 0.00569 - 0.00478*math.Sin(rad(omega)))
// True obliquity: Laskar (1986) mean obliquity + nutation in obliquity.
eps0 := 84381.448 - T*(46.8150+T*(0.00059-T*0.001813)) // arcseconds
eps := rad(eps0/3600 + 0.00256*math.Cos(rad(omega)))
delta = deg(math.Asin(math.Sin(eps) * math.Sin(lam)))
// EoT via y-series (Spencer 1971 / Meeus p.185).
// y = tan²(e/2); multiply degrees by 4 to get minutes (1° = 4 min).
y, L0r := math.Pow(math.Tan(eps/2), 2), rad(L0)
eot = deg(y*math.Sin(2*L0r)-
2*e*math.Sin(Mr)+
4*e*y*math.Sin(Mr)*math.Cos(2*L0r)-
0.5*y*y*math.Sin(4*L0r)-
1.25*e*e*math.Sin(2*Mr)) * 4
return delta, eot
}
// hourAngle solves for the hour angle H (hours) at which the Sun reaches
// the given altitude, using the spherical law of cosines:
//
// cos H = (sin a - sin phi*sin delta) / (cos phi*cos delta)
//
// Returns (0, false) when |cos H| > 1 - the Sun never reaches that altitude
// (midnight sun / polar night). Diyanet resolves these via the Takdir method.
func hourAngle(altDeg, lat, delta float64) (float64, bool) {
cosH := (math.Sin(rad(altDeg)) - math.Sin(rad(lat))*math.Sin(rad(delta))) /
(math.Cos(rad(lat)) * math.Cos(rad(delta)))
if math.Abs(cosH) > 1 {
return 0, false
}
return deg(math.Acos(cosH)) / degPerHour, true
}
// asrAltitude returns the solar altitude at which Asr-i Avval begins.
//
// Diyanet (majority school): Asr starts when shadow length = object height +
// its shortest noon shadow (fey-i zeval). Shadow factor = 1 (Hanafi uses 2).
//
// cot a = 1 + tan|phi - delta| -> a = atan(1 / (1 + tan|phi - delta|))
func asrAltitude(lat, delta float64) float64 {
return deg(math.Atan(1 / (1 + math.Tan(rad(math.Abs(lat-delta))))))
}
type computedTimes struct {
Imsak, Sunrise, Dhuhr, Asr, Sunset, Maghrib, Isha *time.Time
}
// prayerTimes computes all Diyanet prayer times for the given date, returned
// as UTC-aware values. Solar noon is the central reference:
//
// T_noon(UTC) = 12 - lambda/15 - EoT/60
//
// Morning times (Imsak, Sunrise) = noon - H + temkin
// Afternoon/evening times = noon + H + temkin
func prayerTimes(lat, lon float64, d time.Time) computedTimes {
delta, eot := sunParams(julianDay(d))
noon := 12 - lon/degPerHour - eot/60
// offset converts a noon-relative hour angle to a UTC *time.Time,
// applying the given temkin (minutes). Returns nil for polar night/day.
offset := func(h float64, ok bool, sign int, tk float64) *time.Time {
if !ok {
return nil
}
t := utcTime(d, noon+float64(sign)*h+tk/60)
return &t
}
hSun, okSun := hourAngle(sunAngle, lat, delta)
hAsr, okAsr := hourAngle(asrAltitude(lat, delta), lat, delta)
hImsak, okImsak := hourAngle(imsakAngle, lat, delta)
hIsha, okIsha := hourAngle(ishaAngle, lat, delta)
tDhuhr := utcTime(d, noon+temkin.Dhuhr/60)
return computedTimes{
Imsak: offset(hImsak, okImsak, -1, temkin.Imsak),
Sunrise: offset(hSun, okSun, -1, temkin.Sunrise),
Dhuhr: &tDhuhr,
Asr: offset(hAsr, okAsr, +1, temkin.Asr),
Sunset: offset(hSun, okSun, +1, 0), // geometric sunset, no temkin
Maghrib: offset(hSun, okSun, +1, temkin.Maghrib),
Isha: offset(hIsha, okIsha, +1, temkin.Isha),
}
}
// utcTime converts decimal hours (e.g. 10.5 = 10:30) to a UTC time.Time.
func utcTime(d time.Time, hours float64) time.Time {
base := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC)
return base.Add(time.Duration(hours * float64(time.Hour)))
}
func rad(d float64) float64 { return d * math.Pi / 180 }
func deg(r float64) float64 { return r * 180 / math.Pi }
+125
View File
@@ -0,0 +1,125 @@
package diyanetcitydb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/doug-martin/goqu/v9"
"prayertimes/pkg/prayer"
)
var ErrLocationNotFound = errors.New("location not found")
type Provider struct {
db *goqu.Database
}
func New(conn *sql.DB) Provider {
return Provider{db: goqu.New("sqlite3", conn)}
}
type locationRow struct {
ID int `db:"id"`
Country string `db:"country"`
City string `db:"city"`
Region string `db:"region"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
Timezone string `db:"timezone"`
}
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.LegacyLocation, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
pattern := "%" + query + "%"
q := p.db.
From("diyanet_cities").
Select(
goqu.I("id"),
goqu.I("country"),
goqu.I("city"),
goqu.COALESCE(goqu.I("region"), "").As("region"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
).
Where(
goqu.Or(
goqu.L("country LIKE ? COLLATE NOCASE", pattern),
goqu.L("city LIKE ? COLLATE NOCASE", pattern),
goqu.L("COALESCE(region, '') LIKE ? COLLATE NOCASE", pattern),
),
).
Order(
goqu.I("country").Asc(),
goqu.I("city").Asc(),
goqu.I("region").Asc(),
goqu.I("id").Asc(),
).
Limit(100)
rows := make([]locationRow, 0)
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return nil, fmt.Errorf("failed to query diyanet locations: %w", err)
}
locations := make([]prayer.LegacyLocation, 0, len(rows))
for _, row := range rows {
locations = append(locations, prayer.LegacyLocation{
ID: row.ID,
Country: row.Country,
City: row.City,
Region: row.Region,
Latitude: row.Latitude,
Longitude: row.Longitude,
Timezone: row.Timezone,
})
}
return locations, nil
}
func (p Provider) GetLocationByID(ctx context.Context, id int) (prayer.LegacyLocation, error) {
q := p.db.
From("diyanet_cities").
Select(
goqu.I("id"),
goqu.I("country"),
goqu.I("city"),
goqu.COALESCE(goqu.I("region"), "").As("region"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.COALESCE(goqu.I("timezone"), "").As("timezone"),
).
Where(goqu.I("id").Eq(id)).
Limit(1)
var rows []locationRow
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return prayer.LegacyLocation{}, fmt.Errorf("failed to query location by id: %w", err)
}
if len(rows) == 0 {
return prayer.LegacyLocation{}, ErrLocationNotFound
}
row := rows[0]
location := prayer.LegacyLocation{
ID: row.ID,
Country: row.Country,
City: row.City,
Region: row.Region,
Latitude: row.Latitude,
Longitude: row.Longitude,
Timezone: row.Timezone,
}
return location, nil
}
+71
View File
@@ -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
}
+61
View File
@@ -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)
}
+45
View File
@@ -0,0 +1,45 @@
package hijricalendar
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToISODate(t *testing.T) {
tests := []struct {
name string
date time.Time
want string
}{
{
name: "ramadan 3 1447",
date: time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC),
want: "1447-09-03",
},
{
name: "ramadan 1 1447",
date: time.Date(2026, 2, 18, 0, 0, 0, 0, time.UTC),
want: "1447-09-01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToISODate(tt.date)
require.NotEmpty(t, got)
assert.Equal(t, tt.want, got)
})
}
}
func TestGregorianToIslamicRange(t *testing.T) {
y, m, d := GregorianToIslamic(2026, 2, 20)
assert.Positive(t, y)
assert.GreaterOrEqual(t, m, 1)
assert.LessOrEqual(t, m, 12)
assert.GreaterOrEqual(t, d, 1)
assert.LessOrEqual(t, d, 30)
}
+33 -67
View File
@@ -1,85 +1,51 @@
package prayer package prayer
import ( import (
"context"
"database/sql/driver"
"encoding/json"
"errors" "errors"
"time" "time"
) )
var ErrInvalidLocation = errors.New("invalid location") var ErrInvalidLocation = errors.New("invalid location")
type TimesProvider interface {
Get(ctx context.Context, location string) ([]Times, error)
Name() string
}
type Coordinates struct { type Coordinates struct {
Latitude float64 Latitude float64
Longitude float64 Longitude float64
} }
type LocationTimesProvider interface { type Location struct {
GetByCoords(ctx context.Context, coords Coordinates) ([]Times, error) ID int `json:"id"`
Name() string Name string `json:"name"`
} ASCIIName string `json:"ascii_name"`
AlternateNames string `json:"alternate_names"`
type Date time.Time CountryCode string `json:"country_code"`
Latitude float64 `json:"latitude"`
func (d Date) String() string { Longitude float64 `json:"longitude"`
return time.Time(d).Format(time.DateOnly) Timezone string `json:"timezone,omitempty"`
}
func (d *Date) Scan(src any) error {
switch v := src.(type) {
case []byte:
return json.Unmarshal(v, d)
case string:
return json.Unmarshal([]byte(v), d)
case time.Time:
*d = Date(v)
return nil
}
return nil
}
func (d *Date) Value() (driver.Value, error) {
return json.Marshal(d)
}
func (d *Date) UnmarshalJSON(bytes []byte) error {
var t time.Time
if err := json.Unmarshal(bytes, &t); err != nil {
return err
}
_, offset := t.Zone()
t = t.Add(time.Duration(offset * int(time.Second)))
t = t.UTC()
*d = Date(t)
return nil
}
func (d Date) MarshalJSON() ([]byte, error) {
t := time.Time(d)
return json.Marshal(t.Format(time.DateOnly))
}
func (d Date) Time() time.Time {
t := time.Time(d)
_, offset := t.Zone()
t = t.Add(time.Duration(offset * int(time.Second)))
t = t.UTC()
return t
} }
type Times struct { type Times struct {
Date string `json:"date"` Date time.Time `json:"date"`
Fajr string `json:"fajr"` DateHijri string `json:"date_hijri"`
Sunrise string `json:"sunrise"` Fajr time.Time `json:"fajr"`
Dhuhr string `json:"dhuhr"` Sunrise time.Time `json:"sunrise"`
Asr string `json:"asr"` Dhuhr time.Time `json:"dhuhr"`
Maghrib string `json:"maghrib"` Asr time.Time `json:"asr"`
Isha string `json:"isha"` Sunset time.Time `json:"sunset,omitempty"`
Maghrib time.Time `json:"maghrib"`
Isha time.Time `json:"isha"`
}
type TimesResult struct {
Location Location `json:"location"`
Times []Times `json:"prayertimes"`
}
type LegacyLocation struct {
ID int
Country string
City string
Region string
Latitude float64
Longitude float64
Timezone string
} }
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Download GeoNames cities5000 from https://download.geonames.org/export/dump/ and seed a SQLite database."""
from __future__ import annotations
import argparse
import io
import sqlite3
import sys
import urllib.request
import zipfile
from pathlib import Path
SOURCE_URL = "https://download.geonames.org/export/dump/cities5000.zip"
ARCHIVE_ENTRY = "cities5000.txt"
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS cities (
geoname_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
ascii_name TEXT NOT NULL,
alternate_names TEXT,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
feature_class TEXT,
feature_code TEXT,
country_code TEXT,
cc2 TEXT,
admin1_code TEXT,
admin2_code TEXT,
admin3_code TEXT,
admin4_code TEXT,
population INTEGER,
elevation INTEGER,
dem INTEGER,
timezone TEXT,
modification_date TEXT
)
"""
CREATE_INDEXES_SQL = (
"CREATE INDEX IF NOT EXISTS idx_cities_name ON cities(name)",
"CREATE INDEX IF NOT EXISTS idx_cities_ascii_name ON cities(ascii_name)",
"CREATE INDEX IF NOT EXISTS idx_cities_alternate_names ON cities(alternate_names)",
"CREATE INDEX IF NOT EXISTS idx_cities_country_code ON cities(country_code)",
"CREATE INDEX IF NOT EXISTS idx_cities_population ON cities(population DESC)",
)
INSERT_SQL = """
INSERT OR REPLACE INTO cities (
geoname_id,
name,
ascii_name,
alternate_names,
latitude,
longitude,
feature_class,
feature_code,
country_code,
cc2,
admin1_code,
admin2_code,
admin3_code,
admin4_code,
population,
elevation,
dem,
timezone,
modification_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Seed GeoNames cities5000 into SQLite")
parser.add_argument("--db", required=True, help="Target SQLite database path")
parser.add_argument(
"--batch-size", type=int, default=1000, help="Insert batch size"
)
return parser.parse_args()
def to_int(value: str) -> int | None:
value = value.strip()
if value == "":
return None
return int(value)
def to_float(value: str) -> float:
return float(value.strip())
def row_from_columns(columns: list[str]) -> tuple:
return (
int(columns[0]),
columns[1],
columns[2],
columns[3],
to_float(columns[4]),
to_float(columns[5]),
columns[6],
columns[7],
columns[8],
columns[9],
columns[10],
columns[11],
columns[12],
columns[13],
to_int(columns[14]),
to_int(columns[15]),
to_int(columns[16]),
columns[17],
columns[18],
)
def download_archive() -> bytes:
with urllib.request.urlopen(SOURCE_URL) as response:
return response.read()
def stream_city_rows(archive_bytes: bytes):
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
with zf.open(ARCHIVE_ENTRY) as raw_file:
for raw_line in raw_file:
line = raw_line.decode("utf-8").rstrip("\n")
if not line:
continue
columns = line.split("\t")
if len(columns) != 19:
continue
yield row_from_columns(columns)
def seed_database(db_path: Path, batch_size: int) -> int:
archive = download_archive()
inserted = 0
batch = []
db_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(str(db_path)) as conn:
cursor = conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute(CREATE_TABLE_SQL)
for sql in CREATE_INDEXES_SQL:
cursor.execute(sql)
for row in stream_city_rows(archive):
batch.append(row)
if len(batch) >= batch_size:
cursor.executemany(INSERT_SQL, batch)
inserted += len(batch)
batch.clear()
if batch:
cursor.executemany(INSERT_SQL, batch)
inserted += len(batch)
conn.commit()
return inserted
def main() -> int:
args = parse_args()
try:
inserted = seed_database(Path(args.db), args.batch_size)
except Exception as exc: # noqa: BLE001
print(f"failed to seed cities database: {exc}", file=sys.stderr)
return 1
print(f"seeded {inserted} city rows into {args.db}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+72 -18
View File
@@ -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'
+253 -92
View File
@@ -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());
}
+98 -6
View File
@@ -7,6 +7,60 @@ body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
.location-search {
position: relative;
max-width: 38rem;
margin: 1rem auto;
padding: 0 1rem;
}
.location-search__input {
width: 100%;
border: 1px solid #ddd;
border-radius: 0.75rem;
padding: 0.75rem 0.875rem;
font: inherit;
}
.location-search__dropdown {
position: absolute;
left: 1rem;
right: 1rem;
top: calc(100% + 0.25rem);
background: #fff;
border: 1px solid #ddd;
border-radius: 0.75rem;
max-height: 18rem;
overflow: auto;
z-index: 5;
}
.location-search__results {
list-style: none;
margin: 0;
padding: 0;
}
.location-search__item {
width: 100%;
border: 0;
border-bottom: 1px solid #f0f0f0;
background: transparent;
text-align: left;
padding: 0.625rem 0.75rem;
font: inherit;
cursor: pointer;
}
.location-search__item:hover {
background: #fafafa;
}
.location-search__state {
padding: 0.75rem;
color: #666;
}
p { p {
margin: 0; margin: 0;
} }
@@ -39,6 +93,21 @@ p + p {
margin: auto; margin: auto;
} }
@media (max-width: 768px) {
.table-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.salath-table {
font-size: 1.375rem;
}
.clock {
font-size: 3rem;
}
}
.salath-table { .salath-table {
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
@@ -63,14 +132,37 @@ td {
.salath-name { .salath-name {
} }
.current { .salath-name-group {
color: tomato; display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.salath-name-alternatives {
line-height: 1.1;
}
.salath-name-alternatives span + span:before {
content: " / ";
opacity: 0.5;
} }
.salath-name + .salath-name:before { .salath-name-group--center {
content: '/'; align-items: center;
opacity: 0.25; }
margin: 0 0.5em;
.salath-name--primary {
line-height: 1.05;
}
.salath-name--secondary {
font-size: 0.58em;
font-weight: 500;
opacity: 0.75;
line-height: 1.1;
}
.current {
color: tomato;
} }
.salath-time { .salath-time {