diff --git a/go.mod b/go.mod index 6b73bb1..4ca52ae 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,18 @@ module prayertimes go 1.25.0 require ( + github.com/doug-martin/goqu/v9 v9.19.0 github.com/gofiber/fiber/v3 v3.0.0 github.com/imroc/req/v3 v3.57.0 github.com/rs/zerolog v1.29.0 github.com/stretchr/testify v1.11.1 + modernc.org/sqlite v1.46.1 ) require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gofiber/schema v1.6.0 // indirect github.com/gofiber/utils/v2 v2.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect @@ -21,17 +24,23 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect github.com/refraction-networking/utls v1.8.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 18b528c..88a937f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ +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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo= +github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= @@ -13,13 +22,18 @@ github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= @@ -30,12 +44,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= +github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -47,6 +66,8 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1 github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -54,6 +75,10 @@ github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg= github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= @@ -68,23 +93,67 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api.go b/internal/api/api.go index 73748da..c21c24a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -13,7 +13,7 @@ import ( "github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/favicon" "github.com/gofiber/fiber/v3/middleware/logger" - recover "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static" "prayertimes/pkg/prayer" @@ -25,12 +25,16 @@ type httpError struct { } type Services struct { - Provider DiyanetProvider + PrayerProvider PrayerProvider + LocationProvider LocationProvider } -type DiyanetProvider interface { +type PrayerProvider interface { Get(ctx context.Context, locationID string) ([]prayer.Times, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) +} + +type LocationProvider interface { SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) } @@ -82,7 +86,7 @@ func New(services Services) *fiber.App { switch { case locationID != "": - times, err = services.Provider.Get(ctx.Context(), locationID) + times, err = services.PrayerProvider.Get(ctx.Context(), locationID) case latitude != "" && longitude != "": lat, latErr := strconv.ParseFloat(latitude, 64) if latErr != nil { @@ -93,7 +97,7 @@ func New(services Services) *fiber.App { return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr)) } - times, err = services.Provider.GetByCoords(ctx.Context(), prayer.Coordinates{ + times, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: lat, Longitude: lng, }) @@ -124,7 +128,7 @@ func New(services Services) *fiber.App { return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest) } - locations, err := services.Provider.SearchLocations(ctx.Context(), query.Text) + locations, err := services.LocationProvider.SearchLocations(ctx.Context(), query.Text) if err != nil { return fmt.Errorf("failed to search locations: %w", err) } diff --git a/main.go b/main.go index 986a9ea..5831ca0 100644 --- a/main.go +++ b/main.go @@ -1,40 +1,54 @@ package main import ( + "fmt" "os" + "strings" "github.com/rs/zerolog/log" "prayertimes/internal/api" "prayertimes/internal/net" + "prayertimes/pkg/citydb" "prayertimes/pkg/diyanetapi" "prayertimes/pkg/diyanetcalc" "prayertimes/pkg/diyanetprovider" ) func main() { - port := getDefaultEnv("PORT", "8000") + if err := run(); err != nil { + log.Fatal().Err(err).Msg("failed to run app") + } +} - services, err := newServices() - if err != nil { - log.Fatal().Err(err).Msg("failed to init services") +func run() error { + port := getDefaultEnv("PORT", "8000") + dbPath := strings.TrimSpace(getDefaultEnv("DB_PATH", "./prayertimes.sqlite3")) + if dbPath == "" { + return fmt.Errorf("DB_PATH is not set") } - app := api.New(services) - err = app.Listen(":" + port) + locationProvider, err := citydb.Open(dbPath) if err != nil { - log.Fatal().Err(err).Msg("exit with an error") + return fmt.Errorf("failed to open city database: %w", err) } -} + defer locationProvider.Close() -func newServices() (api.Services, error) { diyanetAPIProvider := diyanetapi.New(net.ReqClient) diyanetCalcProvider := diyanetcalc.New() - provider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider) + prayerProvider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider) + + services := api.Services{ + PrayerProvider: prayerProvider, + LocationProvider: locationProvider, + } + + app := api.New(services) + if err := app.Listen(":" + port); err != nil { + return fmt.Errorf("failed to listen on http port: %w", err) + } - return api.Services{ - Provider: provider, - }, nil + return nil } func getDefaultEnv(name string, defaultValue string) string { diff --git a/pkg/citydb/provider.go b/pkg/citydb/provider.go new file mode 100644 index 0000000..e03b63e --- /dev/null +++ b/pkg/citydb/provider.go @@ -0,0 +1,99 @@ +package citydb + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/doug-martin/goqu/v9" + + _ "modernc.org/sqlite" + + "prayertimes/pkg/prayer" +) + +type Provider struct { + db *goqu.Database +} + +func New(db *goqu.Database) Provider { + return Provider{db: db} +} + +func Open(path string) (Provider, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return Provider{}, fmt.Errorf("failed to open cities database: %w", err) + } + + if err := conn.Ping(); err != nil { + return Provider{}, fmt.Errorf("failed to connect to cities database: %w", err) + } + + return New(goqu.New("sqlite3", conn)), nil +} + +func (p Provider) Close() error { + return p.db.Db.(*sql.DB).Close() +} + +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"` + Latitude float64 `db:"latitude"` + Longitude float64 `db:"longitude"` +} + +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("latitude"), + goqu.I("longitude"), + ). + 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, + Latitude: row.Latitude, + Longitude: row.Longitude, + }) + } + + return locations, nil +} diff --git a/pkg/diyanetapi/provider.go b/pkg/diyanetapi/provider.go index 6cd425c..17a9624 100644 --- a/pkg/diyanetapi/provider.go +++ b/pkg/diyanetapi/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/imroc/req/v3" @@ -67,61 +66,6 @@ func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coord return fmt.Sprintf("%d", response.ResultObject[0].ID), nil } -func (d Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) { - query = strings.TrimSpace(query) - if query == "" { - return []prayer.Location{}, nil - } - - res, err := d.http.NewRequest(). - SetContext(ctx). - SetQueryParam("searchText", query). - Get("https://namazvakti.diyanet.gov.tr/api/Search/GetByName") - if err != nil { - return nil, fmt.Errorf("failed to search locations: %w", err) - } - - var response struct { - Success bool `json:"success"` - ResultObject struct { - Results []struct { - ID int `json:"cityID"` - CityNameTR string `json:"cityNameTR"` - StateNameTR string `json:"stateNameTR"` - CountryNameTR string `json:"countryNameTR"` - CityNameEN string `json:"cityNameEN"` - StateNameEN string `json:"stateNameEN"` - CountryNameEN string `json:"countryNameEN"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - } `json:"results"` - } `json:"resultObject"` - } - if err := res.Unmarshal(&response); err != nil { - return nil, fmt.Errorf("failed to unmarshal search response: %w", err) - } - if !response.Success { - return nil, fmt.Errorf("failed to search locations in upstream: %w", errors.New(res.String())) - } - - locations := make([]prayer.Location, 0, len(response.ResultObject.Results)) - for _, it := range response.ResultObject.Results { - locations = append(locations, prayer.Location{ - ID: it.ID, - NameTR: formatLocationName(it.CountryNameTR, it.StateNameTR, it.CityNameTR), - NameEN: formatLocationName(it.CountryNameEN, it.StateNameEN, it.CityNameEN), - Latitude: it.Latitude, - Longitude: it.Longitude, - }) - } - - return locations, nil -} - -func formatLocationName(country, state, city string) string { - return fmt.Sprintf("%s / %s / %s", strings.TrimSpace(country), strings.TrimSpace(state), strings.TrimSpace(city)) -} - func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { res, err := d.http.NewRequest(). SetContext(ctx). diff --git a/pkg/diyanetprovider/provider.go b/pkg/diyanetprovider/provider.go index 731433d..eee277e 100644 --- a/pkg/diyanetprovider/provider.go +++ b/pkg/diyanetprovider/provider.go @@ -12,7 +12,6 @@ import ( type APIProvider interface { Get(ctx context.Context, locationID string) ([]prayer.Times, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) - SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) } type FallbackProvider interface { @@ -29,18 +28,11 @@ func New(api APIProvider, fallback FallbackProvider) Provider { return Provider{ api: api, fallback: fallback, - timeout: time.Second, + timeout: 2 * time.Second, } } -func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) { - locations, err := p.api.SearchLocations(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to search locations from api provider: %w", err) - } - - return locations, nil -} +var ErrEmptyTimes = errors.New("diyanet did not return any prayer times") func (p Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout) @@ -51,7 +43,7 @@ func (p Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, e return nil, fmt.Errorf("failed to get prayer times from api provider: %w", err) } if len(times) == 0 { - return nil, fmt.Errorf("failed to get prayer times from api provider: %w", errors.New("empty prayer times result")) + return nil, ErrEmptyTimes } return times, nil @@ -66,16 +58,14 @@ func (p Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([ return times, nil } - fallbackTimes, fallbackErr := p.fallback.GetByCoords(ctx, coords) - if fallbackErr != nil { - if err != nil { - return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", errors.Join(err, fallbackErr)) - } - return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", fallbackErr) + times, err = p.fallback.GetByCoords(ctx, coords) + if err != nil { + return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", err) } - if len(fallbackTimes) == 0 { - return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", errors.New("empty prayer times result")) + + if len(times) == 0 { + return nil, fmt.Errorf("fallback provider did not return any prayer times") } - return fallbackTimes, nil + return times, nil } diff --git a/pkg/prayer/types.go b/pkg/prayer/types.go index c6303fe..66fd216 100644 --- a/pkg/prayer/types.go +++ b/pkg/prayer/types.go @@ -12,11 +12,13 @@ type Coordinates struct { } type Location struct { - ID int `json:"id"` - NameTR string `json:"name_tr"` - NameEN string `json:"name_en"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` + ID int `json:"id"` + Name string `json:"name"` + ASCIIName string `json:"ascii_name"` + AlternateNames string `json:"alternate_names"` + CountryCode string `json:"country_code"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` } type Times struct { diff --git a/scripts/seed_cities.py b/scripts/seed_cities.py new file mode 100644 index 0000000..1058b47 --- /dev/null +++ b/scripts/seed_cities.py @@ -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())