Merge branch 'main' into single_file_link
@@ -10,6 +10,16 @@
|
||||
{
|
||||
"title": "3Commas"
|
||||
},
|
||||
{
|
||||
"title": "Accredible",
|
||||
"slug": "accredible",
|
||||
"altNames": [
|
||||
"Accredible Certificates",
|
||||
"Accredible Badges",
|
||||
"Digital Credentials",
|
||||
"certificates.zaka.ai"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Addy.io",
|
||||
"slug": "addy_io"
|
||||
@@ -71,6 +81,13 @@
|
||||
],
|
||||
"hex": "fd4b2d"
|
||||
},
|
||||
{
|
||||
"title": "Autenticacion Digital",
|
||||
"slug": "autenticacion-digital",
|
||||
"altNames": [
|
||||
"autenticaciondigital.and.gov.co"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "availity"
|
||||
},
|
||||
@@ -279,6 +296,13 @@
|
||||
"title": "CERN",
|
||||
"slug": "cern"
|
||||
},
|
||||
{
|
||||
"title": "Chaturbate",
|
||||
"slug": "chaturbate",
|
||||
"altNames": [
|
||||
"Chaturbate.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ChangeNOW"
|
||||
},
|
||||
@@ -436,7 +460,7 @@
|
||||
"title": "emeritihealth",
|
||||
"altNames": [
|
||||
"Emeriti Health",
|
||||
"Emeriti Retirement Health",
|
||||
"Emeriti Retirement Health"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -742,6 +766,15 @@
|
||||
{
|
||||
"title": "Letterboxd"
|
||||
},
|
||||
{
|
||||
"title": "LifeMiles",
|
||||
"slug": "lifemiles",
|
||||
"altNames": [
|
||||
"Life Miles",
|
||||
"lifemiles.com",
|
||||
"Avianca LifeMiles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "lincolnfinancial",
|
||||
"altNames": [
|
||||
@@ -1295,6 +1328,19 @@
|
||||
"PAYDAY 3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Startmail",
|
||||
"slug": "startmail"
|
||||
},
|
||||
{
|
||||
"title": "Stripchat",
|
||||
"slug": "stripchat",
|
||||
"altNames": [
|
||||
"Strip Chat",
|
||||
"stripchat.com",
|
||||
"StripChat Live"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "STRATO",
|
||||
"hex": "FF8800"
|
||||
@@ -1313,6 +1359,9 @@
|
||||
"T-Mobile ID"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tableau"
|
||||
},
|
||||
{
|
||||
"title": "TCPShield"
|
||||
},
|
||||
@@ -1522,6 +1571,12 @@
|
||||
{
|
||||
"title": "WYZE"
|
||||
},
|
||||
{
|
||||
"title": "X",
|
||||
"altNames": [
|
||||
"Twitter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Xbox",
|
||||
"hex": "107C10"
|
||||
@@ -1577,6 +1632,14 @@
|
||||
"title": "xAI",
|
||||
"slug": "xai"
|
||||
},
|
||||
{
|
||||
"title": "XVideos",
|
||||
"slug": "xvideos",
|
||||
"altNames": [
|
||||
"X Videos",
|
||||
"xvideos.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cronometer",
|
||||
"slug": "cronometer"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 240 240" version="1.1" viewBox="0 0 240 240" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1 214c0-46.021 0-92.042 0.27671-138.19 0.74988-0.75875 1.4558-1.3222 1.6616-2.0308 3.4637-11.931 9.7863-22.061 19.083-30.291 10.734-9.5027 23.139-15.388 37.612-15.425 60.455-0.15068 120.91-0.063334 181.37-0.063242 0 45.688 0 91.375-0.29245 137.19-2.9994 6.1167-5.1585 12.433-8.5123 18.034-11.681 19.506-28.965 30.541-51.873 30.666-59.773 0.32539-119.55 0.10936-179.32 0.10925m151.5-107h36.106v-14.744h-126.3v14.744h90.195m15.11 63.164c1.9138 0.27864 3.8409 0.86449 5.7391 0.78206 7.6904-0.33391 15.992-9.1312 15.551-16.275-0.4837-7.8375-8.5467-15.644-16.184-15.67-8.5565-0.02861-14.433 4.8247-16.567 13.682-1.4658 6.0848 3.2382 13.739 11.461 17.481z" fill="#5B4DFC"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="50%" y1="23.373%" x2="50%" y2="85.937%" id="a"><stop stop-color="#02729D" offset="0%"/><stop stop-color="#00333F" offset="100%"/></linearGradient><linearGradient x1="63.263%" y1="134.632%" x2="51.527%" y2="36.554%" id="b"><stop stop-color="#FFE5BE" offset="0%"/><stop stop-color="#FEBE1D" offset="58%"/><stop stop-color="#FCAE1E" offset="69%"/><stop stop-color="#F58420" offset="92%"/><stop stop-color="#F37321" offset="100%"/></linearGradient></defs><g fill="none"><path d="M46.126 61.143c-13.37 0-22.008-10.053-22.008-25.606.006-1.882.137-3.762.392-5.626C9.072 27.993 0 22.686 0 15.412c0-4.811 3.898-9.02 11.91-12.899L17.094 0l.207 5.767c.278 7.462 5.656 11.056 10.286 12.777C32.346 7.41 41.096.644 51.042.644c10.545 0 15.264 5.889 15.264 11.726 0 7.621-7.984 15.956-23.278 17.848-1.225 3.027-1.936 6.148-1.936 8.642 0 4.753 2.143 4.753 3.424 4.753 1.799 0 6.192-3.657 6.848-7.725l.507-3.168h3.209c6.214 0 10.234 3.797 10.234 9.679-.004 7.64-7.477 18.744-19.188 18.744Z" fill="url(#a)" transform="translate(6 15)"/><path d="M44.516 47.374c4.038 0 9.675-5.364 10.564-10.894 4.2 0 6.47 2.221 6.47 5.923 0 5.807-6.026 14.986-15.428 14.986-12.717 0-18.247-10.286-18.247-21.838a41.255 41.255 0 0 1 1.051-8.957c-14.435-1.162-25.17-5.53-25.17-11.17.008-2.936 2.94-6.208 9.798-9.524.33 9.29 7.185 15.101 16.308 17.252 3.372-10.23 10.894-18.747 21.18-18.747 7.632 0 11.503 3.65 11.503 7.961 0 5.922-7.243 13.162-22.063 14.32-1.88 3.872-3.154 8.35-3.154 12.167 0 4.815 1.991 8.52 7.188 8.52ZM42.03 23.98c7.402-.551 9.993-2.876 9.993-4.534 0-.94-.884-1.714-2.157-1.714-2.691 0-5.567 2.598-7.836 6.248Z" fill="url(#b)" transform="translate(6 15)"/><path d="M36.41 41.587a1.377 1.377 0 0 1 1.233 1.676 39.89 39.89 0 0 0-1.018 8.654c0 5.6 1.436 11.419 4.915 15.427-4.497-3.949-6.292-10.5-6.292-16.793a39.882 39.882 0 0 1 1.018-8.653c.023-.102.034-.207.033-.311Zm25.769 11.33c1.68.196 2.832.877 3.453 2.024a5.415 5.415 0 0 0-2.08-.648c-1.506 5.552-7.047 10.834-11.659 10.834-2.902 0-5.137-1.036-6.596-2.95 1.362 1.03 3.117 1.573 5.223 1.573 4.626 0 10.153-5.281 11.659-10.833Zm-5.134-32.135c4.33 0 7.577 1.288 9.12 3.442-1.773-1.325-4.441-2.066-7.747-2.066-10.323 0-17.026 9.198-19.872 17.793a1.377 1.377 0 0 1-1.625.91c-.359-.085-.703-.188-1.051-.281a1.362 1.362 0 0 0-.837-.37l-.618-.056c-4.293-1.395-7.799-3.586-10.312-6.41 2.898 2.657 6.77 4.637 11.449 5.748a1.377 1.377 0 0 0 1.625-.91c2.842-8.606 9.545-17.8 19.868-17.8Zm2.073 12.41a2.99 2.99 0 0 1 1.673 2.617c-.001.124-.01.247-.026.37a24.799 24.799 0 0 1-3.779 1.85c1.714-1.08 2.428-2.38 2.428-3.582 0-.436-.101-.865-.296-1.255ZM18.366 23.047c.1.65.235 1.293.404 1.928-4.053 2.35-6.248 4.723-6.248 6.81a3.83 3.83 0 0 0 .877 2.329c-1.458-1.162-2.258-2.414-2.258-3.702 0-2.25 2.547-4.834 7.225-7.365Z" fill-opacity=".4" fill="#FFF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
101
mobile/apps/auth/assets/custom-icons/icons/lifemiles.svg
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 26.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1000 425.96859"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="lifemiles-logo-white.svg"
|
||||
width="1000"
|
||||
height="425.9686"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
||||
id="metadata39"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs37">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</defs><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#111111"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview35"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:zoom="0.21047873"
|
||||
inkscape:cx="874.3819"
|
||||
inkscape:cy="-478.96841"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1"><inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid80"
|
||||
originx="112"
|
||||
originy="919.50723" /></sodipodi:namedview>
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#2c3d47;fill-opacity:1;stroke-width:17.97037315"
|
||||
id="path10"
|
||||
d="m 503.10835,200.17953 c -10.36162,0 -19.4727,2.34995 -27.33325,7.23061 -7.8605,4.88066 -14.64912,10.48438 -20.18731,16.81116 1.6079,6.86908 2.67979,14.64198 3.21574,23.49947 0.53591,8.85749 0.89324,17.35347 0.89324,25.30714 v 152.56585 h -95.57696 v -154.3735 c 0,-24.58407 -3.03703,-42.47983 -9.28969,-54.0488 -6.0741,-11.38821 -16.97163,-17.17269 -32.51405,-17.17269 -10.0043,0 -18.57946,2.16919 -25.90406,6.68831 -7.32459,4.51913 -13.21998,9.94208 -17.50752,16.26887 V 425.59376 H 183.50616 V 129.6811 h 91.82531 v 39.22605 c 9.11113,-16.0881 21.08054,-27.47631 36.08706,-34.3454 14.82782,-6.86907 30.01291,-10.30361 45.55533,-10.30361 19.11543,0 34.83648,3.97684 47.34185,11.74974 12.50539,7.7729 24.29616,20.60723 35.5511,38.32222 20.7232,-33.44156 53.05854,-50.07196 97.18473,-50.07196 19.11537,0 35.19376,4.1576 48.2351,12.29204 13.04134,8.3152 23.76025,19.16112 31.79944,32.89926 8.21784,13.73816 13.93458,29.46473 17.50754,47.36049 3.573,17.89575 5.35947,36.69535 5.35947,56.39874 v 152.56589 h -95.39833 v -154.5543 c 0,-24.58407 -3.03703,-42.47983 -9.28969,-54.0488 -5.89539,-11.38821 -16.79299,-16.99193 -32.15672,-16.99193"
|
||||
class="st0" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#2c3d47;fill-opacity:1;stroke-width:18.07651901"
|
||||
id="path12"
|
||||
d="m 94.576402,332.35756 c 0,12.5291 8.918858,22.3039 25.860738,22.3039 h 65.76015 l -2.21663,70.9323 H 120.43714 C 81.005734,425.59376 0,419.93116 0,349.78556 V 0 h 94.576402 z"
|
||||
class="st0"
|
||||
sodipodi:nodetypes="ccccccccc" /><path
|
||||
class="st0"
|
||||
d="m 733.71651,296.61048 c 29.55738,-29.55737 77.63263,-29.55737 107.19006,0 29.55734,29.5574 29.55734,77.63268 0,107.19008 -29.55743,29.5574 -77.63268,29.5574 -107.19006,0 -29.55738,-29.7355 -29.55738,-77.63268 0,-107.19008"
|
||||
id="path24"
|
||||
style="fill:#ff1d1e;fill-opacity:1;stroke-width:17.80565071"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
class="st0"
|
||||
d="m 733.71651,22.93762 c 29.55738,-29.55739 77.63263,-29.55739 107.19006,0 29.55734,29.55739 29.55734,77.63263 0,107.19003 -29.55743,29.55737 -77.63268,29.55737 -107.19006,0 -29.55738,-29.73544 -29.55738,-77.63264 0,-107.19003"
|
||||
id="path26"
|
||||
style="fill:#ff1d1e;fill-opacity:1;stroke-width:17.80565071"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
class="st0"
|
||||
d="m 870.64206,159.68502 c 29.55734,-29.55737 77.63259,-29.55737 107.18993,0 29.55731,29.55739 29.55731,77.63264 0,107.19001 -29.55734,29.5574 -77.63259,29.5574 -107.18993,0 -29.73549,-29.55737 -29.73549,-77.63262 0,-107.19001"
|
||||
id="path28"
|
||||
style="fill:#ff1d1e;fill-opacity:1;stroke-width:17.80565071"
|
||||
inkscape:connector-curvature="0" /></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
15
mobile/apps/auth/assets/custom-icons/icons/startmail.svg
Executable file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="500px" height="500px" viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#6573FF;}
|
||||
.st1{fill:#202945;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M500,47.2C500,20.9,478.6,0,452.9,0H47.7C21.4-0.5,0,20.9,0,47.2v43.9c0,0,186.4,180.6,250.5,180.6
|
||||
C319.6,271.7,500,92.2,500,92.2S500,56.5,500,47.2z"/>
|
||||
<path class="st1" d="M0,452.8C0,479.1,21.4,500,47.2,500h405.6c26.3,0,47.2-21.4,47.2-47.2V142.7c0,0-159.2,184.4-249.7,184.4
|
||||
C160.8,327.1,0,178.4,0,178.4C0,236.6,0,395.2,0,452.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 843 B |
1
mobile/apps/auth/assets/custom-icons/icons/stripchat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><path d="M44.86 8.486c23.44 0 42.423 15.87 42.423 35.463 0 18.47-16.877 33.633-38.366 35.318-.749.072-1.57-.61-1.57-1.406V57c0-1.032.538-1.853 1.31-2.602l15.893-17.04c1.172-1.267.26-2.275-.513-2.275H24.893c-.749 0-1.901 1.008-.47 2.506l15.892 16.78c.773.773 1.032 1.546 1.032 2.602v20.602c0 .797-.869 1.641-1.545 1.57a32.528 32.528 0 0 1-3.399-.447c-10.08-1.709-2.673 2.414-28.968 7.22 6.048-11.343 10.455-13.969 5.626-18.514A32.292 32.292 0 0 1 2.49 43.915c0-19.593 18.984-35.462 42.375-35.462v.033h-.005Z" fill="#A02831"/></svg>
|
||||
|
After Width: | Height: | Size: 590 B |
52
mobile/apps/auth/assets/custom-icons/icons/tableau.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 100.2 98" style="enable-background:new 0 0 100.2 98;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E8762C;}
|
||||
.st1{fill:#C72035;}
|
||||
.st2{fill:#59879B;}
|
||||
.st3{fill:#5B6591;}
|
||||
.st4{fill:#EB912C;}
|
||||
.st5{fill:#1F447E;}
|
||||
.st6{fill:#7099A6;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="98" width="100.2" x="-301.9" y="-233.3">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<g>
|
||||
<g id="icon_1_">
|
||||
<polygon class="st0" points="65.7,51.8 52,51.8 52,66.8 46.6,66.8 46.6,51.8 32.8,51.8 32.8,46.6 46.6,46.6 46.6,31.6 52,31.6
|
||||
52,46.6 65.7,46.6 ">
|
||||
</polygon>
|
||||
<polygon class="st1" points="38.2,70.3 25.9,70.3 25.9,56.8 21.3,56.8 21.3,70.3 8.8,70.3 8.8,74.3 21.3,74.3 21.3,87.6
|
||||
25.9,87.6 25.9,74.3 38.2,74.3 ">
|
||||
</polygon>
|
||||
<polygon class="st2" points="90.7,23 78.3,23 78.3,9.6 73.7,9.6 73.7,23 61.4,23 61.4,27.2 73.7,27.2 73.7,40.5 78.3,40.5
|
||||
78.3,27.2 90.7,27.2 ">
|
||||
</polygon>
|
||||
<polygon class="st3" points="59.8,84.9 51.5,84.9 51.5,75.6 47.5,75.6 47.5,84.9 39,84.9 39,88.5 47.5,88.5 47.5,98 51.5,98
|
||||
51.5,88.5 59.8,88.5 ">
|
||||
</polygon>
|
||||
<polygon class="st4" points="38.1,22.9 25.6,22.9 25.6,9.6 21.1,9.6 21.1,22.9 8.6,22.9 8.6,26.9 21.1,26.9 21.1,40.5 25.6,40.5
|
||||
25.6,26.9 38.1,26.9 ">
|
||||
</polygon>
|
||||
<polygon class="st3" points="100.2,47.4 91.9,47.4 91.9,38.1 87.8,38.1 87.8,47.4 79.4,47.4 79.4,51 87.8,51 87.8,60.3
|
||||
91.9,60.3 91.9,51 100.2,51 ">
|
||||
</polygon>
|
||||
<polygon class="st5" points="89.9,70.3 77.6,70.3 77.6,56.8 73,56.8 73,70.3 60.6,70.3 60.6,74.3 73,74.3 73,87.6 77.6,87.6
|
||||
77.6,74.3 89.9,74.3 ">
|
||||
</polygon>
|
||||
<polygon class="st6" points="59.2,9.3 50.9,9.3 50.9,0 47.9,0 47.9,9.3 39.6,9.3 39.6,12.1 47.9,12.1 47.9,21.2 50.9,21.2
|
||||
50.9,12.1 59.2,12.1 ">
|
||||
</polygon>
|
||||
<polygon class="st6" points="19.6,47.8 11.3,47.8 11.3,38.7 8.3,38.7 8.3,47.8 0,47.8 0,50.6 8.3,50.6 8.3,59.7 11.3,59.7
|
||||
11.3,50.6 19.6,50.6 ">
|
||||
</polygon>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
18
mobile/apps/auth/assets/custom-icons/icons/x.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 49.8 45" style="enable-background:new 0 0 49.8 45;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="45" width="49.8" x="-67.2" y="-209.8">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<path class="st0" d="M39.2,0h7.6L30.2,19.1L49.8,45H34.4l-12-15.7L8.6,45H1l17.8-20.4L0,0h15.8l10.9,14.4L39.2,0z M36.5,40.4h4.2
|
||||
L13.5,4.3H8.9L36.5,40.4z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
1
mobile/apps/auth/assets/custom-icons/icons/xvideos.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#000" d="M0 0h90v90H0z"/><path d="m32 11 13 19.217L58 11h17L53.5 42.783 78 79H61L45 55.348 29 79H12l24.5-36.217L15 11h17Z" fill="#F40000"/></g></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -979,6 +979,55 @@ class FilesDB with SqlDbBase {
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove references for local files which are either already uploaded
|
||||
// or queued for upload but not yet uploaded
|
||||
Future<int> removeQueuedLocalFiles(Set<String> localIDs) async {
|
||||
if (localIDs.isEmpty) {
|
||||
_logger.finest("No local IDs provided for removal");
|
||||
return 0;
|
||||
}
|
||||
|
||||
final db = await instance.sqliteAsyncDB;
|
||||
const batchSize = 10000;
|
||||
int totalRemoved = 0;
|
||||
|
||||
final localIDsList = localIDs.toList();
|
||||
|
||||
for (int i = 0; i < localIDsList.length; i += batchSize) {
|
||||
final endIndex = (i + batchSize > localIDsList.length)
|
||||
? localIDsList.length
|
||||
: i + batchSize;
|
||||
|
||||
final batch = localIDsList.sublist(i, endIndex);
|
||||
final placeholders = List.filled(batch.length, '?').join(',');
|
||||
|
||||
final r = await db.execute(
|
||||
'''
|
||||
DELETE FROM $filesTable
|
||||
WHERE $columnLocalID IN ($placeholders)
|
||||
AND ($columnCollectionID IS NULL OR $columnCollectionID = -1)
|
||||
AND ($columnUploadedFileID IS NULL OR $columnUploadedFileID = -1)
|
||||
''',
|
||||
batch,
|
||||
);
|
||||
|
||||
if (r.isNotEmpty) {
|
||||
_logger
|
||||
.fine("Batch ${(i ~/ batchSize) + 1}: Removed ${r.length} files");
|
||||
totalRemoved += r.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalRemoved > 0) {
|
||||
_logger.warning(
|
||||
"Removed $totalRemoved potential dups for already queued local files",
|
||||
);
|
||||
} else {
|
||||
_logger.finest("No duplicate id found for queued/uploaded files");
|
||||
}
|
||||
return totalRemoved;
|
||||
}
|
||||
|
||||
Future<Set<String>> getLocalFileIDsForCollection(int collectionID) async {
|
||||
final db = await instance.sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
|
||||
@@ -383,8 +383,12 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
}
|
||||
if (personID == null && clusterID == null) {
|
||||
_logger.severe("personID and clusterID cannot be null both");
|
||||
throw Exception("personID and clusterID cannot be null");
|
||||
}
|
||||
_logger.severe(
|
||||
"Something went wrong finding a face from `getCoverFaceForPerson` (personID: $personID, clusterID: $clusterID)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1427,12 +1427,20 @@ class CollectionsService {
|
||||
}
|
||||
// group files by collectionID
|
||||
final Map<int, List<EnteFile>> filesByCollection = {};
|
||||
final Map<int, Set<int>> fileSeenByCollection = {};
|
||||
for (final file in filesToCopy) {
|
||||
if (filesByCollection.containsKey(file.collectionID!)) {
|
||||
filesByCollection[file.collectionID!]!.add(file.copyWith());
|
||||
} else {
|
||||
filesByCollection[file.collectionID!] = [file.copyWith()];
|
||||
fileSeenByCollection.putIfAbsent(file.collectionID!, () => <int>{});
|
||||
if (fileSeenByCollection[file.collectionID]!
|
||||
.contains(file.uploadedFileID)) {
|
||||
_logger.warning(
|
||||
"skip copy, duplicate ID: ${file.uploadedFileID} in collection "
|
||||
"${file.collectionID}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
filesByCollection
|
||||
.putIfAbsent(file.collectionID!, () => [])
|
||||
.add(file.copyWith());
|
||||
}
|
||||
for (final entry in filesByCollection.entries) {
|
||||
final srcCollectionID = entry.key;
|
||||
@@ -1579,9 +1587,6 @@ class CollectionsService {
|
||||
params["files"] = [];
|
||||
for (final batchFile in batch) {
|
||||
final fileKey = getFileKey(batchFile);
|
||||
_logger.info(
|
||||
"srcCollection : $srcCollectionID file: ${batchFile.uploadedFileID} key: ${CryptoUtil.bin2base64(fileKey)} ",
|
||||
);
|
||||
final encryptedKeyData =
|
||||
CryptoUtil.encryptSync(fileKey, getCollectionKey(dstCollectionID));
|
||||
batchFile.encryptedKey =
|
||||
@@ -1643,17 +1648,27 @@ class CollectionsService {
|
||||
);
|
||||
final List<EnteFile> filesToCopy = [];
|
||||
final List<EnteFile> filesToAdd = [];
|
||||
final Set<int> seenForAdd = {};
|
||||
final Set<int> seenForCopy = {};
|
||||
|
||||
for (final EnteFile file in othersFile) {
|
||||
if (hashToUserFile.containsKey(file.hash ?? '')) {
|
||||
final userFile = hashToUserFile[file.hash]!;
|
||||
if (userFile.fileType == file.fileType) {
|
||||
filesToAdd.add(userFile);
|
||||
} else {
|
||||
filesToCopy.add(file);
|
||||
}
|
||||
} else {
|
||||
filesToCopy.add(file);
|
||||
final userFile = hashToUserFile[file.hash ?? ''];
|
||||
final bool shouldAdd =
|
||||
userFile != null && userFile.fileType == file.fileType;
|
||||
final targetList = shouldAdd ? filesToAdd : filesToCopy;
|
||||
final seenSet = shouldAdd ? seenForAdd : seenForCopy;
|
||||
final fileToProcess = shouldAdd ? userFile : file;
|
||||
final uploadID = fileToProcess.uploadedFileID;
|
||||
|
||||
if (seenSet.contains(uploadID)) {
|
||||
final action = shouldAdd ? "adding" : "copying";
|
||||
_logger.warning(
|
||||
"skip $action file $uploadID as it is already ${action}ed",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
targetList.add(fileToProcess);
|
||||
seenSet.add(uploadID!);
|
||||
}
|
||||
return (filesToAdd, filesToCopy);
|
||||
}
|
||||
|
||||
365
mobile/apps/photos/lib/services/date_parse_service.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
import 'dart:collection';
|
||||
|
||||
class PartialDate {
|
||||
final int? day;
|
||||
final int? month;
|
||||
final int? year;
|
||||
|
||||
const PartialDate({this.day, this.month, this.year});
|
||||
|
||||
static const empty = PartialDate();
|
||||
|
||||
bool get isEmpty => day == null && month == null && year == null;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PartialDate &&
|
||||
runtimeType == other.runtimeType &&
|
||||
day == other.day &&
|
||||
month == other.month &&
|
||||
year == other.year;
|
||||
|
||||
@override
|
||||
int get hashCode => day.hashCode ^ month.hashCode ^ year.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PartialDate(day: $day, month: $month, year: $year)';
|
||||
}
|
||||
}
|
||||
|
||||
class DateParseService {
|
||||
static final DateParseService instance = DateParseService._private();
|
||||
DateParseService._private();
|
||||
|
||||
static const int _MIN_YEAR = 1900;
|
||||
static const int _MAX_YEAR = 2100;
|
||||
static const int _TWO_DIGIT_YEAR_PIVOT = 50;
|
||||
|
||||
static final _ordinalRegex = RegExp(r'\b(\d{1,2})(st|nd|rd|th)\b');
|
||||
static final _normalizeRegex = RegExp(r'\bof\b|[,\.]+|\s+');
|
||||
static final _isoFormatRegex =
|
||||
RegExp(r'^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$');
|
||||
static final _standardFormatRegex =
|
||||
RegExp(r'^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$');
|
||||
static final _dotFormatRegex = RegExp(r'^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$');
|
||||
static final _compactFormatRegex = RegExp(r'^(\d{8})$');
|
||||
static final _yearOnlyRegex = RegExp(r'^\s*(\d{4})\s*$');
|
||||
static final _shortFormatRegex = RegExp(r'^(\d{1,2})[\/-](\d{1,2})$');
|
||||
|
||||
static final Map<String, int> _monthMap = UnmodifiableMapView({
|
||||
"january": 1,
|
||||
"february": 2,
|
||||
"march": 3,
|
||||
"april": 4,
|
||||
"may": 5,
|
||||
"june": 6,
|
||||
"july": 7,
|
||||
"august": 8,
|
||||
"september": 9,
|
||||
"october": 10,
|
||||
"november": 11,
|
||||
"december": 12,
|
||||
"jan": 1,
|
||||
"feb": 2,
|
||||
"mar": 3,
|
||||
"apr": 4,
|
||||
"jun": 6,
|
||||
"jul": 7,
|
||||
"aug": 8,
|
||||
"sep": 9,
|
||||
"sept": 9,
|
||||
"oct": 10,
|
||||
"nov": 11,
|
||||
"dec": 12,
|
||||
"janu": 1,
|
||||
"febr": 2,
|
||||
"marc": 3,
|
||||
"apri": 4,
|
||||
"juli": 7,
|
||||
"augu": 8,
|
||||
"sepe": 9,
|
||||
"octo": 10,
|
||||
"nove": 11,
|
||||
"dece": 12,
|
||||
});
|
||||
|
||||
static const Map<int, String> monthNumberToName = {
|
||||
1: "January",
|
||||
2: "February",
|
||||
3: "March",
|
||||
4: "April",
|
||||
5: "May",
|
||||
6: "June",
|
||||
7: "July",
|
||||
8: "August",
|
||||
9: "September",
|
||||
10: "October",
|
||||
11: "November",
|
||||
12: "December",
|
||||
};
|
||||
|
||||
PartialDate parse(String input) {
|
||||
if (input.trim().isEmpty) return PartialDate.empty;
|
||||
|
||||
final lowerInput = input.toLowerCase();
|
||||
|
||||
var result = _parseRelativeDate(lowerInput);
|
||||
if (!result.isEmpty) return result;
|
||||
|
||||
result = _parseStructuredFormats(lowerInput);
|
||||
if (!result.isEmpty) return result;
|
||||
|
||||
final normalized = _normalizeDateString(lowerInput);
|
||||
result = _parseTokenizedDate(normalized);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
String getMonthName(int month) {
|
||||
return monthNumberToName[month] ?? 'Unknown';
|
||||
}
|
||||
|
||||
String _normalizeDateString(String input) {
|
||||
return input
|
||||
.replaceAllMapped(_ordinalRegex, (match) => match.group(1)!)
|
||||
.replaceAll(_normalizeRegex, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
int _convertTwoDigitYear(int year) {
|
||||
return year < _TWO_DIGIT_YEAR_PIVOT ? 2000 + year : 1900 + year;
|
||||
}
|
||||
|
||||
PartialDate _parseRelativeDate(String lowerInput) {
|
||||
final bool hasToday = lowerInput.contains('today');
|
||||
final bool hasTomorrow = lowerInput.contains('tomorrow');
|
||||
final bool hasYesterday = lowerInput.contains('yesterday');
|
||||
|
||||
final int count =
|
||||
(hasToday ? 1 : 0) + (hasTomorrow ? 1 : 0) + (hasYesterday ? 1 : 0);
|
||||
|
||||
if (count > 1) {
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (hasToday) {
|
||||
return PartialDate(day: now.day, month: now.month, year: now.year);
|
||||
}
|
||||
if (hasTomorrow) {
|
||||
final tomorrow = now.add(const Duration(days: 1));
|
||||
return PartialDate(
|
||||
day: tomorrow.day,
|
||||
month: tomorrow.month,
|
||||
year: tomorrow.year,
|
||||
);
|
||||
}
|
||||
if (hasYesterday) {
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
return PartialDate(
|
||||
day: yesterday.day,
|
||||
month: yesterday.month,
|
||||
year: yesterday.year,
|
||||
);
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
PartialDate _parseStructuredFormats(String input) {
|
||||
final cleanInput = input.replaceAll(' ', '');
|
||||
|
||||
Match? match = _isoFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final yearVal = int.tryParse(match.group(1)!);
|
||||
final monthVal = int.tryParse(match.group(2)!);
|
||||
final dayVal = int.tryParse(match.group(3)!);
|
||||
if (yearVal != null &&
|
||||
yearVal >= _MIN_YEAR &&
|
||||
yearVal <= _MAX_YEAR &&
|
||||
monthVal != null &&
|
||||
monthVal >= 1 &&
|
||||
monthVal <= 12 &&
|
||||
dayVal != null &&
|
||||
dayVal >= 1 &&
|
||||
dayVal <= 31) {
|
||||
return PartialDate(day: dayVal, month: monthVal, year: yearVal);
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
match = _standardFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final p1 = int.parse(match.group(1)!);
|
||||
final p2 = int.parse(match.group(2)!);
|
||||
final yearRaw = int.parse(match.group(3)!);
|
||||
final year = yearRaw > 99 ? yearRaw : _convertTwoDigitYear(yearRaw);
|
||||
|
||||
if (year < _MIN_YEAR || year > _MAX_YEAR) return PartialDate.empty;
|
||||
|
||||
if (p1 > 12) {
|
||||
if (p1 >= 1 && p1 <= 31 && p2 >= 1 && p2 <= 12) {
|
||||
return PartialDate(day: p1, month: p2, year: year);
|
||||
}
|
||||
} else if (p2 > 12) {
|
||||
if (p1 >= 1 && p1 <= 12 && p2 >= 1 && p2 <= 31) {
|
||||
return PartialDate(day: p2, month: p1, year: year);
|
||||
}
|
||||
} else {
|
||||
if (p1 >= 1 && p1 <= 12 && p2 >= 1 && p2 <= 31) {
|
||||
return PartialDate(day: p2, month: p1, year: year);
|
||||
}
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
match = _shortFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final p1 = int.parse(match.group(1)!);
|
||||
final p2 = int.parse(match.group(2)!);
|
||||
|
||||
if (p1 > 12) {
|
||||
if (p1 >= 1 && p1 <= 31 && p2 >= 1 && p2 <= 12) {
|
||||
return PartialDate(day: p1, month: p2);
|
||||
}
|
||||
} else if (p2 > 12) {
|
||||
if (p1 >= 1 && p1 <= 12 && p2 >= 1 && p2 <= 31) {
|
||||
return PartialDate(day: p2, month: p1);
|
||||
}
|
||||
} else {
|
||||
if (p1 >= 1 && p1 <= 12 && p2 >= 1 && p2 <= 31) {
|
||||
return PartialDate(day: p2, month: p1);
|
||||
}
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
match = _dotFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final yearRaw = int.parse(match.group(3)!);
|
||||
final year = yearRaw > 99 ? yearRaw : _convertTwoDigitYear(yearRaw);
|
||||
final dayVal = int.tryParse(match.group(1)!);
|
||||
final monthVal = int.tryParse(match.group(2)!);
|
||||
|
||||
if (year >= _MIN_YEAR &&
|
||||
year <= _MAX_YEAR &&
|
||||
dayVal != null &&
|
||||
dayVal >= 1 &&
|
||||
dayVal <= 31 &&
|
||||
monthVal != null &&
|
||||
monthVal >= 1 &&
|
||||
monthVal <= 12) {
|
||||
return PartialDate(day: dayVal, month: monthVal, year: year);
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
match = _compactFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final yearVal = int.tryParse(cleanInput.substring(0, 4));
|
||||
final monthVal = int.tryParse(cleanInput.substring(4, 6));
|
||||
final dayVal = int.tryParse(cleanInput.substring(6, 8));
|
||||
|
||||
if (yearVal != null &&
|
||||
yearVal >= _MIN_YEAR &&
|
||||
yearVal <= _MAX_YEAR &&
|
||||
monthVal != null &&
|
||||
monthVal >= 1 &&
|
||||
monthVal <= 12 &&
|
||||
dayVal != null &&
|
||||
dayVal >= 1 &&
|
||||
dayVal <= 31) {
|
||||
return PartialDate(day: dayVal, month: monthVal, year: yearVal);
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
PartialDate _parseTokenizedDate(String normalized) {
|
||||
final tokens = normalized.split(' ');
|
||||
int? day, month, year;
|
||||
|
||||
if (tokens.length == 1) {
|
||||
final token = tokens[0];
|
||||
final match = _yearOnlyRegex.firstMatch(token);
|
||||
if (match != null) {
|
||||
final parsedYear = int.tryParse(match.group(1)!);
|
||||
if (parsedYear != null &&
|
||||
parsedYear >= _MIN_YEAR &&
|
||||
parsedYear <= _MAX_YEAR) {
|
||||
return PartialDate(year: parsedYear);
|
||||
}
|
||||
}
|
||||
if (_monthMap.containsKey(token)) {
|
||||
return PartialDate(month: _monthMap[token]!);
|
||||
}
|
||||
final singleValue = int.tryParse(token);
|
||||
if (singleValue != null && singleValue >= 1 && singleValue <= 31) {
|
||||
return PartialDate(day: singleValue);
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
for (final token in tokens) {
|
||||
if (_monthMap.containsKey(token) && month == null) {
|
||||
month = _monthMap[token];
|
||||
continue;
|
||||
}
|
||||
|
||||
final value = int.tryParse(token);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value >= _MIN_YEAR && value <= _MAX_YEAR && year == null) {
|
||||
year = value;
|
||||
} else if (value >= 1 && value <= 31 && day == null) {
|
||||
day = value;
|
||||
} else if (value >= 0 && value <= 99 && year == null) {
|
||||
final convertedYear = _convertTwoDigitYear(value);
|
||||
if (convertedYear >= _MIN_YEAR && convertedYear <= _MAX_YEAR) {
|
||||
year = convertedYear;
|
||||
}
|
||||
} else if (value >= 1 && value <= 12 && month == null) {
|
||||
month = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (day != null && (day < 1 || day > 31)) {
|
||||
day = null;
|
||||
}
|
||||
if (month != null && (month < 1 || month > 12)) {
|
||||
month = null;
|
||||
}
|
||||
|
||||
final bool inputHadMonthWord = tokens.any((t) => _monthMap.containsKey(t));
|
||||
final bool inputHadYearWord = tokens.any((t) {
|
||||
final v = int.tryParse(t);
|
||||
return v != null && v >= 1000 && v <= 9999;
|
||||
});
|
||||
|
||||
if (day != null && month == null && year == null && tokens.length > 1) {
|
||||
if (normalized.contains('of') &&
|
||||
!inputHadMonthWord &&
|
||||
!inputHadYearWord) {
|
||||
return PartialDate.empty;
|
||||
}
|
||||
if (!inputHadMonthWord && !inputHadYearWord && tokens.length > 1) {}
|
||||
}
|
||||
|
||||
if (day == null && month == null && year == null) {
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
if (day != null && month == null && year == null && tokens.length > 1) {
|
||||
if (!inputHadMonthWord && !inputHadYearWord) {
|
||||
return PartialDate.empty;
|
||||
}
|
||||
}
|
||||
|
||||
return PartialDate(day: day, month: month, year: year);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import "package:ml_linalg/dtype.dart";
|
||||
import "package:ml_linalg/vector.dart";
|
||||
import "package:photos/generated/protos/ente/common/vector.pb.dart";
|
||||
import "package:photos/models/base/id.dart";
|
||||
import "package:photos/services/isolate_functions.dart";
|
||||
import "package:photos/services/isolate_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_clustering/face_db_info_for_clustering.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
import "package:photos/services/machine_learning/ml_result.dart";
|
||||
import "package:photos/utils/isolate/isolate_operations.dart";
|
||||
import "package:photos/utils/isolate/super_isolate.dart";
|
||||
|
||||
class FaceInfo {
|
||||
final String faceID;
|
||||
@@ -507,7 +507,8 @@ ClusteringResult _runCompleteClustering(Map args) {
|
||||
EVector.fromBuffer(entry.value).values,
|
||||
dtype: DType.float32,
|
||||
),
|
||||
fileCreationTime: fileIDToCreationTime?[getFileIdFromFaceId<int>(entry.key)],
|
||||
fileCreationTime:
|
||||
fileIDToCreationTime?[getFileIdFromFaceId<int>(entry.key)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
|
||||
import "package:computer/computer.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/models/ml/face/box.dart";
|
||||
import "package:photos/services/isolate_functions.dart";
|
||||
import "package:photos/services/isolate_service.dart";
|
||||
import "package:photos/utils/image_ml_util.dart";
|
||||
import "package:photos/utils/isolate/isolate_operations.dart";
|
||||
import "package:photos/utils/isolate/super_isolate.dart";
|
||||
|
||||
final Computer _computer = Computer.shared();
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
class FaceThumbnailGenerator extends SuperIsolate {
|
||||
@override
|
||||
Logger get logger => _logger;
|
||||
@@ -37,20 +35,30 @@ class FaceThumbnailGenerator extends SuperIsolate {
|
||||
String imagePath,
|
||||
List<FaceBox> faceBoxes,
|
||||
) async {
|
||||
final List<Map<String, dynamic>> faceBoxesJson =
|
||||
faceBoxes.map((box) => box.toJson()).toList();
|
||||
final List<Uint8List> faces = await runInIsolate(
|
||||
IsolateOperation.generateFaceThumbnails,
|
||||
{
|
||||
'imagePath': imagePath,
|
||||
'faceBoxesList': faceBoxesJson,
|
||||
},
|
||||
).then((value) => value.cast<Uint8List>());
|
||||
final compressedFaces =
|
||||
await compressFaceThumbnails({'listPngBytes': faces});
|
||||
_logger.fine(
|
||||
"Compressed face thumbnails from sizes ${faces.map((e) => e.length / 1024).toList()} to ${compressedFaces.map((e) => e.length / 1024).toList()} kilobytes",
|
||||
);
|
||||
return compressedFaces;
|
||||
try {
|
||||
_logger.info(
|
||||
"Generating face thumbnails for ${faceBoxes.length} face boxes in $imagePath",
|
||||
);
|
||||
final List<Map<String, dynamic>> faceBoxesJson =
|
||||
faceBoxes.map((box) => box.toJson()).toList();
|
||||
final List<Uint8List> faces = await runInIsolate(
|
||||
IsolateOperation.generateFaceThumbnails,
|
||||
{
|
||||
'imagePath': imagePath,
|
||||
'faceBoxesList': faceBoxesJson,
|
||||
},
|
||||
).then((value) => value.cast<Uint8List>());
|
||||
_logger.info("Generated face thumbnails");
|
||||
final compressedFaces =
|
||||
await compressFaceThumbnails({'listPngBytes': faces});
|
||||
_logger.fine(
|
||||
"Compressed face thumbnails from sizes ${faces.map((e) => e.length / 1024).toList()} to ${compressedFaces.map((e) => e.length / 1024).toList()} kilobytes",
|
||||
);
|
||||
return compressedFaces;
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to generate face thumbnails", e, s);
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'dart:async';
|
||||
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/models/ml/vector.dart";
|
||||
import "package:photos/services/isolate_functions.dart";
|
||||
import "package:photos/services/isolate_service.dart";
|
||||
import "package:photos/services/machine_learning/ml_constants.dart";
|
||||
import "package:photos/services/machine_learning/semantic_search/clip/clip_text_encoder.dart";
|
||||
import "package:photos/services/machine_learning/semantic_search/query_result.dart";
|
||||
import "package:photos/services/remote_assets_service.dart";
|
||||
import "package:photos/utils/isolate/isolate_operations.dart";
|
||||
import "package:photos/utils/isolate/super_isolate.dart";
|
||||
import "package:synchronized/synchronized.dart";
|
||||
|
||||
class MLComputer extends SuperIsolate {
|
||||
|
||||
@@ -2,14 +2,14 @@ import "dart:async";
|
||||
|
||||
import "package:flutter/foundation.dart" show debugPrint;
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/services/isolate_functions.dart";
|
||||
import "package:photos/services/isolate_service.dart";
|
||||
import 'package:photos/services/machine_learning/face_ml/face_detection/face_detection_service.dart';
|
||||
import 'package:photos/services/machine_learning/face_ml/face_embedding/face_embedding_service.dart';
|
||||
import "package:photos/services/machine_learning/ml_models_overview.dart";
|
||||
import 'package:photos/services/machine_learning/ml_result.dart';
|
||||
import "package:photos/services/machine_learning/semantic_search/clip/clip_image_encoder.dart";
|
||||
import "package:photos/services/remote_assets_service.dart";
|
||||
import "package:photos/utils/isolate/isolate_operations.dart";
|
||||
import "package:photos/utils/isolate/super_isolate.dart";
|
||||
import "package:photos/utils/ml_util.dart";
|
||||
import "package:photos/utils/network_util.dart";
|
||||
import "package:synchronized/synchronized.dart";
|
||||
|
||||
@@ -43,6 +43,7 @@ import "package:photos/models/search/search_types.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/account/user_service.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/date_parse_service.dart";
|
||||
import "package:photos/services/filter/db_filters.dart";
|
||||
import "package:photos/services/location_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
@@ -59,7 +60,6 @@ import "package:photos/utils/cache_util.dart";
|
||||
import "package:photos/utils/file_util.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import 'package:photos/utils/standalone/date_time.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class SearchService {
|
||||
Future<List<EnteFile>>? _cachedFilesFuture;
|
||||
@@ -1063,20 +1063,59 @@ class SearchService {
|
||||
String query,
|
||||
) async {
|
||||
final List<GenericSearchResult> searchResults = [];
|
||||
final potentialDates = _getPossibleEventDate(context, query);
|
||||
|
||||
for (var potentialDate in potentialDates) {
|
||||
final int day = potentialDate.item1;
|
||||
final int month = potentialDate.item2.monthNumber;
|
||||
final int? year = potentialDate.item3; // nullable
|
||||
final parsedDate = DateParseService.instance.parse(query);
|
||||
|
||||
if (parsedDate.isEmpty) {
|
||||
return searchResults;
|
||||
}
|
||||
// Handle month-year queries
|
||||
if (parsedDate.day == null &&
|
||||
parsedDate.month != null &&
|
||||
parsedDate.year != null) {
|
||||
final month = parsedDate.month!;
|
||||
final year = parsedDate.year!;
|
||||
final monthYearFiles =
|
||||
await FilesDB.instance.getFilesCreatedWithinDurations(
|
||||
[_getDurationForMonthInYear(month, year)],
|
||||
ignoreCollections(),
|
||||
order: 'DESC',
|
||||
);
|
||||
if (monthYearFiles.isNotEmpty) {
|
||||
final monthName = DateParseService.instance.getMonthName(month);
|
||||
final name = '$monthName $year';
|
||||
searchResults.add(
|
||||
GenericSearchResult(
|
||||
ResultType.month,
|
||||
name,
|
||||
monthYearFiles,
|
||||
hierarchicalSearchFilter: TopLevelGenericFilter(
|
||||
filterName: name,
|
||||
occurrence: kMostRelevantFilter,
|
||||
filterResultType: ResultType.month,
|
||||
matchedUploadedIDs: filesToUploadedFileIDs(monthYearFiles),
|
||||
filterIcon: Icons.calendar_month_outlined,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle day-month queries (with or without year)
|
||||
else if (parsedDate.day != null && parsedDate.month != null) {
|
||||
final int day = parsedDate.day!;
|
||||
final int month = parsedDate.month!;
|
||||
final int? year = parsedDate.year; // nullable for generic dates
|
||||
|
||||
final matchedFiles =
|
||||
await FilesDB.instance.getFilesCreatedWithinDurations(
|
||||
_getDurationsForCalendarDateInEveryYear(day, month, year: year),
|
||||
ignoreCollections(),
|
||||
order: 'DESC',
|
||||
);
|
||||
|
||||
if (matchedFiles.isNotEmpty) {
|
||||
final name = '$day ${potentialDate.item2.name} ${year ?? ''}';
|
||||
final monthName = DateParseService.instance.getMonthName(month);
|
||||
final name = '$day $monthName${year != null ? ' $year' : ''}';
|
||||
searchResults.add(
|
||||
GenericSearchResult(
|
||||
ResultType.event,
|
||||
@@ -1482,55 +1521,12 @@ class SearchService {
|
||||
return durationsOfMonthInEveryYear;
|
||||
}
|
||||
|
||||
List<Tuple3<int, MonthData, int?>> _getPossibleEventDate(
|
||||
BuildContext context,
|
||||
String query,
|
||||
) {
|
||||
final List<Tuple3<int, MonthData, int?>> possibleEvents = [];
|
||||
if (query.trim().isEmpty) {
|
||||
return possibleEvents;
|
||||
}
|
||||
final result = query
|
||||
.trim()
|
||||
.split(RegExp('[ ,-/]+'))
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
final resultCount = result.length;
|
||||
if (resultCount < 1 || resultCount > 4) {
|
||||
return possibleEvents;
|
||||
}
|
||||
|
||||
final int? day = int.tryParse(result[0]);
|
||||
if (day == null || day < 1 || day > 31) {
|
||||
return possibleEvents;
|
||||
}
|
||||
final List<MonthData> potentialMonth = resultCount > 1
|
||||
? _getMatchingMonths(context, result[1])
|
||||
: getMonthData(context);
|
||||
final int? parsedYear = resultCount >= 3 ? int.tryParse(result[2]) : null;
|
||||
final List<int> matchingYears = [];
|
||||
if (parsedYear != null) {
|
||||
bool foundMatch = false;
|
||||
for (int i = searchStartYear; i <= currentYear; i++) {
|
||||
if (i.toString().startsWith(parsedYear.toString())) {
|
||||
matchingYears.add(i);
|
||||
foundMatch = foundMatch || (i == parsedYear);
|
||||
}
|
||||
}
|
||||
if (!foundMatch && parsedYear > 1000 && parsedYear <= currentYear) {
|
||||
matchingYears.add(parsedYear);
|
||||
}
|
||||
}
|
||||
for (var element in potentialMonth) {
|
||||
if (matchingYears.isEmpty) {
|
||||
possibleEvents.add(Tuple3(day, element, null));
|
||||
} else {
|
||||
for (int yr in matchingYears) {
|
||||
possibleEvents.add(Tuple3(day, element, yr));
|
||||
}
|
||||
}
|
||||
}
|
||||
return possibleEvents;
|
||||
List<int> _getDurationForMonthInYear(int month, int year) {
|
||||
return [
|
||||
DateTime(year, month, 1).microsecondsSinceEpoch,
|
||||
month == 12
|
||||
? DateTime(year + 1, 1, 1).microsecondsSinceEpoch
|
||||
: DateTime(year, month + 1, 1).microsecondsSinceEpoch,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ class RemoteSyncService {
|
||||
Completer<void>? _existingSync;
|
||||
bool _isExistingSyncSilent = false;
|
||||
|
||||
// _hasCleanupStaleEntry is used to track if we have already cleaned up
|
||||
// statle db entries in this sync session.
|
||||
bool _hasCleanupStaleEntry = false;
|
||||
|
||||
static const kHasSyncedArchiveKey = "has_synced_archive";
|
||||
/* This setting is used to maintain a list of local IDs for videos that the user has manually
|
||||
marked for upload, even if the global video upload setting is currently disabled.
|
||||
@@ -371,6 +375,14 @@ class RemoteSyncService {
|
||||
final Set<String> alreadyClaimedLocalIDs =
|
||||
await _db.getLocalIDsMarkedForOrAlreadyUploaded(ownerID);
|
||||
localIDsToSync.removeAll(alreadyClaimedLocalIDs);
|
||||
if (alreadyClaimedLocalIDs.isNotEmpty && !_hasCleanupStaleEntry) {
|
||||
try {
|
||||
await _db.removeQueuedLocalFiles(alreadyClaimedLocalIDs);
|
||||
} catch(e, s) {
|
||||
_logger.severe("removeQueuedLocalFiles failed",e,s);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (localIDsToSync.isEmpty) {
|
||||
@@ -439,6 +451,7 @@ class RemoteSyncService {
|
||||
// "force reload due to display new files"
|
||||
Bus.instance.fire(ForceReloadHomeGalleryEvent("newFilesDisplay"));
|
||||
}
|
||||
_hasCleanupStaleEntry = true;
|
||||
}
|
||||
|
||||
Future<void> updateDeviceFolderSyncStatus(
|
||||
|
||||
@@ -21,6 +21,7 @@ class PersonFaceWidget extends StatefulWidget {
|
||||
final String? clusterID;
|
||||
final bool useFullFile;
|
||||
final VoidCallback? onErrorCallback;
|
||||
final bool keepAlive;
|
||||
|
||||
// PersonFaceWidget constructor checks that both personId and clusterID are not null
|
||||
// and that the file is not null
|
||||
@@ -29,6 +30,7 @@ class PersonFaceWidget extends StatefulWidget {
|
||||
this.clusterID,
|
||||
this.useFullFile = true,
|
||||
this.onErrorCallback,
|
||||
this.keepAlive = false,
|
||||
super.key,
|
||||
}) : assert(
|
||||
personId != null || clusterID != null,
|
||||
@@ -39,12 +41,16 @@ class PersonFaceWidget extends StatefulWidget {
|
||||
State<PersonFaceWidget> createState() => _PersonFaceWidgetState();
|
||||
}
|
||||
|
||||
class _PersonFaceWidgetState extends State<PersonFaceWidget> {
|
||||
class _PersonFaceWidgetState extends State<PersonFaceWidget>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
Future<Uint8List?>? faceCropFuture;
|
||||
EnteFile? fileForFaceCrop;
|
||||
|
||||
bool get isPerson => widget.personId != null;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.keepAlive;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -64,6 +70,10 @@ class _PersonFaceWidgetState extends State<PersonFaceWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(
|
||||
context,
|
||||
); // Calling super.build for AutomaticKeepAliveClientMixin
|
||||
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: faceCropFuture,
|
||||
builder: (context, snapshot) {
|
||||
@@ -163,7 +173,7 @@ class _PersonFaceWidgetState extends State<PersonFaceWidget> {
|
||||
}
|
||||
}
|
||||
if (fileForFaceCrop == null) {
|
||||
_logger.warning(
|
||||
_logger.severe(
|
||||
"No suitable file found for face crop for person: ${widget.personId} or cluster: ${widget.clusterID}",
|
||||
);
|
||||
return null;
|
||||
@@ -176,7 +186,7 @@ class _PersonFaceWidgetState extends State<PersonFaceWidget> {
|
||||
clusterID: widget.clusterID,
|
||||
);
|
||||
if (face == null) {
|
||||
debugPrint(
|
||||
_logger.severe(
|
||||
"No cover face for person: ${widget.personId} or cluster ${widget.clusterID} and fileID ${fileForFaceCrop.uploadedFileID!}",
|
||||
);
|
||||
return null;
|
||||
@@ -188,7 +198,13 @@ class _PersonFaceWidgetState extends State<PersonFaceWidget> {
|
||||
personOrClusterID: personOrClusterId,
|
||||
useTempCache: false,
|
||||
);
|
||||
return cropMap?[face.faceID];
|
||||
final result = cropMap?[face.faceID];
|
||||
if (result == null) {
|
||||
_logger.severe(
|
||||
"Null cover face crop for person: ${widget.personId} or cluster ${widget.clusterID} and fileID ${fileForFaceCrop.uploadedFileID!}",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Error getting cover face for person: ${widget.personId} or cluster ${widget.clusterID}",
|
||||
|
||||
@@ -95,12 +95,14 @@ class SelectablePersonSearchExample extends StatelessWidget {
|
||||
final GenericSearchResult searchResult;
|
||||
final double size;
|
||||
final SelectedPeople selectedPeople;
|
||||
final bool isDefaultFace;
|
||||
|
||||
const SelectablePersonSearchExample({
|
||||
super.key,
|
||||
required this.searchResult,
|
||||
required this.selectedPeople,
|
||||
this.size = 102,
|
||||
this.isDefaultFace = false,
|
||||
});
|
||||
|
||||
void _handleTap(BuildContext context) {
|
||||
@@ -192,7 +194,10 @@ class SelectablePersonSearchExample extends StatelessWidget {
|
||||
searchResult.previewThumbnail()!,
|
||||
shouldShowSyncStatus: false,
|
||||
)
|
||||
: FaceSearchResult(searchResult);
|
||||
: FaceSearchResult(
|
||||
searchResult,
|
||||
isDefaultFace: isDefaultFace,
|
||||
);
|
||||
} else {
|
||||
child = const NoThumbnailWidget(
|
||||
addBorder: false,
|
||||
@@ -301,8 +306,13 @@ class SelectablePersonSearchExample extends StatelessWidget {
|
||||
|
||||
class FaceSearchResult extends StatelessWidget {
|
||||
final SearchResult searchResult;
|
||||
final bool isDefaultFace;
|
||||
|
||||
const FaceSearchResult(this.searchResult, {super.key});
|
||||
const FaceSearchResult(
|
||||
this.searchResult, {
|
||||
super.key,
|
||||
this.isDefaultFace = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -313,6 +323,7 @@ class FaceSearchResult extends StatelessWidget {
|
||||
key: params.containsKey(kPersonWidgetKey)
|
||||
? ValueKey(params[kPersonWidgetKey])
|
||||
: ValueKey(params[kPersonParamID] ?? params[kClusterParamId]),
|
||||
keepAlive: isDefaultFace,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -486,6 +497,7 @@ class _PeopleSectionAllWidgetState extends State<PeopleSectionAllWidget> {
|
||||
searchResult: normalFaces[index],
|
||||
size: itemSize,
|
||||
selectedPeople: widget.selectedPeople!,
|
||||
isDefaultFace: true,
|
||||
)
|
||||
: PersonSearchExample(
|
||||
searchResult: normalFaces[index],
|
||||
@@ -525,6 +537,7 @@ class _PeopleSectionAllWidgetState extends State<PeopleSectionAllWidget> {
|
||||
searchResult: extraFaces[index],
|
||||
size: itemSize,
|
||||
selectedPeople: widget.selectedPeople!,
|
||||
isDefaultFace: false,
|
||||
)
|
||||
: PersonSearchExample(
|
||||
searchResult: extraFaces[index],
|
||||
|
||||
@@ -136,7 +136,7 @@ Future<Map<String, Uint8List>?> getCachedFaceCrops(
|
||||
facesWithoutCrops[face.faceID] = face.detection.box;
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
_logger.warning(
|
||||
"Error reading cached face crop for faceID ${face.faceID} from file ${faceCropCacheFile.path}",
|
||||
e,
|
||||
s,
|
||||
@@ -212,7 +212,7 @@ Future<Map<String, Uint8List>?> getCachedFaceCrops(
|
||||
milliseconds: 100 * pow(2, fetchAttempt + 1).toInt(),
|
||||
);
|
||||
await Future.delayed(backoff);
|
||||
_logger.warning(
|
||||
_logger.fine(
|
||||
"Error getting face crops for faceIDs: ${faces.map((face) => face.faceID).toList()}, retrying (attempt ${fetchAttempt + 1}) in ${backoff.inMilliseconds} ms",
|
||||
e,
|
||||
s,
|
||||
@@ -225,13 +225,13 @@ Future<Map<String, Uint8List>?> getCachedFaceCrops(
|
||||
useTempCache: useTempCache,
|
||||
);
|
||||
}
|
||||
_logger.severe(
|
||||
_logger.warning(
|
||||
"Error getting face crops for faceIDs: ${faces.map((face) => face.faceID).toList()}",
|
||||
e,
|
||||
s,
|
||||
);
|
||||
} else {
|
||||
_logger.info(
|
||||
_logger.severe(
|
||||
"Stopped getting face crops for faceIDs: ${faces.map((face) => face.faceID).toList()} due to $e",
|
||||
);
|
||||
}
|
||||
@@ -334,12 +334,14 @@ Future<Map<String, Uint8List>?> _getFaceCrops(
|
||||
if (useFullFile && file.fileType != FileType.video) {
|
||||
final File? ioFile = await getFile(file);
|
||||
if (ioFile == null) {
|
||||
_logger.severe("Failed to get file for face crop generation");
|
||||
return null;
|
||||
}
|
||||
imagePath = ioFile.path;
|
||||
} else {
|
||||
final thumbnail = await getThumbnailForUploadedFile(file);
|
||||
if (thumbnail == null) {
|
||||
_logger.severe("Failed to get thumbnail for face crop generation");
|
||||
return null;
|
||||
}
|
||||
imagePath = thumbnail.path;
|
||||
|
||||
@@ -7,9 +7,10 @@ import "package:flutter/services.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/core/error-reporting/isolate_logging.dart";
|
||||
import "package:photos/models/base/id.dart";
|
||||
import "package:photos/services/isolate_functions.dart";
|
||||
import "package:photos/utils/isolate/isolate_operations.dart";
|
||||
import "package:synchronized/synchronized.dart";
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
abstract class SuperIsolate {
|
||||
Logger get logger;
|
||||
|
||||
@@ -80,6 +81,8 @@ abstract class SuperIsolate {
|
||||
if (rootToken != null) {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
|
||||
}
|
||||
final logger = Logger('SuperIsolate');
|
||||
logger.info('IsolateMain started');
|
||||
|
||||
receivePort.listen((message) async {
|
||||
final taskID = message[0] as String;
|
||||
@@ -87,6 +90,7 @@ abstract class SuperIsolate {
|
||||
final function = IsolateOperation.values[functionIndex];
|
||||
final args = message[2] as Map<String, dynamic>;
|
||||
final sendPort = message[3] as SendPort;
|
||||
logger.info("Starting isolate operation $function in isolate");
|
||||
|
||||
late final Object data;
|
||||
try {
|
||||
@@ -5,10 +5,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
|
||||
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "72.0.0"
|
||||
version: "76.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -21,7 +21,7 @@ packages:
|
||||
dependency: transitive
|
||||
description: dart
|
||||
source: sdk
|
||||
version: "0.3.2"
|
||||
version: "0.3.3"
|
||||
adaptive_theme:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -34,10 +34,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
|
||||
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.7.0"
|
||||
version: "6.11.0"
|
||||
android_intent_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -317,10 +317,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.0"
|
||||
computer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1416,18 +1416,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.5"
|
||||
version: "10.0.7"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
version: "3.0.8"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1536,10 +1536,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: macros
|
||||
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
|
||||
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2-main.4"
|
||||
version: "0.1.3-main.0"
|
||||
maps_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2309,7 +2309,7 @@ packages:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2434,10 +2434,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
version: "1.12.0"
|
||||
step_progress_indicator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2466,10 +2466,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
|
||||
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.3.0"
|
||||
styled_text:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2530,26 +2530,26 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e"
|
||||
sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.25.7"
|
||||
version: "1.25.8"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.7.3"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696"
|
||||
sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.4"
|
||||
version: "0.6.5"
|
||||
thermal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2813,10 +2813,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.5"
|
||||
version: "14.3.0"
|
||||
volume_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2877,10 +2877,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
|
||||
sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.0.4"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
346
mobile/apps/photos/test/utils/date_query_parsing_test.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
import 'package:photos/services/date_parse_service.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
// Get an instance of the service
|
||||
final DateParseService dateParseService = DateParseService.instance;
|
||||
|
||||
// --- Natural Language Date Parsing ---
|
||||
group('Natural Language Date Parsing', () {
|
||||
// Relative dates: today, tomorrow, yesterday
|
||||
test('should parse "today" correctly', () {
|
||||
final DateTime now = DateTime.now();
|
||||
final PartialDate expectedDate =
|
||||
PartialDate(day: now.day, month: now.month, year: now.year);
|
||||
final PartialDate parsedDate = dateParseService.parse('today');
|
||||
|
||||
expect(
|
||||
parsedDate.day,
|
||||
expectedDate.day,
|
||||
reason: 'Day mismatch for today',
|
||||
);
|
||||
expect(
|
||||
parsedDate.month,
|
||||
expectedDate.month,
|
||||
reason: 'Month mismatch for today',
|
||||
);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
expectedDate.year,
|
||||
reason: 'Year mismatch for today',
|
||||
);
|
||||
});
|
||||
|
||||
test('should parse "tomorrow" correctly', () {
|
||||
final DateTime tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final PartialDate expectedDate = PartialDate(
|
||||
day: tomorrow.day,
|
||||
month: tomorrow.month,
|
||||
year: tomorrow.year,
|
||||
);
|
||||
final PartialDate parsedDate = dateParseService.parse('tomorrow');
|
||||
|
||||
expect(
|
||||
parsedDate.day,
|
||||
expectedDate.day,
|
||||
reason: 'Day mismatch for tomorrow',
|
||||
);
|
||||
expect(
|
||||
parsedDate.month,
|
||||
expectedDate.month,
|
||||
reason: 'Month mismatch for tomorrow',
|
||||
);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
expectedDate.year,
|
||||
reason: 'Year mismatch for tomorrow',
|
||||
);
|
||||
});
|
||||
|
||||
test('should parse "yesterday" correctly', () {
|
||||
final DateTime yesterday =
|
||||
DateTime.now().subtract(const Duration(days: 1));
|
||||
final PartialDate expectedDate = PartialDate(
|
||||
day: yesterday.day,
|
||||
month: yesterday.month,
|
||||
year: yesterday.year,
|
||||
);
|
||||
final PartialDate parsedDate = dateParseService.parse('yesterday');
|
||||
|
||||
expect(
|
||||
parsedDate.day,
|
||||
expectedDate.day,
|
||||
reason: 'Day mismatch for yesterday',
|
||||
);
|
||||
expect(
|
||||
parsedDate.month,
|
||||
expectedDate.month,
|
||||
reason: 'Month mismatch for yesterday',
|
||||
);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
expectedDate.year,
|
||||
reason: 'Year mismatch for yesterday',
|
||||
);
|
||||
});
|
||||
|
||||
// Month names: Full (February), abbreviated (Feb), and partial (Febr)
|
||||
test('should parse full month name "February 2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('February 2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse abbreviated month name "Feb 2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('Feb 2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse partial month name "Febr 2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('Febr 2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
// Ordinal numbers: 25th, 22nd, 3rd, 1st
|
||||
test('should parse ordinal number "25th Jan 2024"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25th Jan 2024');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 1);
|
||||
expect(parsedDate.year, 2024);
|
||||
});
|
||||
|
||||
test('should parse ordinal number "22nd Feb"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('22nd Feb');
|
||||
expect(parsedDate.day, 22);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
test('should parse ordinal number "3rd March"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('3rd March');
|
||||
expect(parsedDate.day, 3);
|
||||
expect(parsedDate.month, 3);
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
// Flexible combinations
|
||||
test('should parse "25th Feb" (generic date)', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25th Feb');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
test('should parse "February 2025" (month-year query)', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('February 2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse "25th of February 2025"', () {
|
||||
final PartialDate parsedDate =
|
||||
dateParseService.parse('25th of February 2025');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Structured Date Format Support ---
|
||||
group('Structured Date Format Support', () {
|
||||
// ISO format: 2025-02-25, 2025/02/25
|
||||
test('should parse ISO format "2025-02-25"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('2025-02-25');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse ISO format "2025/02/25"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('2025/02/25');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
// Standard formats: 02/25/2025, 25/02/2025 (with MM/DD vs DD/MM detection)
|
||||
test('should parse standard MM/DD/YYYY format "02/25/2025"', () {
|
||||
// Your parser assumes MM/DD if ambiguous (e.g., both parts <= 12)
|
||||
// but for 02/25/2025, 25 > 12, so it correctly interprets 02 as month and 25 as day.
|
||||
final PartialDate parsedDate = dateParseService.parse('02/25/2025');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse standard DD/MM/YYYY format "25/02/2025"', () {
|
||||
// Your parser handles DD/MM explicitly when day part > 12
|
||||
final PartialDate parsedDate = dateParseService.parse('25/02/2025');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse ambiguous "01/02/2024" as MM/DD/YYYY (Jan 2)', () {
|
||||
// Test your specific heuristic for ambiguous cases
|
||||
final PartialDate parsedDate = dateParseService.parse('01/02/2024');
|
||||
expect(parsedDate.day, 2);
|
||||
expect(parsedDate.month, 1);
|
||||
expect(parsedDate.year, 2024);
|
||||
});
|
||||
|
||||
// Dot notation: 25.02.2025, 25.02.25
|
||||
test('should parse dot notation "25.02.2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25.02.2025');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse dot notation with two-digit year "25.02.25"', () {
|
||||
// Assumes century detection (e.g., 25 -> 2025)
|
||||
final PartialDate parsedDate = dateParseService.parse('25.02.25');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
// Compact format: 20250225
|
||||
test('should parse compact format "20250225"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('20250225');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
// Short formats: 02/25, 25/02 (your parser doesn't explicitly handle short yearless formats)
|
||||
// Based on your _standardFormatRegex: RegExp(r'^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$');
|
||||
// and _parseTokenizedDate, "02/25" would be processed by _parseTokenizedDate.
|
||||
// Let's test how your current parser handles these.
|
||||
test(
|
||||
'should parse short MM/DD format "02/25" (no year, handled by tokenized)',
|
||||
() {
|
||||
final PartialDate parsedDate = dateParseService.parse('02/25');
|
||||
expect(parsedDate.day, 25); // value 25 is assigned to day first
|
||||
expect(parsedDate.month, 2); // value 02 is assigned to month
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'should parse short DD/MM format "25/02" (no year, handled by tokenized)',
|
||||
() {
|
||||
// This will be parsed by _parseTokenizedDate
|
||||
final PartialDate parsedDate = dateParseService.parse('25/02');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
// Two-digit years: 25/02/25 (with century detection)
|
||||
test('should parse two-digit year "25/02/25"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25/02/25');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025); // Based on _convertTwoDigitYear pivot
|
||||
});
|
||||
|
||||
test('should parse two-digit year "01/01/01" as 2001', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('01/01/01');
|
||||
expect(parsedDate.day, 1);
|
||||
expect(parsedDate.month, 1);
|
||||
expect(parsedDate.year, 2001); // 01 < _TWO_DIGIT_YEAR_PIVOT
|
||||
});
|
||||
|
||||
test('should parse two-digit year "01/01/99" as 1999', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('01/01/99');
|
||||
expect(parsedDate.day, 1);
|
||||
expect(parsedDate.month, 1);
|
||||
expect(parsedDate.year, 1999); // 99 > _TWO_DIGIT_YEAR_PIVOT
|
||||
});
|
||||
});
|
||||
|
||||
// --- Smart Query Types ---
|
||||
group('Smart Query Types', () {
|
||||
test('should parse year-only query "2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, isNull);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse month-year query "February 2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('February 2025');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
|
||||
test('should parse generic date query "25th Feb" (year is null)', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25th Feb');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, isNull);
|
||||
});
|
||||
|
||||
test('should parse specific date query "25/02/2025"', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('25/02/2025');
|
||||
expect(parsedDate.day, 25);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(parsedDate.year, 2025);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Invalid Date Queries ---
|
||||
group('Invalid Date Queries', () {
|
||||
test('should parse "February 30000" as month-only (invalid year ignored)',
|
||||
() {
|
||||
final PartialDate parsedDate = dateParseService.parse('February 30000');
|
||||
expect(parsedDate.day, isNull);
|
||||
expect(parsedDate.month, 2);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
isNull,
|
||||
reason: 'Year 30000 is out of range and should be ignored',
|
||||
);
|
||||
});
|
||||
|
||||
// Specific case to test if invalid day/month values are set to null
|
||||
test('should return null for invalid day/month in tokenized parsing', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('32 Jan 2024');
|
||||
expect(parsedDate.day, isNull, reason: 'Day should be null for 32');
|
||||
expect(parsedDate.month, 1);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
2024,
|
||||
);
|
||||
|
||||
// "Jan 13 2024" - This is a valid date (Jan 13, 2024), should parse completely.
|
||||
final PartialDate parsedDate2 = dateParseService.parse('Jan 13 2024');
|
||||
expect(parsedDate2.day, 13);
|
||||
expect(parsedDate2.month, 1);
|
||||
expect(parsedDate2.year, 2024);
|
||||
|
||||
// "Feb 0 2024" - Day 0 should be null, but month and year are valid.
|
||||
final PartialDate parsedDate3 = dateParseService.parse('Feb 0 2024');
|
||||
expect(parsedDate3.day, isNull, reason: 'Day should be null for 0');
|
||||
expect(parsedDate3.month, 2);
|
||||
expect(parsedDate3.year, 2024);
|
||||
});
|
||||
|
||||
test('should handle invalid day/month in tokenized parsing gracefully', () {
|
||||
final PartialDate parsedDate = dateParseService.parse('32 Jan 2024');
|
||||
expect(parsedDate.day, isNull, reason: 'Day should be null for 32');
|
||||
expect(parsedDate.month, 1);
|
||||
expect(
|
||||
parsedDate.year,
|
||||
2024,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE trash RESET (
|
||||
autovacuum_analyze_scale_factor,
|
||||
autovacuum_vacuum_scale_factor,
|
||||
autovacuum_analyze_threshold,
|
||||
autovacuum_vacuum_threshold
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE trash SET (
|
||||
autovacuum_analyze_scale_factor = 0.01, -- Trigger ANALYZE after 1% of rows change
|
||||
autovacuum_vacuum_scale_factor = 0.02, -- Trigger VACUUM after 2% of rows change
|
||||
autovacuum_analyze_threshold = 1000,
|
||||
autovacuum_vacuum_threshold = 1000
|
||||
);
|
||||
@@ -347,7 +347,7 @@ func (t *TrashRepository) GetTimeStampForLatestNonDeletedEntry(userID int64) (*i
|
||||
// GetUserIDToFileIDsMapForDeletion returns map of userID to fileIds, where the file ids which should be deleted by now
|
||||
func (t *TrashRepository) GetUserIDToFileIDsMapForDeletion() (map[int64][]int64, error) {
|
||||
rows, err := t.DB.Query(`SELECT user_id, file_id FROM trash
|
||||
WHERE delete_by <= $1 AND is_deleted = FALSE AND is_restored = FALSE limit $2`,
|
||||
WHERE delete_by <= $1 AND is_deleted IS FALSE AND is_restored IS FALSE limit $2`,
|
||||
time.Microseconds(), TrashDiffLimit)
|
||||
if err != nil {
|
||||
return nil, stacktrace.Propagate(err, "")
|
||||
|
||||
@@ -404,10 +404,7 @@ const Footer: React.FC = () => {
|
||||
return (
|
||||
<Stack sx={{ my: "4rem", gap: 2, alignItems: "center" }}>
|
||||
<Typography>{t("auth_download_mobile_app")}</Typography>
|
||||
<a
|
||||
href="https://github.com/ente-io/ente/tree/main/auth#-download"
|
||||
download
|
||||
>
|
||||
<a href="https://ente.io/auth/#download-auth" download>
|
||||
<Button color="accent">{t("download")}</Button>
|
||||
</a>
|
||||
</Stack>
|
||||
|
||||