mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-12 20:22:03 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e413482e6 | ||
|
|
9a77cb0aab | ||
|
|
6d8009725e | ||
|
|
2995e0c4d9 | ||
|
|
84b3b8ebd2 | ||
|
|
28f067f808 |
No files matched your search
+11
-11
@@ -5,6 +5,7 @@ go 1.27.1
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
@@ -21,21 +22,21 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/gtsteffaniak/go-media v1.2.1
|
||||
github.com/gtsteffaniak/go-media v1.2.2
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/image v0.48.0
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/sync v0.25.0
|
||||
golang.org/x/sys v0.50.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
golang.org/x/text v0.44.0
|
||||
golang.org/x/time v0.16.0
|
||||
golang.org/x/tools v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -44,7 +45,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.61.0 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.62.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/abema/go-mp4 v1.7.4 // indirect
|
||||
github.com/at-wat/ebml-go v0.19.4 // indirect
|
||||
@@ -54,10 +55,10 @@ require (
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.6 // indirect
|
||||
github.com/gen2brain/h265 v0.2.4 // indirect
|
||||
github.com/gen2brain/jxl v0.2.0 // indirect
|
||||
github.com/gen2brain/vpx v0.2.1 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.7 // indirect
|
||||
github.com/gen2brain/h265 v0.2.5 // indirect
|
||||
github.com/gen2brain/jxl v0.2.1 // indirect
|
||||
github.com/gen2brain/vpx v0.2.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
@@ -80,7 +81,6 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0 // indirect
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
|
||||
+22
-24
@@ -2,8 +2,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Eyevinn/mp4ff v0.61.0 h1:uX3GBauEouxuGepRbv15JB7X2RyVyE2EZEfLpgimL6o=
|
||||
github.com/Eyevinn/mp4ff v0.61.0/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/Eyevinn/mp4ff v0.62.1 h1:EMuNIrmTCm5acOCVENca5Fzk+Mdek/l4bQZdk2EfReo=
|
||||
github.com/Eyevinn/mp4ff v0.62.1/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
@@ -25,6 +25,8 @@ github.com/at-wat/ebml-go v0.19.4/go.mod h1:wu3fQj+DWo6SOOl9c4yDi+gFs9Oa96Z2B5qx
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
@@ -62,14 +64,14 @@ github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDm
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
github.com/gen2brain/folio v0.2.8 h1:9iDI7h+zt4mjwEnzm1x8Pl9BrWqLwUwB74X/fnc1PZs=
|
||||
github.com/gen2brain/folio v0.2.8/go.mod h1:4/PcXfaXIYmW1nwQ4bJeE+aG5ybG58Aw9xrwODXaXII=
|
||||
github.com/gen2brain/gav1d v0.2.6 h1:7TSkrWTdE22k5Ul7vYZML7Nu80gNW6OovV7FSCITSE0=
|
||||
github.com/gen2brain/gav1d v0.2.6/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.4 h1:JUSbGkRAYZpd7ucCL192yx4ViEo75ZO6OEdm1TQt9qM=
|
||||
github.com/gen2brain/h265 v0.2.4/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.0 h1:cXSQnfbcC0JGFzaI344VzPMbe7mh6rYc0tjPcBUsE4U=
|
||||
github.com/gen2brain/jxl v0.2.0/go.mod h1:VjJHRai/8I8Es01mULqyiOihRBOjJr1Zo+NoN/MyIwo=
|
||||
github.com/gen2brain/vpx v0.2.1 h1:UD6tibcs/zlaIOKNFXeVyZuHBEaW9GYfxKSxvAN9cc8=
|
||||
github.com/gen2brain/vpx v0.2.1/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/gen2brain/gav1d v0.2.7 h1:ehu3jWt+0/46Is5llW/ZyX2OA3hTBJNukKnebzuk8KU=
|
||||
github.com/gen2brain/gav1d v0.2.7/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.5 h1:D3qCPx5GU+gHpinvLJtYMLSmQy64RGOm4fzMNGQcA1g=
|
||||
github.com/gen2brain/h265 v0.2.5/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.1 h1:74+8uw2RxUHGDVrDp+727PZ2hvAd5/psyV/mpsOQea0=
|
||||
github.com/gen2brain/jxl v0.2.1/go.mod h1:d7VMurg9ek62xfuyZyVAumFcuaQf9U8NlW57j/k5MQI=
|
||||
github.com/gen2brain/vpx v0.2.2 h1:kRCIx6Y2LsTe8hercAfhER2QbeHYnBOetovy4pkC4V0=
|
||||
github.com/gen2brain/vpx v0.2.2/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -157,18 +159,14 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/gtsteffaniak/go-av1 v1.0.0 h1:CILp67bcOtiDXvnrQubnH9ctub0OSKnUNA2JJuoIP+c=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0/go.mod h1:C/dIrjfdUcHgdyChr2YXwLY3pIVjENI9npSd98PU4lE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmmq6v5tnA=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
|
||||
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
|
||||
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
|
||||
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
@@ -258,8 +256,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||
golang.org/x/image v0.48.0 h1:1AvuPcuoOTJpA7s4VZ5umrb90T3So0rkB/bSGmxkxik=
|
||||
golang.org/x/image v0.48.0/go.mod h1:mmtg64iqJKGqEgy+r13dfybl85tmxUd2lMnmaiSdWYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
@@ -279,8 +277,8 @@ golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -292,8 +290,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
@@ -303,8 +301,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -14,13 +14,13 @@ func TestUserUpdateChangesSkipsSensitiveFields(t *testing.T) {
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
DarkMode: users.DarkModeDark,
|
||||
},
|
||||
},
|
||||
TOTPSecret: "secret-value",
|
||||
}
|
||||
after := *before
|
||||
after.DarkMode = false
|
||||
after.DarkMode = users.DarkModeLight
|
||||
after.TOTPSecret = "new-secret"
|
||||
|
||||
changes := UserUpdateChanges(before, &after, []string{"totpSecret", "darkMode"}, false)
|
||||
@@ -36,7 +36,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "akadmin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
DarkMode: users.DarkModeDark,
|
||||
StickySidebar: true,
|
||||
Locale: "en",
|
||||
SingleClick: false,
|
||||
@@ -47,7 +47,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
},
|
||||
}
|
||||
after := *before
|
||||
after.DarkMode = false
|
||||
after.DarkMode = users.DarkModeLight
|
||||
after.Sorting = users.Sorting{By: "name", Asc: true}
|
||||
|
||||
which := []string{
|
||||
@@ -60,7 +60,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("expected 3 changes, got %d: %#v", len(changes), changes)
|
||||
}
|
||||
if changes[0].Field != "darkMode" || changes[0].From != "true" || changes[0].To != "false" {
|
||||
if changes[0].Field != "darkMode" || changes[0].From != "dark" || changes[0].To != "light" {
|
||||
t.Fatalf("unexpected darkMode change: %#v", changes[0])
|
||||
}
|
||||
if changes[1].Field != "sorting.asc" || changes[1].From != "false" || changes[1].To != "true" {
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestFinishUserLoad_legacyProfileRowPreservesAbsentSettingsFields(t *testing
|
||||
if !loaded.ShowFirstLogin {
|
||||
t.Fatal("expected top-level showFirstLogin to be preserved when settings omits it")
|
||||
}
|
||||
if !loaded.DarkMode {
|
||||
t.Fatal("expected profile darkMode=true")
|
||||
if !loaded.DarkMode.IsDark() {
|
||||
t.Fatalf("expected profile darkMode=true, got %q", loaded.DarkMode)
|
||||
}
|
||||
if len(loaded.PasskeyCredentials) != 0 {
|
||||
t.Fatalf("expected no passkeys when settings omits passkeyCredentials, got %#v", loaded.PasskeyCredentials)
|
||||
@@ -95,7 +95,7 @@ func TestUserDataRoundTrip_profileSettingsOverlay(t *testing.T) {
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
ShowFirstLogin: true,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
DarkMode: users.DarkModeDark,
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "Home", Target: "/files"},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DarkMode is the user's theme preference: "auto" (follow the client's
|
||||
// system preference), "dark", or "light". It unmarshals from legacy JSON
|
||||
// booleans for backward compatibility with existing stored users.
|
||||
type DarkMode string
|
||||
|
||||
const (
|
||||
DarkModeAuto DarkMode = "auto"
|
||||
DarkModeDark DarkMode = "dark"
|
||||
DarkModeLight DarkMode = "light"
|
||||
)
|
||||
|
||||
// UnmarshalJSON accepts legacy boolean values (true -> dark, false -> light),
|
||||
// the tri-state strings ("auto", "dark", "light"), and null (unset).
|
||||
func (d *DarkMode) UnmarshalJSON(data []byte) error {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
*d = ""
|
||||
case bool:
|
||||
if val {
|
||||
*d = DarkModeDark
|
||||
} else {
|
||||
*d = DarkModeLight
|
||||
}
|
||||
case string:
|
||||
switch DarkMode(strings.ToLower(val)) {
|
||||
case "":
|
||||
*d = ""
|
||||
case "true":
|
||||
// Some YAML decoders cast scalar booleans to strings.
|
||||
*d = DarkModeDark
|
||||
case "false":
|
||||
*d = DarkModeLight
|
||||
case DarkModeAuto:
|
||||
*d = DarkModeAuto
|
||||
case DarkModeDark:
|
||||
*d = DarkModeDark
|
||||
case DarkModeLight:
|
||||
*d = DarkModeLight
|
||||
default:
|
||||
return fmt.Errorf("invalid darkMode value %q: must be \"auto\", \"dark\" or \"light\"", val)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid darkMode value: must be a boolean or one of \"auto\", \"dark\", \"light\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDark reports the effective boolean value for server-side rendering
|
||||
// contexts. "auto" cannot be resolved server-side; callers that can know the
|
||||
// client preference should handle DarkModeAuto explicitly.
|
||||
func (d DarkMode) IsDark() bool {
|
||||
return d == DarkModeDark
|
||||
}
|
||||
|
||||
// Or returns d unless it is unset (""), in which case it returns def.
|
||||
func (d DarkMode) Or(def DarkMode) DarkMode {
|
||||
if d == "" {
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -308,7 +308,7 @@ type NonAdminEditable struct {
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
DarkMode DarkMode `json:"darkMode"` // theme preference: "auto" (follow system), "dark", or "light"
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
|
||||
@@ -80,6 +80,11 @@ var heifImageExts = map[string]bool{
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
|
||||
// stillImageMaxSourcePixels caps the original image area (width×height) passed to
|
||||
// go-media for HEIC, JPEG XL, and camera RAW decode. JPEG XL honors it today
|
||||
// (~50 MiB of 8-bit RGB at 4096×4096); other still formats ignore the field.
|
||||
const stillImageMaxSourcePixels = 4096 * 4096
|
||||
|
||||
// cameraRawExts match go-media rawpreview formats (also iteminfo raw extensions).
|
||||
var cameraRawExts = map[string]bool{
|
||||
".cr2": true, ".cr3": true, ".nef": true, ".nrw": true, ".arw": true, ".srf": true, ".sr2": true,
|
||||
@@ -628,10 +633,11 @@ func stillImageJPEG(path string, maxW, maxH, quality int, op string, supported f
|
||||
quality = 85
|
||||
}
|
||||
res, err := media.Thumbnail(f, size, media.ThumbnailOptions{
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
MaxSourcePixels: stillImageMaxSourcePixels,
|
||||
})
|
||||
if err != nil {
|
||||
logFail(err)
|
||||
|
||||
@@ -2,11 +2,13 @@ package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -66,3 +68,24 @@ func TestJXLJPEGMissingFileReturnsFalse(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
require.Empty(t, data)
|
||||
}
|
||||
|
||||
// JPEG XL above stillImageMaxSourcePixels must fail with go-media's ErrUnsupported
|
||||
// and return ok=false (no ffmpeg fallback).
|
||||
func TestJXLJPEGRejectsOversizedSource(t *testing.T) {
|
||||
withGate(t, false)
|
||||
f, err := os.Open(fixtureJXL)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = media.Thumbnail(f, info.Size(), media.ThumbnailOptions{
|
||||
MaxWidth: 256,
|
||||
MaxHeight: 256,
|
||||
Format: "jpeg",
|
||||
Quality: 85,
|
||||
MaxSourcePixels: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, media.ErrUnsupported), "oversized JXL must surface ErrUnsupported, got %v", err)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -122,23 +123,21 @@ func TestDefaultBackgroundColor(t *testing.T) {
|
||||
saved := settings.Config
|
||||
defer func() { settings.Config = saved }()
|
||||
|
||||
dark := true
|
||||
light := false
|
||||
|
||||
settings.Config.Frontend.Styling.DarkBackground = "#111111"
|
||||
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = &dark
|
||||
settings.Config.UserDefaults.UI.DarkMode = users.DarkModeDark
|
||||
if got := settings.DefaultBackgroundColor(); got != "#111111" {
|
||||
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = &light
|
||||
settings.Config.UserDefaults.UI.DarkMode = users.DarkModeLight
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = nil
|
||||
settings.Config.UserDefaults.UI.DarkMode = ""
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestStateInit_jwtYAML_organizedDefaultsOnNewUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.DarkMode || !loaded.Preview.Image {
|
||||
if !loaded.DarkMode.IsDark() || !loaded.Preview.Image {
|
||||
t.Fatalf("profile fields: darkMode=%v preview.image=%v", loaded.DarkMode, loaded.Preview.Image)
|
||||
}
|
||||
if loaded.BackendScopes[0].Permissions.Modify != access.Modify {
|
||||
|
||||
@@ -1098,14 +1098,14 @@ func TestManagerSuspendsProducerWhenAllClientsPaused(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
if snap := snapshotProducer(t, mgr, sess.ID); !snap.exists || !snap.suspended {
|
||||
t.Fatal("a suspended producer must stay registered and marked suspended")
|
||||
}
|
||||
@@ -1185,13 +1185,13 @@ func TestManagerResumesProducerOnUnpauseHeartbeat(t *testing.T) {
|
||||
pollUntil(t, 2*time.Second, func() bool {
|
||||
return snapshotProducer(t, mgr, sess.ID).encodeHead >= 1
|
||||
}, "encode head to advance past segment 0")
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1216,13 +1216,13 @@ func TestManagerResumesProducerOnSegmentRequest(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
|
||||
// The suspended job reads as !Running, so a request beyond its head takes
|
||||
// the restart path: the relaunched job produces the requested segment.
|
||||
@@ -1246,13 +1246,13 @@ func TestManagerStopReleasesSuspendedProducer(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
|
||||
if err := mgr.Stop(in.Username, sess.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1308,6 +1308,29 @@ func mustStart(t *testing.T, mgr *Manager, in StartInput) SessionInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// waitForProducerVODJobAttached waits until the session's producer has the VOD
|
||||
// job wired up. StartCount can advance before runProducerJob assigns p.job.
|
||||
func waitForProducerVODJobAttached(t *testing.T, mgr *Manager, sessionID string) {
|
||||
t.Helper()
|
||||
pollUntil(t, 5*time.Second, func() bool {
|
||||
mgr.mu.Lock()
|
||||
p := mgr.producers[sessionID]
|
||||
ok := p != nil && p.job != nil
|
||||
mgr.mu.Unlock()
|
||||
return ok
|
||||
}, "producer VOD job attached")
|
||||
}
|
||||
|
||||
// waitForProducerParked waits until suspendPausedProducers has parked the
|
||||
// producer (suspended flag), instead of polling a stale *fakeJob snapshot.
|
||||
func waitForProducerParked(t *testing.T, mgr *Manager, sessionID string) {
|
||||
t.Helper()
|
||||
pollUntil(t, 5*time.Second, func() bool {
|
||||
snap := snapshotProducer(t, mgr, sessionID)
|
||||
return snap.exists && snap.suspended
|
||||
}, "suspended producer to park")
|
||||
}
|
||||
|
||||
// pollUntil fails the test when cond is not met within timeout.
|
||||
func pollUntil(t *testing.T, timeout time.Duration, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
@@ -39,9 +36,6 @@ const (
|
||||
// duplicateResultsCache caches duplicate search results for 15 seconds
|
||||
var duplicateResultsCache = cache.NewCache[duplicateResponse](15 * time.Second)
|
||||
|
||||
// checksumCache caches file checksums for 1 hour, keyed by source/path/modtime
|
||||
var checksumCache = cache.NewCache[string](1 * time.Hour)
|
||||
|
||||
type duplicateGroup struct {
|
||||
Size int64 `json:"size"`
|
||||
Count int `json:"count"`
|
||||
@@ -127,10 +121,12 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
// identical content but different filenames)
|
||||
//
|
||||
// Performance Optimizations:
|
||||
// - Checksums are cached for 1 hour (keyed by path/size/modtime) to speed up subsequent requests
|
||||
// - Files are processed in batches to balance memory usage vs SQL query count
|
||||
// - Resource limits prevent timeouts: max 2 minutes, max 10K checksum operations
|
||||
// - Large fuzzy groups are skipped to avoid expensive checksum operations on false positives
|
||||
// - Checksums are cached for 1 hour (keyed by path/size/modtime) to speed up subsequent requests
|
||||
// The checksum budget counts only cache misses (real disk I/O); cached digests are free,
|
||||
// so a re-run proceeds past the previously-examined files instead of stalling at the limit.
|
||||
// - Files are processed in batches to balance memory usage vs SQL query count
|
||||
// - Resource limits prevent timeouts: max 2 minutes, max 10K checksum operations
|
||||
// - Large fuzzy groups are skipped to avoid expensive checksum operations on false positives
|
||||
//
|
||||
// Response includes incomplete flag if processing was stopped early due to resource limits.
|
||||
//
|
||||
@@ -170,7 +166,10 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
|
||||
// Safety check: Reject duplicate search during active indexing to prevent resource contention
|
||||
// This protects against CPU/memory spikes and ensures indexing performance is not degraded
|
||||
if string(index.Status) == "indexing" {
|
||||
// Use GetStatus() (computed from active scanners and persisted lastScanned) rather than
|
||||
// the raw Status field, which is only set to "ready" by PostScan and would otherwise
|
||||
// block searches indefinitely after a restart until the first scheduled scan runs.
|
||||
if index.GetStatus() == indexing.INDEXING {
|
||||
return http.StatusServiceUnavailable, fmt.Errorf("duplicate search is not available while indexing is in progress - please try again when indexing completes")
|
||||
}
|
||||
|
||||
@@ -238,7 +237,13 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
startTime: time.Now(),
|
||||
uniqueChecksums: make(map[string]bool),
|
||||
}
|
||||
duplicateGroups := findDuplicatesInIndex(index, opts, stats)
|
||||
duplicateGroups := findDuplicatesInIndex(r.Context(), index, opts, stats)
|
||||
|
||||
// The client went away — do not cache or record a truncated result. A cached
|
||||
// partial response would poison the 15s duplicateResultsCache for the next user.
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return http.StatusRequestTimeout, err
|
||||
}
|
||||
|
||||
// Log resource usage for monitoring
|
||||
uniqueChecksumCount := len(stats.uniqueChecksums)
|
||||
@@ -254,8 +259,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
Reason: stats.stopReason,
|
||||
}
|
||||
|
||||
// Cache the results before returning (even partial results)
|
||||
duplicateResultsCache.Set(cacheKey, response)
|
||||
cacheDuplicateResponse(r.Context(), cacheKey, response)
|
||||
|
||||
if stats.stopped {
|
||||
w.Header().Set("X-Search-Incomplete", "true")
|
||||
@@ -267,7 +271,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
|
||||
// findDuplicatesInIndex finds duplicates using the shared IndexDB with resource limits
|
||||
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats *duplicateProcessingStats) []duplicateGroup {
|
||||
func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *duplicatesOptions, stats *duplicateProcessingStats) []duplicateGroup {
|
||||
// Get the shared IndexDB
|
||||
indexDB := indexing.GetIndexDB()
|
||||
|
||||
@@ -290,6 +294,11 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
|
||||
// Process sizes in batches
|
||||
for batchStart := 0; batchStart < len(sizes); batchStart += bulkSizeQueryLimit {
|
||||
if ctx.Err() != nil {
|
||||
stats.stopped = true
|
||||
stats.stopReason = ctx.Err().Error()
|
||||
break
|
||||
}
|
||||
// Check resource limits before processing each batch
|
||||
if shouldStop, reason := stats.shouldStop(); shouldStop {
|
||||
stats.stopped = true
|
||||
@@ -327,6 +336,11 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
// Memory optimization: Delete entries from filesBySize immediately after processing
|
||||
// to allow GC to reclaim memory as we go, rather than holding all data until batch completes
|
||||
for _, size := range sizeBatch {
|
||||
if ctx.Err() != nil {
|
||||
stats.stopped = true
|
||||
stats.stopReason = ctx.Err().Error()
|
||||
break
|
||||
}
|
||||
files, ok := filesBySize[size]
|
||||
if !ok || len(files) < 2 {
|
||||
// Clear entry even if not processing to free memory
|
||||
@@ -345,8 +359,8 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
continue
|
||||
}
|
||||
|
||||
// Group files by MIME type in memory (already ordered by type from SQL)
|
||||
filesByType := groupFilesByType(files)
|
||||
// Group files by MIME (+ extension when MIME is ambiguous) within this size bucket.
|
||||
filesByType := groupFilesByTypeBucket(files)
|
||||
|
||||
// Process each MIME type group
|
||||
for _, typeFiles := range filesByType {
|
||||
@@ -364,29 +378,13 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
|
||||
// Use fuzzy filename matching to group files within this MIME type
|
||||
// This reduces checksum operations by grouping similar filenames
|
||||
filenameGroups := groupFilesByFilename(typeFiles, size)
|
||||
filenameGroups := groupFilesByFilename(ctx, typeFiles, size, stats)
|
||||
|
||||
totalFilesInGroups := 0
|
||||
for _, g := range filenameGroups {
|
||||
if len(g) >= 2 {
|
||||
totalFilesInGroups += len(g)
|
||||
}
|
||||
}
|
||||
|
||||
// Process each fuzzy filename group
|
||||
for _, fileGroup := range filenameGroups {
|
||||
if len(fileGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip checksumming if fuzzy group is too large - groups with many files
|
||||
// are unlikely to be true duplicates (fuzzy matching is too permissive)
|
||||
// This optimization prevents expensive checksum operations on false positives
|
||||
if len(fileGroup) > maxFuzzyGroupSize {
|
||||
logger.Debugf("[Duplicates] Skipping checksum for fuzzy group with %d files (exceeds limit of %d)", len(fileGroup), maxFuzzyGroupSize)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stop if we've hit the group limit
|
||||
if len(groupsWithChecksums) >= maxGroups {
|
||||
break
|
||||
@@ -403,7 +401,7 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
// Verify with checksums using 3-pass progressive verification
|
||||
// At this point, files match on: size + MIME type + fuzzy filename similarity (50%+)
|
||||
// Large fuzzy groups (>10 files) are skipped above to avoid expensive false positives
|
||||
verifiedGroups := groupFilesByChecksum(fileGroup, index, size, stats)
|
||||
verifiedGroups := groupFilesByChecksum(ctx, fileGroup, index, size, stats)
|
||||
|
||||
// Create SearchResult objects and track checksums for merging
|
||||
for _, checksumGroup := range verifiedGroups {
|
||||
@@ -483,100 +481,15 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
})
|
||||
}
|
||||
|
||||
// Groups are already sorted by size (largest to smallest) from SQL query
|
||||
return duplicateGroups
|
||||
}
|
||||
|
||||
// mergeGroupsByChecksum merges groups that share any checksum values
|
||||
// This catches cases where files have identical content but were grouped separately
|
||||
func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGroupWithChecksums {
|
||||
if len(groups) <= 1 {
|
||||
return groups
|
||||
// cacheDuplicateResponse stores a duplicate search response when the client context is still active.
|
||||
func cacheDuplicateResponse(ctx context.Context, cacheKey string, response duplicateResponse) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build a map: checksum -> list of group indices that contain this checksum
|
||||
checksumToGroups := make(map[string][]int)
|
||||
for i, group := range groups {
|
||||
if group.Checksum != "" {
|
||||
checksumToGroups[group.Checksum] = append(checksumToGroups[group.Checksum], i)
|
||||
}
|
||||
}
|
||||
|
||||
// Use union-find to merge groups that share checksums
|
||||
parent := make([]int, len(groups))
|
||||
for i := range parent {
|
||||
parent[i] = i
|
||||
}
|
||||
|
||||
var find func(int) int
|
||||
find = func(x int) int {
|
||||
if parent[x] != x {
|
||||
parent[x] = find(parent[x]) // Path compression
|
||||
}
|
||||
return parent[x]
|
||||
}
|
||||
|
||||
union := func(x, y int) {
|
||||
px, py := find(x), find(y)
|
||||
if px != py {
|
||||
parent[px] = py
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups that share any checksum
|
||||
for _, groupIndices := range checksumToGroups {
|
||||
if len(groupIndices) < 2 {
|
||||
continue
|
||||
}
|
||||
// Merge all groups that share this checksum
|
||||
for i := 1; i < len(groupIndices); i++ {
|
||||
union(groupIndices[0], groupIndices[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Group indices by their root parent
|
||||
groupsByRoot := make(map[int][]int)
|
||||
for i := range groups {
|
||||
root := find(i)
|
||||
groupsByRoot[root] = append(groupsByRoot[root], i)
|
||||
}
|
||||
|
||||
// Merge groups that belong to the same root
|
||||
merged := make([]duplicateGroupWithChecksums, 0, len(groupsByRoot))
|
||||
for _, indices := range groupsByRoot {
|
||||
if len(indices) == 1 {
|
||||
// No merging needed, just add the group
|
||||
merged = append(merged, groups[indices[0]])
|
||||
continue
|
||||
}
|
||||
|
||||
// Merge multiple groups
|
||||
combinedFiles := make([]*indexing.SearchResult, 0)
|
||||
var combinedSize int64
|
||||
var combinedChecksum string
|
||||
|
||||
for _, idx := range indices {
|
||||
group := groups[idx]
|
||||
combinedFiles = append(combinedFiles, group.Files...)
|
||||
if combinedChecksum == "" {
|
||||
combinedChecksum = group.Checksum
|
||||
}
|
||||
if combinedSize == 0 {
|
||||
combinedSize = group.Size
|
||||
}
|
||||
}
|
||||
|
||||
// Only include merged groups with 2+ files
|
||||
if len(combinedFiles) >= 2 {
|
||||
merged = append(merged, duplicateGroupWithChecksums{
|
||||
Size: combinedSize,
|
||||
Files: combinedFiles,
|
||||
Checksum: combinedChecksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
duplicateResultsCache.Set(cacheKey, response)
|
||||
}
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *Context) (*duplicatesOptions, error) {
|
||||
@@ -624,349 +537,6 @@ func prepDuplicatesOptions(r *http.Request, d *Context) (*duplicatesOptions, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// groupFilesByChecksum groups files by partial checksum and returns groups with their checksums
|
||||
func groupFilesByChecksum(files []*iteminfo.FileInfo, index *indexing.Index, fileSize int64, stats *duplicateProcessingStats) []checksumGroup {
|
||||
if len(files) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PASS 1: Header checksums only (first 8KB)
|
||||
// This is the fastest way to eliminate files that are definitely different
|
||||
// ONLY checksum files that already matched on filename+size
|
||||
headerGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
|
||||
for _, file := range files {
|
||||
// Check if we've hit the checksum operation limit
|
||||
if stats.checksumOperations >= maxChecksumOperations {
|
||||
logger.Warningf("[Duplicates] Reached checksum operation limit (%d) in header pass", maxChecksumOperations)
|
||||
break
|
||||
}
|
||||
|
||||
// Verify size matches expected size (should match as we queried by size)
|
||||
if file.Size != fileSize {
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct filesystem path for checksum computation
|
||||
// index.Path is the absolute filesystem root, file.Path is index-relative
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
headerChecksum, err := computeHeaderChecksum(index.Path, filePath, fileSize, file.ModTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats.checksumOperations++
|
||||
stats.uniqueChecksums[headerChecksum] = true
|
||||
headerGroups[headerChecksum] = append(headerGroups[headerChecksum], file)
|
||||
}
|
||||
|
||||
// Filter to only groups with 2+ files (candidates for duplicates)
|
||||
headerCandidates := make([][]*iteminfo.FileInfo, 0)
|
||||
filesAfterHeaderPass := 0
|
||||
for _, group := range headerGroups {
|
||||
if len(group) >= 2 {
|
||||
headerCandidates = append(headerCandidates, group)
|
||||
filesAfterHeaderPass += len(group)
|
||||
}
|
||||
}
|
||||
|
||||
if len(headerCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PASS 2: Middle checksums (header + middle) for header-matched groups
|
||||
// Only process files that matched on header to save disk I/O
|
||||
middleGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
|
||||
for _, headerGroup := range headerCandidates {
|
||||
if len(headerGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range headerGroup {
|
||||
// Check if we've hit the checksum operation limit
|
||||
if stats.checksumOperations >= maxChecksumOperations {
|
||||
logger.Warningf("[Duplicates] Reached checksum operation limit (%d) in middle pass", maxChecksumOperations)
|
||||
break
|
||||
}
|
||||
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
middleChecksum, err := computeMiddleChecksum(index.Path, filePath, fileSize, file.ModTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats.checksumOperations++
|
||||
stats.uniqueChecksums[middleChecksum] = true
|
||||
middleGroups[middleChecksum] = append(middleGroups[middleChecksum], file)
|
||||
}
|
||||
}
|
||||
|
||||
// Final verification: Use middle checksum as final (header + middle is sufficient)
|
||||
// Files that match on header + middle but differ at end are extremely rare
|
||||
// This 2-pass system catches 99.9%+ of differences while being faster
|
||||
groups := make([]checksumGroup, 0, len(middleGroups))
|
||||
for checksum, group := range middleGroups {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, checksumGroup{
|
||||
Files: group,
|
||||
Checksum: checksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// computeHeaderChecksum calculates MD5 hash of only the first 8KB of a file
|
||||
// This is the fastest initial pass to eliminate non-matching files
|
||||
func computeHeaderChecksum(sourcePath, filePath string, size int64, modTime time.Time) (string, error) {
|
||||
// Generate cache key for header-only checksum
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%d:header", sourcePath, filePath, size, modTime.Unix())
|
||||
|
||||
// Check cache first
|
||||
if cachedChecksum, ok := checksumCache.Get(cacheKey); ok {
|
||||
return cachedChecksum, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Read only first 8KB (or entire file if smaller)
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
checksum := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
// Cache the result
|
||||
checksumCache.Set(cacheKey, checksum)
|
||||
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
// computeMiddleChecksum calculates MD5 hash of header + middle portion
|
||||
// Only called when header checksums match (progressive verification)
|
||||
func computeMiddleChecksum(sourcePath, filePath string, size int64, modTime time.Time) (string, error) {
|
||||
// Generate cache key for header+middle checksum
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%d:middle", sourcePath, filePath, size, modTime.Unix())
|
||||
|
||||
// Check cache first
|
||||
if cachedChecksum, ok := checksumCache.Get(cacheKey); ok {
|
||||
return cachedChecksum, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Read first 8KB
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
// For larger files, also read middle 8KB
|
||||
if size > 16384 { // 16KB
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
checksum := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
// Cache the result
|
||||
checksumCache.Set(cacheKey, checksum)
|
||||
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
// groupFilesByFilename groups files by fuzzy filename matching
|
||||
// Works with iteminfo.FileInfo from the main IndexDB
|
||||
// Normalizes filenames on-the-fly for comparison
|
||||
// NOTE: Files passed in should already be filtered by MIME type at the SQL level,
|
||||
// so no need to check extensions here (extension checking is redundant with MIME type filtering)
|
||||
func groupFilesByFilename(files []*iteminfo.FileInfo, expectedSize int64) [][]*iteminfo.FileInfo {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := [][]*iteminfo.FileInfo{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(files); i++ {
|
||||
if used[i] || files[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []*iteminfo.FileInfo{files[i]}
|
||||
used[i] = true
|
||||
|
||||
// Normalize filename for fuzzy matching
|
||||
filename1 := normalizeFilename(files[i].Name)
|
||||
|
||||
for j := i + 1; j < len(files); j++ {
|
||||
if used[j] || files[j].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check size (should match, but safe to check)
|
||||
if files[j].Size != expectedSize {
|
||||
continue
|
||||
}
|
||||
|
||||
filename2 := normalizeFilename(files[j].Name)
|
||||
|
||||
// Check if filenames are similar enough using levenshtein distance
|
||||
if filenamesSimilar(filename1, filename2) {
|
||||
group = append(group, files[j])
|
||||
used[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// normalizeFilename converts filename to lowercase and removes special characters
|
||||
// to make fuzzy matching more effective
|
||||
func normalizeFilename(filename string) string {
|
||||
// Convert to lowercase
|
||||
filename = strings.ToLower(filename)
|
||||
|
||||
// Remove file extension for comparison
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
// Remove non-alphanumeric characters except spaces and hyphens
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// filenamesSimilar checks if two normalized filenames are similar enough to be considered duplicates
|
||||
func filenamesSimilar(name1, name2 string) bool {
|
||||
// Exact match
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty names don't match
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
distance := levenshteinDistance(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
|
||||
// Require at least 50% similarity (lowered to catch more duplicates with username prefixes)
|
||||
// Examples: "video.mp4" vs "username_video.mp4" should match
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.5
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the edit distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
|
||||
// Initialize first row and column
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// groupFilesByType groups files by MIME type
|
||||
// Assumes files are already sorted by type (from SQL ORDER BY)
|
||||
func groupFilesByType(files []*iteminfo.FileInfo) map[string][]*iteminfo.FileInfo {
|
||||
grouped := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, file := range files {
|
||||
grouped[file.Type] = append(grouped[file.Type], file)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// filterFilesByPermission filters files to only include those the user is permitted to access
|
||||
// This is called early in the duplicate search process to avoid processing files the user can't see
|
||||
func filterFilesByPermission(files []*iteminfo.FileInfo, index *indexing.Index, username string) []*iteminfo.FileInfo {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
type duplicateCorpus struct {
|
||||
dir string
|
||||
files []*iteminfo.FileInfo
|
||||
}
|
||||
|
||||
func buildDuplicateCorpus(b *testing.B) *duplicateCorpus {
|
||||
b.Helper()
|
||||
dir := b.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
write := func(name string, payload []byte, mutateTail bool) {
|
||||
if mutateTail && len(payload) > 32 {
|
||||
dup := append([]byte(nil), payload...)
|
||||
dup[len(dup)-1] ^= 0xff
|
||||
payload = dup
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), payload, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
jpegHeader := []byte{0xff, 0xd8, 0xff, 0xe0}
|
||||
pngHeader := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
pdfHeader := []byte("%PDF-1.4\n")
|
||||
mp4Header := []byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p'}
|
||||
|
||||
mkPayload := func(header []byte, size int) []byte {
|
||||
out := make([]byte, size)
|
||||
copy(out, header)
|
||||
for i := len(header); i < len(out); i++ {
|
||||
out[i] = byte(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
baseJPEG := mkPayload(jpegHeader, 65536)
|
||||
basePNG := mkPayload(pngHeader, 65536)
|
||||
basePDF := mkPayload(pdfHeader, 65536)
|
||||
baseMP4 := mkPayload(mp4Header, 65536)
|
||||
|
||||
entries := []struct {
|
||||
name string
|
||||
typ string
|
||||
data []byte
|
||||
}{
|
||||
{"exact-a.jpg", "image/jpeg", baseJPEG},
|
||||
{"exact-b.jpg", "image/jpeg", baseJPEG},
|
||||
{"renamed-copy.jpeg", "image/jpeg", baseJPEG},
|
||||
{"user_exact-a.jpg", "image/jpeg", baseJPEG},
|
||||
{"png-a.png", "image/png", basePNG},
|
||||
{"png-b.png", "image/png", basePNG},
|
||||
{"doc-a.pdf", "application/pdf", basePDF},
|
||||
{"doc-b.pdf", "application/pdf", basePDF},
|
||||
{"clip-a.mp4", "video/mp4", baseMP4},
|
||||
{"clip-b.mp4", "video/mp4", baseMP4},
|
||||
{"sized-a.bin", "application/octet-stream", baseJPEG},
|
||||
{"sized-b.bin", "application/octet-stream", mkPayload(jpegHeader, 65536)},
|
||||
}
|
||||
|
||||
files := make([]*iteminfo.FileInfo, 0, len(entries)+50)
|
||||
for _, e := range entries {
|
||||
write(e.name, e.data, false)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: e.name, Size: int64(len(e.data)), ModTime: now, Type: e.typ},
|
||||
Path: "/" + e.name,
|
||||
})
|
||||
}
|
||||
write("tail-b.jpg", baseJPEG, true)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
name := fmt.Sprintf("fuzzy_photo_%d.jpg", i)
|
||||
write(name, baseJPEG, false)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: name, Size: int64(len(baseJPEG)), ModTime: now, Type: "image/jpeg"},
|
||||
Path: "/" + name,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
name := fmt.Sprintf("bucket_%04d.jpg", i)
|
||||
write(name, mkPayload(jpegHeader, 65536), i%7 == 0)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: name, Size: 65536, ModTime: now, Type: "image/jpeg"},
|
||||
Path: "/" + name,
|
||||
})
|
||||
}
|
||||
|
||||
return &duplicateCorpus{dir: dir, files: files}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateTypeBuckets(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByTypeBucket(corpus.files)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateFilenameGroups(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
subset := corpus.files[len(corpus.files)-30:]
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByFilename(ctx, subset, 65536, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateChecksumGrouping(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
pair := []*iteminfo.FileInfo{corpus.files[0], corpus.files[1]}
|
||||
idx := &indexing.Index{Path: corpus.dir}
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now(), uniqueChecksums: map[string]bool{}}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByChecksum(ctx, pair, idx, pair[0].Size, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateMerge(b *testing.B) {
|
||||
mkFiles := func(n int, prefix string) []*indexing.SearchResult {
|
||||
out := make([]*indexing.SearchResult, n)
|
||||
for i := range out {
|
||||
out[i] = &indexing.SearchResult{Path: fmt.Sprintf("/%s/%d", prefix, i)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
groups := []duplicateGroupWithChecksums{
|
||||
{Size: 100, Checksum: "d1", Files: mkFiles(4, "a")},
|
||||
{Size: 100, Checksum: "d1", Files: mkFiles(3, "b")},
|
||||
{Size: 200, Checksum: "d2", Files: mkFiles(2, "c")},
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = mergeGroupsByChecksum(groups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// typeBucketKey returns the MIME/extension bucket used inside a fixed-size group.
|
||||
// When the MIME type already implies a single on-disk format, extension is omitted
|
||||
// as a redundant pre-filter. Otherwise a normalized lowercase extension is appended.
|
||||
//
|
||||
// JPEG aliases: ".jpg", ".jpeg", and ".jpe" normalize to "jpg" so differently named
|
||||
// JPEGs stay in one bucket without splitting the MIME-implied image/jpeg group.
|
||||
func typeBucketKey(file *iteminfo.FileInfo) string {
|
||||
mime := file.Type
|
||||
if mimeImpliesExtension(mime) {
|
||||
return mime
|
||||
}
|
||||
return mime + "\x1f" + normalizedFileExtension(file.Name)
|
||||
}
|
||||
|
||||
func mimeImpliesExtension(mime string) bool {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff",
|
||||
"video/mp4", "video/webm", "video/quicktime", "video/x-msvideo",
|
||||
"audio/mpeg", "audio/wav", "audio/flac", "audio/ogg",
|
||||
"application/pdf", "application/zip", "application/gzip",
|
||||
"text/plain", "text/html", "text/css", "application/json":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizedFileExtension returns a lowercase extension without the dot, folding JPEG aliases to "jpg".
|
||||
func normalizedFileExtension(name string) string {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
switch ext {
|
||||
case "jpeg", "jpe":
|
||||
return "jpg"
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
func groupFilesByTypeBucket(files []*iteminfo.FileInfo) map[string][]*iteminfo.FileInfo {
|
||||
grouped := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, file := range files {
|
||||
key := typeBucketKey(file)
|
||||
grouped[key] = append(grouped[key], file)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestTypeBucketKeyJPEGAliases(t *testing.T) {
|
||||
jpg := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "a.jpg", Type: "image/jpeg"}}
|
||||
jpeg := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "b.jpeg", Type: "image/jpeg"}}
|
||||
if typeBucketKey(jpg) != typeBucketKey(jpeg) {
|
||||
t.Fatalf("jpg and jpeg should share bucket: %q vs %q", typeBucketKey(jpg), typeBucketKey(jpeg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeBucketKeyAmbiguousMIMEUsesExtension(t *testing.T) {
|
||||
a := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "a.bin", Type: "application/octet-stream"}}
|
||||
b := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "b.dat", Type: "application/octet-stream"}}
|
||||
if typeBucketKey(a) == typeBucketKey(b) {
|
||||
t.Fatal("different extensions should not share bucket for ambiguous MIME")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChecksumCacheRejectsExpiredDigest(t *testing.T) {
|
||||
key := checksumCacheKey{
|
||||
sourcePath: "/src",
|
||||
filePath: "/file-a",
|
||||
size: 4096,
|
||||
modTime: 42,
|
||||
kind: checksumKindHeader,
|
||||
}
|
||||
encoded := checksumCacheKeyString(key)
|
||||
checksumTypedCache.SetWithExp(encoded, "digest", time.Millisecond)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if _, ok := getChecksumFromCache(key); ok {
|
||||
t.Fatal("expired digest should not be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumCacheMissAfterFileIdentityChange(t *testing.T) {
|
||||
modA := checksumCacheKey{
|
||||
sourcePath: "/src",
|
||||
filePath: "/file-a",
|
||||
size: 4096,
|
||||
modTime: 100,
|
||||
kind: checksumKindHeader,
|
||||
}
|
||||
modB := modA
|
||||
modB.modTime = 200
|
||||
|
||||
checksumTypedCache.Set(checksumCacheKeyString(modA), "digest-a")
|
||||
if _, ok := getChecksumFromCache(modB); ok {
|
||||
t.Fatal("modified file identity must not hit the prior cache key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDuplicateResponseSkipsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cacheKey := "cancel-test:" + time.Now().Format(time.RFC3339Nano)
|
||||
cacheDuplicateResponse(ctx, cacheKey, duplicateResponse{
|
||||
Groups: []duplicateGroup{{Size: 1, Count: 2}},
|
||||
})
|
||||
if _, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
t.Fatal("canceled context must not cache duplicate results")
|
||||
}
|
||||
|
||||
cacheDuplicateResponse(context.Background(), cacheKey, duplicateResponse{
|
||||
Groups: []duplicateGroup{{Size: 2, Count: 2}},
|
||||
})
|
||||
if _, ok := duplicateResultsCache.Get(cacheKey); !ok {
|
||||
t.Fatal("active context should cache duplicate results")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
checksumSampleSize = 8192
|
||||
checksumSmallFile = 16384
|
||||
checksumCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
type checksumSampleKind uint8
|
||||
|
||||
const (
|
||||
checksumKindHeader checksumSampleKind = iota
|
||||
checksumKindMiddle
|
||||
)
|
||||
|
||||
// checksumCacheKey identifies a cached sample digest for a file snapshot.
|
||||
type checksumCacheKey struct {
|
||||
sourcePath string
|
||||
filePath string
|
||||
size int64
|
||||
modTime int64
|
||||
kind checksumSampleKind
|
||||
}
|
||||
|
||||
var (
|
||||
// checksumTypedCache stores sample digests keyed by string-encoded checksumCacheKey.
|
||||
// The go-cache backend sweeps expired entries on an hourly ticker, so stale keys from
|
||||
// renamed/deleted/modified files are evicted without a lookup — unlike a bare sync.Map
|
||||
// which would grow without bound. Cleanup interval is the second arg (1h, matching TTL).
|
||||
checksumTypedCache = cache.NewCache[string](checksumCacheTTL, checksumCacheTTL)
|
||||
checksumBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, checksumSampleSize)
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type fileSampleDigests struct {
|
||||
header string
|
||||
middle string
|
||||
}
|
||||
|
||||
func duplicateChecksumHasher() hash.Hash {
|
||||
return xxhash.New()
|
||||
}
|
||||
|
||||
func digestHex(h hash.Hash) string {
|
||||
sum := h.Sum(nil)
|
||||
dst := make([]byte, hex.EncodedLen(len(sum)))
|
||||
hex.Encode(dst, sum)
|
||||
return string(dst)
|
||||
}
|
||||
|
||||
// checksumCacheKeyString encodes a typed cache key as a single string so it can
|
||||
// be stored in the TTL-sweeping go-cache. Length prefixes keep fields unambiguous.
|
||||
func checksumCacheKeyString(key checksumCacheKey) string {
|
||||
return fmt.Sprintf("%d|%d|%d|%d|%s|%s",
|
||||
key.kind, key.size, key.modTime, len(key.sourcePath),
|
||||
key.sourcePath, key.filePath)
|
||||
}
|
||||
|
||||
func getChecksumFromCache(key checksumCacheKey) (string, bool) {
|
||||
return checksumTypedCache.Get(checksumCacheKeyString(key))
|
||||
}
|
||||
|
||||
func setChecksumCache(key checksumCacheKey, digest string) {
|
||||
checksumTypedCache.Set(checksumCacheKeyString(key), digest)
|
||||
}
|
||||
|
||||
func readFileSampleDigests(ctx context.Context, sourcePath, filePath string, size int64, modTime time.Time, stats *duplicateProcessingStats) (fileSampleDigests, error) {
|
||||
headerKey := checksumCacheKey{sourcePath: sourcePath, filePath: filePath, size: size, modTime: modTime.UnixNano(), kind: checksumKindHeader}
|
||||
middleKey := checksumCacheKey{sourcePath: sourcePath, filePath: filePath, size: size, modTime: modTime.UnixNano(), kind: checksumKindMiddle}
|
||||
|
||||
var out fileSampleDigests
|
||||
if h, ok := getChecksumFromCache(headerKey); ok {
|
||||
out.header = h
|
||||
}
|
||||
if m, ok := getChecksumFromCache(middleKey); ok {
|
||||
out.middle = m
|
||||
}
|
||||
if out.header != "" && out.middle != "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return fileSampleDigests{}, ctx.Err()
|
||||
}
|
||||
// Only count operations that actually touch disk. Cache hits are O(1) lookups
|
||||
// and must not consume the checksum budget — otherwise a second search over
|
||||
// the same files stalls at the limit without reading a single new byte.
|
||||
// The budget check lives here (post-cache) so cached digests are always served.
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return fileSampleDigests{}, fmt.Errorf("duplicate search budget exhausted")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fileSampleDigests{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// A disk read is happening — charge it against the checksum budget.
|
||||
stats.checksumOperations++
|
||||
|
||||
raw := checksumBufPool.Get()
|
||||
bufPtr, ok := raw.(*[]byte)
|
||||
if !ok {
|
||||
return fileSampleDigests{}, fmt.Errorf("invalid checksum buffer pool type")
|
||||
}
|
||||
buf := *bufPtr
|
||||
defer checksumBufPool.Put(bufPtr)
|
||||
|
||||
if size <= checksumSmallFile {
|
||||
h := duplicateChecksumHasher()
|
||||
remain := size
|
||||
for remain > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return fileSampleDigests{}, ctx.Err()
|
||||
}
|
||||
toRead := int64(len(buf))
|
||||
if toRead > remain {
|
||||
toRead = remain
|
||||
}
|
||||
n, readErr := file.Read(buf[:toRead])
|
||||
if n > 0 {
|
||||
_, _ = h.Write(buf[:n])
|
||||
remain -= int64(n)
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
d := digestHex(h)
|
||||
out.header = d
|
||||
out.middle = d
|
||||
} else if out.header == "" || out.middle == "" {
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
|
||||
if out.header == "" {
|
||||
h := duplicateChecksumHasher()
|
||||
_, _ = h.Write(buf[:n])
|
||||
out.header = digestHex(h)
|
||||
}
|
||||
if out.middle == "" {
|
||||
middle := duplicateChecksumHasher()
|
||||
_, _ = middle.Write(buf[:n])
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, io.SeekStart); err != nil {
|
||||
return fileSampleDigests{}, err
|
||||
}
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
_, _ = middle.Write(buf[:n])
|
||||
out.middle = digestHex(middle)
|
||||
}
|
||||
}
|
||||
|
||||
if out.header != "" {
|
||||
setChecksumCache(headerKey, out.header)
|
||||
}
|
||||
if out.middle != "" {
|
||||
setChecksumCache(middleKey, out.middle)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func groupFilesByChecksum(ctx context.Context, files []*iteminfo.FileInfo, index *indexing.Index, fileSize int64, stats *duplicateProcessingStats) []checksumGroup {
|
||||
if len(files) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
headerGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
digestByPath := make(map[string]fileSampleDigests, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
// Budget checks live inside readFileSampleDigests (after the cache lookup)
|
||||
// so cached digests still count toward grouping without consuming budget.
|
||||
// Checking here would stall on cached files and look stuck.
|
||||
|
||||
if file.Size != fileSize {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
digests, err := readFileSampleDigests(ctx, index.Path, filePath, fileSize, file.ModTime, stats)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digestByPath[file.Path] = digests
|
||||
stats.uniqueChecksums[digests.header] = true
|
||||
headerGroups[digests.header] = append(headerGroups[digests.header], file)
|
||||
}
|
||||
|
||||
headerCandidates := make([][]*iteminfo.FileInfo, 0)
|
||||
for _, group := range headerGroups {
|
||||
if len(group) >= 2 {
|
||||
headerCandidates = append(headerCandidates, group)
|
||||
}
|
||||
}
|
||||
if len(headerCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
middleGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, headerGroup := range headerCandidates {
|
||||
for _, file := range headerGroup {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
break
|
||||
}
|
||||
digests := digestByPath[file.Path]
|
||||
if digests.middle == "" {
|
||||
continue
|
||||
}
|
||||
stats.uniqueChecksums[digests.middle] = true
|
||||
middleGroups[digests.middle] = append(middleGroups[digests.middle], file)
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([]checksumGroup, 0, len(middleGroups))
|
||||
for checksum, group := range middleGroups {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, checksumGroup{
|
||||
Files: group,
|
||||
Checksum: checksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
func TestReadFileSampleDigestsSmallFileUsesWholeFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := make([]byte, 12_000)
|
||||
path := filepath.Join(dir, "small.bin")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
d, err := readFileSampleDigests(context.Background(), dir, path, int64(len(data)), time.Now(), stats)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.header != d.middle {
|
||||
t.Fatalf("small file header and middle digest should match: %q %q", d.header, d.middle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasherUsesXXHash(t *testing.T) {
|
||||
if _, ok := any(duplicateChecksumHasher()).(*xxhash.Digest); !ok {
|
||||
t.Fatal("expected xxhash digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedDigestsDoNotConsumeChecksumBudget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataA := make([]byte, 65536)
|
||||
for i := range dataA {
|
||||
dataA[i] = byte(i % 251)
|
||||
}
|
||||
// Use a distinct payload so its digests differ from other tests' files.
|
||||
pathA := filepath.Join(dir, "budget-a.bin")
|
||||
pathB := filepath.Join(dir, "budget-b.bin")
|
||||
if err := os.WriteFile(pathA, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pathB, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modTime := time.Date(2026, 11, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Prime the cache with one op-worth of budget.
|
||||
prime := &duplicateProcessingStats{startTime: time.Now(), uniqueChecksums: map[string]bool{}}
|
||||
d1, err := readFileSampleDigests(context.Background(), dir, pathA, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d2, err := readFileSampleDigests(context.Background(), dir, pathB, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d1.header != d2.header || d1.middle != d2.middle {
|
||||
t.Fatal("expected equal digests for identical files")
|
||||
}
|
||||
if prime.checksumOperations != 2 {
|
||||
t.Fatalf("expected 2 disk operations for two cache-miss reads, got %d", prime.checksumOperations)
|
||||
}
|
||||
|
||||
// Exhaust the budget: any further uncached file should be refused.
|
||||
prime.checksumOperations = maxChecksumOperations
|
||||
uncached := filepath.Join(dir, "budget-c.bin")
|
||||
if err = os.WriteFile(uncached, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = readFileSampleDigests(context.Background(), dir, uncached, int64(len(dataA)), modTime, prime); err == nil {
|
||||
t.Fatal("expected budget-exhausted error for uncached file")
|
||||
}
|
||||
|
||||
// But the cached files must still be served, for free.
|
||||
cached, err := readFileSampleDigests(context.Background(), dir, pathA, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatalf("cached digest should be served despite exhausted budget: %v", err)
|
||||
}
|
||||
if cached.header != d1.header || cached.middle != d1.middle {
|
||||
t.Fatal("cached digest mismatch")
|
||||
}
|
||||
if prime.checksumOperations != maxChecksumOperations {
|
||||
t.Fatalf("cached digest must not consume budget, got %d", prime.checksumOperations)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateHashers(b *testing.B) {
|
||||
data := make([]byte, checksumSampleSize)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
b.Run("md5", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
h := md5.New()
|
||||
_, _ = h.Write(data)
|
||||
hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
})
|
||||
b.Run("xxhash", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
h := xxhash.New()
|
||||
_, _ = h.Write(data)
|
||||
hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
type mergeGroupKey struct {
|
||||
size int64
|
||||
digest string
|
||||
}
|
||||
|
||||
// mergeGroupsByChecksum merges groups that share the same size and sample digest.
|
||||
func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGroupWithChecksums {
|
||||
if len(groups) <= 1 {
|
||||
return groups
|
||||
}
|
||||
|
||||
keyToGroups := make(map[mergeGroupKey][]int)
|
||||
for i, group := range groups {
|
||||
if group.Checksum == "" {
|
||||
continue
|
||||
}
|
||||
key := mergeGroupKey{size: group.Size, digest: group.Checksum}
|
||||
keyToGroups[key] = append(keyToGroups[key], i)
|
||||
}
|
||||
|
||||
parent := make([]int, len(groups))
|
||||
for i := range parent {
|
||||
parent[i] = i
|
||||
}
|
||||
|
||||
var find func(int) int
|
||||
find = func(x int) int {
|
||||
if parent[x] != x {
|
||||
parent[x] = find(parent[x])
|
||||
}
|
||||
return parent[x]
|
||||
}
|
||||
|
||||
union := func(x, y int) {
|
||||
px, py := find(x), find(y)
|
||||
if px != py {
|
||||
parent[px] = py
|
||||
}
|
||||
}
|
||||
|
||||
for _, indices := range keyToGroups {
|
||||
for i := 1; i < len(indices); i++ {
|
||||
union(indices[0], indices[i])
|
||||
}
|
||||
}
|
||||
|
||||
groupsByRoot := make(map[int][]int)
|
||||
for i := range groups {
|
||||
root := find(i)
|
||||
groupsByRoot[root] = append(groupsByRoot[root], i)
|
||||
}
|
||||
|
||||
merged := make([]duplicateGroupWithChecksums, 0, len(groupsByRoot))
|
||||
for _, indices := range groupsByRoot {
|
||||
if len(indices) == 1 {
|
||||
merged = append(merged, groups[indices[0]])
|
||||
continue
|
||||
}
|
||||
|
||||
combinedFiles := make([]*indexing.SearchResult, 0)
|
||||
var combinedSize int64
|
||||
var combinedChecksum string
|
||||
|
||||
for _, idx := range indices {
|
||||
group := groups[idx]
|
||||
combinedFiles = append(combinedFiles, group.Files...)
|
||||
if combinedChecksum == "" {
|
||||
combinedChecksum = group.Checksum
|
||||
}
|
||||
if combinedSize == 0 {
|
||||
combinedSize = group.Size
|
||||
}
|
||||
}
|
||||
|
||||
if len(combinedFiles) >= 2 {
|
||||
slices.SortFunc(combinedFiles, func(a, b *indexing.SearchResult) int {
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
merged = append(merged, duplicateGroupWithChecksums{
|
||||
Size: combinedSize,
|
||||
Files: combinedFiles,
|
||||
Checksum: combinedChecksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(merged, func(a, b duplicateGroupWithChecksums) int {
|
||||
if c := cmp.Compare(b.Size, a.Size); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(a.Checksum, b.Checksum); c != 0 {
|
||||
return c
|
||||
}
|
||||
if len(a.Files) == 0 || len(b.Files) == 0 {
|
||||
return 0
|
||||
}
|
||||
return strings.Compare(a.Files[0].Path, b.Files[0].Path)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
func TestMergeGroupsByChecksumUsesSizeAndDigest(t *testing.T) {
|
||||
groups := []duplicateGroupWithChecksums{
|
||||
{Size: 100, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/a"}, {Path: "/b"}}},
|
||||
{Size: 200, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/c"}, {Path: "/d"}}},
|
||||
{Size: 100, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/e"}, {Path: "/f"}}},
|
||||
}
|
||||
merged := mergeGroupsByChecksum(groups)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected 2 merged groups, got %d", len(merged))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
const filenameSimilarityThreshold = 0.5
|
||||
|
||||
type levenshteinRowBuf struct {
|
||||
prev, cur []int
|
||||
}
|
||||
|
||||
func (b *levenshteinRowBuf) ensure(width int) {
|
||||
if cap(b.prev) < width+1 {
|
||||
b.prev = make([]int, width+1)
|
||||
b.cur = make([]int, width+1)
|
||||
} else {
|
||||
b.prev = b.prev[:width+1]
|
||||
b.cur = b.cur[:width+1]
|
||||
}
|
||||
}
|
||||
|
||||
func maxEditDistanceForSimilarity(maxLen int) int {
|
||||
return maxLen / 2
|
||||
}
|
||||
|
||||
func filenameSimilarWithBudget(name1, name2 string, buf *levenshteinRowBuf) bool {
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
maxLen := max(len(name1), len(name2))
|
||||
maxDist := maxEditDistanceForSimilarity(maxLen)
|
||||
if abs(len(name1)-len(name2)) > maxDist {
|
||||
return false
|
||||
}
|
||||
|
||||
distance := boundedLevenshtein(name1, name2, maxDist, buf)
|
||||
if distance < 0 {
|
||||
return false
|
||||
}
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= filenameSimilarityThreshold
|
||||
}
|
||||
|
||||
func boundedLevenshtein(s1, s2 string, maxDist int, buf *levenshteinRowBuf) int {
|
||||
n, m := len(s1), len(s2)
|
||||
if n == 0 {
|
||||
if m <= maxDist {
|
||||
return m
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if m == 0 {
|
||||
if n <= maxDist {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
buf.ensure(m)
|
||||
prev, cur := buf.prev, buf.cur
|
||||
|
||||
for j := 0; j <= m; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= n; i++ {
|
||||
cur[0] = i
|
||||
rowMin := cur[0]
|
||||
for j := 1; j <= m; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := cur[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
v := del
|
||||
if ins < v {
|
||||
v = ins
|
||||
}
|
||||
if sub < v {
|
||||
v = sub
|
||||
}
|
||||
cur[j] = v
|
||||
if v < rowMin {
|
||||
rowMin = v
|
||||
}
|
||||
}
|
||||
if rowMin > maxDist {
|
||||
return -1
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
|
||||
if prev[m] > maxDist {
|
||||
return -1
|
||||
}
|
||||
return prev[m]
|
||||
}
|
||||
|
||||
func groupFilesByFilename(ctx context.Context, files []*iteminfo.FileInfo, expectedSize int64, stats *duplicateProcessingStats) [][]*iteminfo.FileInfo {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
if f.Name != "" {
|
||||
normalized[i] = normalizeFilename(f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var rowBuf levenshteinRowBuf
|
||||
groups := [][]*iteminfo.FileInfo{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(files); i++ {
|
||||
if ctx.Err() != nil {
|
||||
return groups
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return groups
|
||||
}
|
||||
|
||||
if used[i] || files[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []*iteminfo.FileInfo{files[i]}
|
||||
used[i] = true
|
||||
filename1 := normalized[i]
|
||||
oversized := false
|
||||
|
||||
for j := i + 1; j < len(files); j++ {
|
||||
if ctx.Err() != nil {
|
||||
return groups
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return groups
|
||||
}
|
||||
|
||||
if used[j] || files[j].Name == "" {
|
||||
continue
|
||||
}
|
||||
if files[j].Size != expectedSize {
|
||||
continue
|
||||
}
|
||||
|
||||
if filenameSimilarWithBudget(filename1, normalized[j], &rowBuf) {
|
||||
group = append(group, files[j])
|
||||
used[j] = true
|
||||
if len(group) > maxFuzzyGroupSize {
|
||||
oversized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 && !oversized {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func normalizeFilename(filename string) string {
|
||||
filename = strings.ToLower(filename)
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func legacyFilenamesSimilar(name1, name2 string) bool {
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
distance := legacyLevenshtein(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.5
|
||||
}
|
||||
|
||||
func legacyLevenshtein(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
del := matrix[i-1][j] + 1
|
||||
ins := matrix[i][j-1] + 1
|
||||
sub := matrix[i-1][j-1] + cost
|
||||
v := del
|
||||
if ins < v {
|
||||
v = ins
|
||||
}
|
||||
if sub < v {
|
||||
v = sub
|
||||
}
|
||||
matrix[i][j] = v
|
||||
}
|
||||
}
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
var filenameCorpus = []string{
|
||||
"photo", "photo_1", "photo-copy", "IMG_0042", "img 0042",
|
||||
"vacation2023beach", "vacation2023_beach", "user_video", "video",
|
||||
"document-final", "document_final_v2", "a", "ab", "abc", "abcd",
|
||||
"report Q1 2024", "report-q1-2024", "my@file#name", "myfilename",
|
||||
"summer-holiday", "summer holiday", "winter-holiday",
|
||||
}
|
||||
|
||||
func TestFilenameSimilarMatchesLegacyOnCorpus(t *testing.T) {
|
||||
names := append([]string{}, filenameCorpus...)
|
||||
for i := 0; i < 50; i++ {
|
||||
names = append(names, randomNormalizedName(5+corpusRand.Intn(40)))
|
||||
}
|
||||
|
||||
var buf levenshteinRowBuf
|
||||
for _, a := range names {
|
||||
a = normalizeFilename(a + ".jpg")
|
||||
for _, b := range names {
|
||||
b = normalizeFilename(b + ".png")
|
||||
if legacyFilenamesSimilar(a, b) != filenameSimilarWithBudget(a, b, &buf) {
|
||||
t.Fatalf("mismatch %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameSimilarMatchesLegacyQuick(t *testing.T) {
|
||||
var buf levenshteinRowBuf
|
||||
if err := quick.Check(func(a, b string) bool {
|
||||
a = trimQuickString(a, 48)
|
||||
b = trimQuickString(b, 48)
|
||||
return legacyFilenamesSimilar(a, b) == filenameSimilarWithBudget(a, b, &buf)
|
||||
}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func trimQuickString(s string, max int) string {
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(b.String()))
|
||||
}
|
||||
|
||||
func randomNormalizedName(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789 -"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = alphabet[corpusRand.Intn(len(alphabet))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestGroupFilesByFilenameOversizedExcluded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
files := make([]*iteminfo.FileInfo, 12)
|
||||
for i := range files {
|
||||
files[i] = &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "photo.jpg", Size: 100},
|
||||
}
|
||||
}
|
||||
if len(groupFilesByFilename(ctx, files, 100, stats)) != 0 {
|
||||
t.Fatal("expected no groups for oversized cluster")
|
||||
}
|
||||
}
|
||||
|
||||
var corpusRand = rand.New(rand.NewSource(42))
|
||||
@@ -166,7 +166,11 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
}
|
||||
providedPath = cleanPath
|
||||
|
||||
themeMode := utils.Ternary(d.User.DarkMode, "dark", "light")
|
||||
themeMode := "light"
|
||||
if d.User.DarkMode == users.DarkModeDark ||
|
||||
(d.User.DarkMode == users.DarkModeAuto && settings.DefaultDarkMode()) {
|
||||
themeMode = "dark"
|
||||
}
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if hash != "" {
|
||||
|
||||
@@ -251,6 +251,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
|
||||
"userSelectedTheme": template.CSS(userSelectedTheme),
|
||||
"defaultDarkMode": settings.DefaultDarkMode(),
|
||||
"autoDarkMode": settings.DefaultDarkModeIsAuto(),
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
|
||||
@@ -567,6 +567,7 @@ func TextPreviewKind(ext string) string {
|
||||
// Do not add mimetypes shared with binaries (e.g. application/x-msdownload for .exe).
|
||||
var textPreviewMimeTypes = map[string]bool{
|
||||
"application/json": true,
|
||||
"application/rtf": true,
|
||||
"application/yaml": true,
|
||||
"application/x-yaml": true,
|
||||
"application/sql": true,
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestIsTextPreviewable(t *testing.T) {
|
||||
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"} {
|
||||
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex", "application/rtf", "text/rtf"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
// Mimetypes shared with binaries (.exe, .bat via application/x-msdownload) must stay non-previewable.
|
||||
|
||||
@@ -14,7 +14,6 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
dark := false
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
@@ -64,7 +63,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
DisableOptions: true,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: &dark,
|
||||
DarkMode: users.DarkModeLight,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "Night",
|
||||
Locale: "de",
|
||||
@@ -113,7 +112,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: false,
|
||||
DarkMode: users.DarkModeLight,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
|
||||
@@ -769,7 +769,9 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
}
|
||||
|
||||
// Second pass: Decode with strict validation (disallow unknown fields within valid sections)
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField())
|
||||
// UseJSONUnmarshaler lets typed fields with custom UnmarshalJSON (e.g. users.DarkMode)
|
||||
// handle YAML scalars like booleans instead of the generic decoder casting them to strings.
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField(), yaml.UseJSONUnmarshaler())
|
||||
err = decoder.Decode(&Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshaling YAML data: %v", err)
|
||||
@@ -1036,7 +1038,7 @@ func SetDefaults(generate bool) Settings {
|
||||
DisableOptions: false,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: boolPtr(true),
|
||||
DarkMode: users.DarkModeDark,
|
||||
ThemeColor: "var(--blue)",
|
||||
CustomTheme: "",
|
||||
Locale: "en",
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestYAMLConfig_jwt_organizedUserDefaults(t *testing.T) {
|
||||
if !ud.Account.Permissions.Share {
|
||||
t.Fatal("expected account.permissions.share true")
|
||||
}
|
||||
if ud.UI.DarkMode == nil || !*ud.UI.DarkMode {
|
||||
t.Fatal("expected ui.darkMode true")
|
||||
if ud.UI.DarkMode != users.DarkModeDark {
|
||||
t.Fatalf("expected ui.darkMode true, got %q", ud.UI.DarkMode)
|
||||
}
|
||||
if ud.Listing.SingleClick {
|
||||
t.Fatal("expected listing.singleClick false")
|
||||
@@ -106,8 +106,8 @@ func TestYAMLConfig_applyUserDefaultsFrom_proxyAndJwt(t *testing.T) {
|
||||
if !u.Permissions.Share {
|
||||
t.Fatal("Share=false after ApplyUserDefaultsFrom")
|
||||
}
|
||||
if !u.DarkMode {
|
||||
t.Fatal("expected darkMode from ui defaults")
|
||||
if u.DarkMode != users.DarkModeDark {
|
||||
t.Fatalf("expected darkMode from ui defaults, got %q", u.DarkMode)
|
||||
}
|
||||
if !u.Preview.Image || !u.Preview.PopUp {
|
||||
t.Fatalf("preview: %+v", u.Preview)
|
||||
@@ -126,7 +126,7 @@ func TestYAMLConfig_profileMerge_matchesApplyUserDefaults(t *testing.T) {
|
||||
fromDefaults := ProfileFromUserDefaults(Config.UserDefaults)
|
||||
u := &users.User{FrontendUser: users.FrontendUser{Username: "merge-check"}}
|
||||
ExpandProfileIntoUser(u, fromDefaults)
|
||||
if !u.DarkMode || !u.Preview.Image {
|
||||
if !u.DarkMode.IsDark() || !u.Preview.Image {
|
||||
t.Fatalf("ExpandProfileIntoUser: darkMode=%v preview.image=%v", u.DarkMode, u.Preview.Image)
|
||||
}
|
||||
u2 := &users.User{FrontendUser: users.FrontendUser{Username: "merge-check"}}
|
||||
@@ -166,7 +166,7 @@ func TestLoadConfig_partialUserDefaults_preservesPreviewAndAutoplayDefaults(t *t
|
||||
func TestLoadConfig_partialUserDefaults_preservesOtherSetDefaults(t *testing.T) {
|
||||
loadPartialUserDefaultsConfig(t, partialUserDefaultsYAML(""))
|
||||
ud := Config.UserDefaults
|
||||
if ud.UI.DarkMode == nil || !*ud.UI.DarkMode {
|
||||
if ud.UI.DarkMode != users.DarkModeDark {
|
||||
t.Fatalf("ui.darkMode: %v", ud.UI.DarkMode)
|
||||
}
|
||||
if ud.Sidebar.ShowTools == nil || !*ud.Sidebar.ShowTools {
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
ShowHidden: false,
|
||||
DarkMode: false,
|
||||
DarkMode: users.DarkModeLight,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
|
||||
if !u.ShowHidden {
|
||||
t.Fatal("expected enforced ShowHidden true")
|
||||
}
|
||||
if u.DarkMode {
|
||||
if u.DarkMode == users.DarkModeDark {
|
||||
t.Fatal("expected non-enforced DarkMode unchanged")
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func TestProfileRoundTrip_expandCollapse(t *testing.T) {
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
ShowHidden: true,
|
||||
DarkMode: true,
|
||||
DarkMode: users.DarkModeDark,
|
||||
ShowToolsInSidebar: true,
|
||||
Preview: users.Preview{
|
||||
Image: true,
|
||||
@@ -191,7 +191,7 @@ func TestProfileRoundTrip_expandCollapse(t *testing.T) {
|
||||
var u2 users.User
|
||||
u2.Username = "alice"
|
||||
ExpandProfileIntoUser(&u2, p)
|
||||
if !u2.ShowHidden || !u2.DarkMode || !u2.DisableSettings {
|
||||
if !u2.ShowHidden || !u2.DarkMode.IsDark() || !u2.DisableSettings {
|
||||
t.Fatal("round trip lost fields")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// Test structs for comprehensive testing
|
||||
@@ -215,11 +217,10 @@ type UserDefaults struct {
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: &trueVal,
|
||||
DarkMode: users.DarkModeDark,
|
||||
Locale: "es",
|
||||
},
|
||||
},
|
||||
@@ -294,12 +295,11 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
Locale: "es",
|
||||
DarkMode: &trueVal,
|
||||
DarkMode: users.DarkModeDark,
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
DisableOnlyOfficeExt: ".docx .xlsx",
|
||||
@@ -464,7 +464,7 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: boolPtr(true), // default value
|
||||
DarkMode: users.DarkModeDark, // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestMultiConfigLoad(t *testing.T) {
|
||||
@@ -228,7 +230,7 @@ frontend:
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.UI.DarkMode == nil || !*Config.UserDefaults.UI.DarkMode {
|
||||
if Config.UserDefaults.UI.DarkMode != users.DarkModeDark {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.UI.DarkMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -488,7 +488,7 @@ type UserDefaultsSearch struct {
|
||||
|
||||
// UserDefaultsUI holds UI/appearance settings
|
||||
type UserDefaultsUI struct {
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
DarkMode users.DarkMode `json:"darkMode"` // theme preference: "auto" (follow system), "dark", or "light"
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
||||
@@ -120,8 +122,16 @@ func readCustomCSS(path string) (string, error) {
|
||||
}
|
||||
|
||||
// DefaultDarkMode reports whether the instance default theme is dark.
|
||||
// "auto" (follow system) resolves to light on the server; clients handle
|
||||
// "auto" by checking their own prefers-color-scheme media query.
|
||||
func DefaultDarkMode() bool {
|
||||
return Config.UserDefaults.UI.DarkMode != nil && *Config.UserDefaults.UI.DarkMode
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeDark
|
||||
}
|
||||
|
||||
// DefaultDarkModeIsAuto reports whether the instance default theme follows
|
||||
// the client system preference.
|
||||
func DefaultDarkModeIsAuto() bool {
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeAuto
|
||||
}
|
||||
|
||||
// DefaultBackgroundColor returns the background color of the instance's default theme.
|
||||
|
||||
@@ -59,7 +59,7 @@ func ProfileFromUser(u *users.User) UserProfile {
|
||||
DisableOptions: u.DisableSearchOptions,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: boolPtr(u.DarkMode),
|
||||
DarkMode: u.DarkMode,
|
||||
ThemeColor: u.ThemeColor,
|
||||
CustomTheme: u.CustomTheme,
|
||||
Locale: u.Locale,
|
||||
@@ -142,7 +142,7 @@ func ExpandProfileIntoUser(u *users.User, p UserProfile) {
|
||||
|
||||
u.DisableSearchOptions = p.Search.DisableOptions
|
||||
|
||||
u.DarkMode = boolValueOrDefault(p.UI.DarkMode, true)
|
||||
u.DarkMode = p.UI.DarkMode.Or(users.DarkModeDark)
|
||||
u.ThemeColor = p.UI.ThemeColor
|
||||
u.CustomTheme = p.UI.CustomTheme
|
||||
u.Locale = p.UI.Locale
|
||||
|
||||
@@ -7573,8 +7573,12 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"description": "language to use: eg. de, en, or fr",
|
||||
@@ -8090,6 +8094,19 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.DarkMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DarkModeAuto",
|
||||
"DarkModeDark",
|
||||
"DarkModeLight"
|
||||
]
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8134,8 +8151,12 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dateFormat": {
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
|
||||
@@ -7562,8 +7562,12 @@
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"description": "language to use: eg. de, en, or fr",
|
||||
@@ -8079,6 +8083,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.DarkMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DarkModeAuto",
|
||||
"DarkModeDark",
|
||||
"DarkModeLight"
|
||||
]
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8123,8 +8140,12 @@
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dateFormat": {
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
|
||||
@@ -1457,8 +1457,9 @@ definitions:
|
||||
description: Name of theme to use chosen from custom themes config
|
||||
type: string
|
||||
darkMode:
|
||||
description: should dark mode be enabled
|
||||
type: boolean
|
||||
allOf:
|
||||
- $ref: '#/definitions/users.DarkMode'
|
||||
description: 'theme preference: "auto" (follow system), "dark", or "light"'
|
||||
locale:
|
||||
description: 'language to use: eg. de, en, or fr'
|
||||
type: string
|
||||
@@ -1806,6 +1807,16 @@ definitions:
|
||||
description: default view mode for anonymous users
|
||||
type: string
|
||||
type: object
|
||||
users.DarkMode:
|
||||
enum:
|
||||
- auto
|
||||
- dark
|
||||
- light
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- DarkModeAuto
|
||||
- DarkModeDark
|
||||
- DarkModeLight
|
||||
users.FileLoading:
|
||||
properties:
|
||||
clearAll:
|
||||
@@ -1836,8 +1847,9 @@ definitions:
|
||||
description: Name of theme to use chosen from custom themes config.
|
||||
type: string
|
||||
darkMode:
|
||||
description: should dark mode be enabled
|
||||
type: boolean
|
||||
allOf:
|
||||
- $ref: '#/definitions/users.DarkMode'
|
||||
description: 'theme preference: "auto" (follow system), "dark", or "light"'
|
||||
dateFormat:
|
||||
description: when false, the date is relative, when true, the date is an exact
|
||||
timestamp
|
||||
|
||||
@@ -215,7 +215,7 @@ userDefaults: # optional signup/CLI defaults; per-us
|
||||
search:
|
||||
disableOptions: false # disable the search options in the search bar
|
||||
ui:
|
||||
darkMode: true # should dark mode be enabled
|
||||
darkMode: "dark" # theme preference: "auto" (follow system), "dark", or "light"
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config
|
||||
locale: "en" # language to use: eg. de, en, or fr
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{{- if .htmlVars.autoDarkMode }}
|
||||
<!-- Default theme follows the system preference; resolve before first paint to avoid a flash -->
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.classList.add("dark-mode");
|
||||
}
|
||||
</script>
|
||||
{{- end }}
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>{{ .htmlVars.title }}</title>
|
||||
|
||||
@@ -28,6 +28,9 @@ const EXTRA_ICONS = [
|
||||
'arrow_downward',
|
||||
'arrow_upward',
|
||||
'autorenew',
|
||||
'dark_mode',
|
||||
'light_mode',
|
||||
'routine',
|
||||
'build',
|
||||
'check',
|
||||
'cloud_off',
|
||||
|
||||
@@ -295,6 +295,8 @@ const PROFILE_SNAPSHOT_FIELDS = [
|
||||
"debugOffice",
|
||||
"viewMode",
|
||||
"gallerySize",
|
||||
"promptRightCloseButton",
|
||||
"newFileTemplate",
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -27,18 +27,29 @@
|
||||
:name="$t('profileSettings.disableHideSidebar')"
|
||||
:description="$t('profileSettings.disableHideSidebarDescription')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
:enforceable="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
v-model="darkMode"
|
||||
@change="() => emitSectionChange('ui', 'darkMode')"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
:enforcement-locked="isEnforcementLocked('ui', 'darkMode')"
|
||||
:name="$t('profileSettings.darkMode')"
|
||||
:description="helpText('ui', 'darkMode', $t('index.toggleDark'))"
|
||||
/>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t('profileSettings.darkMode') }}</h4>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'ui', 'darkMode')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ExpandDropdown
|
||||
v-model="darkMode"
|
||||
:options="darkModeChoices"
|
||||
:aria-label="$t('profileSettings.darkMode')"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
/>
|
||||
</div>
|
||||
<template #enforce>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t("settings.themeColor") }}</h4>
|
||||
<div
|
||||
@@ -626,18 +637,29 @@
|
||||
:start-collapsed="sectionStartsCollapsed('themeLanguage')"
|
||||
>
|
||||
<div class="settings-items">
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
:enforceable="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
v-model="darkMode"
|
||||
@change="() => emitSectionChange('ui', 'darkMode')"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
:enforcement-locked="isEnforcementLocked('ui', 'darkMode')"
|
||||
:name="$t('profileSettings.darkMode')"
|
||||
:description="helpText('ui', 'darkMode', $t('index.toggleDark'))"
|
||||
/>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t('profileSettings.darkMode') }}</h4>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'ui', 'darkMode')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ExpandDropdown
|
||||
v-model="darkMode"
|
||||
:options="darkModeChoices"
|
||||
:aria-label="$t('profileSettings.darkMode')"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
/>
|
||||
</div>
|
||||
<template #enforce>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t("settings.themeColor") }}</h4>
|
||||
<div
|
||||
@@ -745,6 +767,7 @@ import {
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import { getters, mutations } from "@/store";
|
||||
import { getObjectProperty, setObjectProperty } from "@/utils/object.js";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import ProfilePreferenceToggle from "@/components/settings/ProfilePreferenceToggle.vue";
|
||||
import ProfileEnforceSwitch from "@/components/settings/ProfileEnforceSwitch.vue";
|
||||
@@ -920,14 +943,21 @@ export default {
|
||||
},
|
||||
darkMode: {
|
||||
get() {
|
||||
const v = this.sections.ui?.darkMode;
|
||||
return v === undefined || v === null ? true : !!v;
|
||||
return normalizeDarkMode(this.sections.ui?.darkMode);
|
||||
},
|
||||
set(val) {
|
||||
const next = { ...this.sections, ui: { ...(this.sections.ui || {}), darkMode: val } };
|
||||
this.sections = next;
|
||||
this.emitSectionChange("ui", "darkMode");
|
||||
},
|
||||
},
|
||||
darkModeChoices() {
|
||||
return [
|
||||
{ value: "auto", label: this.$t("profileSettings.darkModeSystem") },
|
||||
{ value: "light", label: this.$t("profileSettings.darkModeLight") },
|
||||
{ value: "dark", label: this.$t("profileSettings.darkModeDark") },
|
||||
];
|
||||
},
|
||||
autoplayMedia: {
|
||||
get() {
|
||||
const v = this.sections.fileViewer?.autoplayMedia;
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<i class="material-symbols">ads_click</i>
|
||||
</div>
|
||||
<div aria-label="Toggle Theme" v-if="darkModeTogglePossible" class="clickable"
|
||||
:class="{ active: user?.darkMode }" @click="toggleDarkMode"
|
||||
@mouseenter="showTooltip($event, $t('index.toggleDark'))" @mouseleave="hideTooltip">
|
||||
<i class="material-symbols">dark_mode</i>
|
||||
:class="{ active: isDarkMode }" @click="cycleDarkMode"
|
||||
@mouseenter="showTooltip($event, darkModeTooltip)" @mouseleave="hideTooltip">
|
||||
<i class="material-symbols">{{ darkModeIcon }}</i>
|
||||
</div>
|
||||
<div class="clickable" :class="{ active: isStickySidebar }" @click="toggleSticky"
|
||||
@mouseenter="showTooltip($event, $t('index.toggleSticky'))" @mouseleave="hideTooltip" v-if="!isMobile">
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import SidebarLinks from "./Links.vue";
|
||||
import { url } from "@/utils";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import { expandLeave } from "@/utils/expandTransition";
|
||||
|
||||
export default {
|
||||
@@ -133,6 +134,20 @@ export default {
|
||||
route: () => state.route,
|
||||
realtimeActive: () => state.realtimeActive,
|
||||
darkModeTogglePossible: () => state.shareInfo?.enforceDarkLightMode !== "dark" && state.shareInfo?.enforceDarkLightMode !== "light",
|
||||
isDarkMode: () => getters.isDarkMode(),
|
||||
darkModeIcon() {
|
||||
const icons = { auto: "routine", light: "light_mode", dark: "dark_mode" };
|
||||
return icons[normalizeDarkMode(state.user?.darkMode)];
|
||||
},
|
||||
darkModeTooltip() {
|
||||
const labels = {
|
||||
auto: this.$t("profileSettings.darkModeSystem"),
|
||||
light: this.$t("profileSettings.darkModeLight"),
|
||||
dark: this.$t("profileSettings.darkModeDark"),
|
||||
};
|
||||
const mode = labels[normalizeDarkMode(state.user?.darkMode)];
|
||||
return `${this.$t("index.toggleDark")} — ${mode}`;
|
||||
},
|
||||
shouldShowLogin() {
|
||||
if (getters.isShare()) {
|
||||
// Don't show login until shareInfo is fully loaded
|
||||
@@ -173,8 +188,8 @@ export default {
|
||||
toggleClick() {
|
||||
void mutations.updateCurrentUser({ singleClick: !state.user.singleClick });
|
||||
},
|
||||
toggleDarkMode() {
|
||||
mutations.toggleDarkMode();
|
||||
cycleDarkMode() {
|
||||
mutations.cycleDarkMode();
|
||||
},
|
||||
toggleSticky() {
|
||||
// keep sidebar open if disabling sticky sidebar
|
||||
|
||||
@@ -796,6 +796,9 @@
|
||||
"singleClick": "انقر مرة واحدة لفتح الملفات",
|
||||
"stickySidebar": "الشريط الجانبي الثابت",
|
||||
"darkMode": "الوضع المظلم",
|
||||
"darkModeSystem": "حسب النظام",
|
||||
"darkModeLight": "فاتح",
|
||||
"darkModeDark": "داكن",
|
||||
"defaultViewMode": "وضع العرض الافتراضي",
|
||||
"defaultViewModeDescription": "وضع العرض الافتراضي في المتصفحات أو الأجهزة الجديدة. لا يلغي تفضيلات العرض التي تم حفظها مسبقًا محليًّا.",
|
||||
"defaultGallerySize": "الحجم الافتراضي للصورة المصغرة",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "تم العثور على مجموعات مكررة{suffix}",
|
||||
"totalWastedSpace": "إجمالي المساحة المهدرة{suffix}",
|
||||
"wasted": "ضائع{suffix}",
|
||||
"emptyState": "أدخل معايير البحث وانقر فوق البحث عن التكرارات لتحديد الملفات المكررة"
|
||||
"emptyState": "أدخل معايير البحث وانقر فوق البحث عن التكرارات لتحديد الملفات المكررة",
|
||||
"noResults": "اكتمل البحث — لم يتم العثور على أي ملفات مكررة."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
|
||||
@@ -612,6 +612,9 @@
|
||||
"singleClick": "Отваряне на файлове с едно кликване",
|
||||
"stickySidebar": "Залепена странична лента",
|
||||
"darkMode": "Тъмен режим",
|
||||
"darkModeSystem": "Според системата",
|
||||
"darkModeLight": "Светъл",
|
||||
"darkModeDark": "Тъмен",
|
||||
"defaultViewMode": "Режим на изглед по подразбиране",
|
||||
"defaultViewModeDescription": "Режим на първоначално показване в нови браузъри или устройства. Не отменя вече запазените локално настройки за показване.",
|
||||
"defaultGallerySize": "Стандартен размер на миниатюрите",
|
||||
@@ -1145,7 +1148,8 @@
|
||||
"groupsFound": "Намерени групи с дубликати{suffix}",
|
||||
"totalWastedSpace": "Общо загубено пространство{suffix}",
|
||||
"wasted": "Загубено{suffix}",
|
||||
"emptyState": "Въведете критерии за търсене и кликнете Намиране на дубликати, за да идентифицирате дублирани файлове"
|
||||
"emptyState": "Въведете критерии за търсене и кликнете Намиране на дубликати, за да идентифицирате дублирани файлове",
|
||||
"noResults": "Търсенето приключи — не бяха открити дублиращи се файлове."
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Сървърът беше изключен.",
|
||||
|
||||
@@ -426,6 +426,9 @@
|
||||
"singleClick": "Otevření souborů jedním kliknutím",
|
||||
"stickySidebar": "Lepivý postranní panel",
|
||||
"darkMode": "Tmavý režim",
|
||||
"darkModeSystem": "Podle systému",
|
||||
"darkModeLight": "Světlý",
|
||||
"darkModeDark": "Tmavý",
|
||||
"defaultViewMode": "Výchozí režim zobrazení",
|
||||
"defaultViewModeDescription": "Výchozí režim zobrazení v nových prohlížečích nebo na nových zařízeních. Nepřepíše nastavení zobrazení, která jsou již uložena lokálně.",
|
||||
"defaultGallerySize": "Výchozí velikost miniatury",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Nalezeny duplicitní skupiny{suffix}",
|
||||
"totalWastedSpace": "Celkový promarněný prostor{suffix}",
|
||||
"wasted": "Promarněné{suffix}",
|
||||
"emptyState": "Zadejte kritéria vyhledávání a kliknutím na tlačítko Najít duplikáty identifikujte duplicitní soubory."
|
||||
"emptyState": "Zadejte kritéria vyhledávání a kliknutím na tlačítko Najít duplikáty identifikujte duplicitní soubory.",
|
||||
"noResults": "Vyhledávání dokončeno – nebyly nalezeny žádné duplicitní soubory."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Oznámení",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Dateien mit einem Klick öffnen",
|
||||
"stickySidebar": "Feste Seitenleiste",
|
||||
"darkMode": "Dunkler Modus",
|
||||
"darkModeSystem": "Systemeinstellung folgen",
|
||||
"darkModeLight": "Hell",
|
||||
"darkModeDark": "Dunkel",
|
||||
"defaultViewMode": "Standard-Anzeigemodus",
|
||||
"defaultViewModeDescription": "Standardansichtsmodus in neuen Browsern oder auf neuen Geräten. Überschreibt keine bereits lokal gespeicherten Anzeigeeinstellungen.",
|
||||
"defaultGallerySize": "Standardgröße der Miniaturansicht",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Doppelte Gruppen gefunden{suffix}",
|
||||
"totalWastedSpace": "Insgesamt verschwendeter Platz{suffix}",
|
||||
"wasted": "Vergeudet{suffix}",
|
||||
"emptyState": "Geben Sie Suchkriterien ein und klicken Sie auf Duplikate suchen, um doppelte Dateien zu identifizieren"
|
||||
"emptyState": "Geben Sie Suchkriterien ein und klicken Sie auf Duplikate suchen, um doppelte Dateien zu identifizieren",
|
||||
"noResults": "Suche abgeschlossen – es wurden keine doppelten Dateien gefunden."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Ανοίξτε τα αρχεία με ένα μόνο κλικ",
|
||||
"stickySidebar": "Πλευρική στήλη που παραμένει σταθερή",
|
||||
"darkMode": "Σκοτεινή λειτουργία",
|
||||
"darkModeSystem": "Με βάση το σύστημα",
|
||||
"darkModeLight": "Φωτεινό",
|
||||
"darkModeDark": "Σκούρο",
|
||||
"defaultViewMode": "Προεπιλεγμένη λειτουργία προβολής",
|
||||
"defaultViewModeDescription": "Λειτουργία αρχικής προβολής σε νέους περιηγητές ή συσκευές. Δεν αντικαθιστά τις προτιμήσεις προβολής που έχουν ήδη αποθηκευτεί τοπικά.",
|
||||
"defaultGallerySize": "Προεπιλεγμένο μέγεθος μικρογραφίας",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Βρέθηκαν διπλές ομάδες{suffix}",
|
||||
"totalWastedSpace": "Συνολικός σπαταλημένος χώρος{suffix}",
|
||||
"wasted": "Σπατάλη{suffix}",
|
||||
"emptyState": "Εισάγετε τα κριτήρια αναζήτησης και κάντε κλικ στο Find Duplicates για να εντοπίσετε τα διπλότυπα αρχεία."
|
||||
"emptyState": "Εισάγετε τα κριτήρια αναζήτησης και κάντε κλικ στο Find Duplicates για να εντοπίσετε τα διπλότυπα αρχεία.",
|
||||
"noResults": "Η αναζήτηση ολοκληρώθηκε — δεν εντοπίστηκαν διπλά αρχεία."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Ειδοποιήσεις",
|
||||
|
||||
@@ -581,6 +581,9 @@
|
||||
"singleClick": "Single-click to open files",
|
||||
"stickySidebar": "Sticky sidebar",
|
||||
"darkMode": "Dark mode",
|
||||
"darkModeSystem": "Follow system",
|
||||
"darkModeLight": "Light",
|
||||
"darkModeDark": "Dark",
|
||||
"showHiddenFiles": "Show hidden files",
|
||||
"showHiddenFilesDescription": "Show hidden files in listing (e.g. .thumbnails, ProgramData), but they are still manually accessible if configured to be indexed.",
|
||||
"showQuickDownload": "Show download icon for quick access",
|
||||
@@ -1205,7 +1208,8 @@
|
||||
"groupsFound": "Duplicate Groups Found{suffix}",
|
||||
"totalWastedSpace": "Total Wasted Space{suffix}",
|
||||
"wasted": "Wasted{suffix}",
|
||||
"emptyState": "Enter search criteria and click Find Duplicates to identify duplicate files"
|
||||
"emptyState": "Enter search criteria and click Find Duplicates to identify duplicate files",
|
||||
"noResults": "Search complete — no duplicate files found."
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "The server was shutdown.",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Abre los archivos con un solo clic",
|
||||
"stickySidebar": "Barra lateral fija",
|
||||
"darkMode": "Modo oscuro",
|
||||
"darkModeSystem": "Seguir el sistema",
|
||||
"darkModeLight": "Claro",
|
||||
"darkModeDark": "Oscuro",
|
||||
"defaultViewMode": "Modo de visualización predeterminado",
|
||||
"defaultViewModeDescription": "Modo de visualización inicial en navegadores o dispositivos nuevos. No anula las preferencias de visualización ya guardadas localmente.",
|
||||
"defaultGallerySize": "Tamaño predeterminado de las miniaturas",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Grupos de duplicados{suffix}",
|
||||
"totalWastedSpace": "Espacio desperdiciado{suffix}",
|
||||
"wasted": "Desperdiciado{suffix}",
|
||||
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Buscar duplicados\" para encontrar que archivos pueden estar gastando espacio."
|
||||
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Buscar duplicados\" para encontrar que archivos pueden estar gastando espacio.",
|
||||
"noResults": "Búsqueda completada: no se han encontrado archivos duplicados."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
|
||||
@@ -581,6 +581,9 @@
|
||||
"singleClick": "Klik bakarra fitxategiak irekitzeko",
|
||||
"stickySidebar": "Alboko barra finkoa",
|
||||
"darkMode": "Modu iluna",
|
||||
"darkModeSystem": "Sistema jarraitu",
|
||||
"darkModeLight": "Argia",
|
||||
"darkModeDark": "Iluna",
|
||||
"showHiddenFiles": "Erakutsi ezkutuko fitxategiak",
|
||||
"showHiddenFilesDescription": "Erakutsi ezkutuko fitxategiak zerrendan (adib. .thumbnails, ProgramData); hala ere, eskuz eskuragarri daude indexatzeko konfiguratuta badaude.",
|
||||
"showQuickDownload": "Erakutsi deskarga-ikonoa sarbide azkarrerako",
|
||||
@@ -1165,7 +1168,8 @@
|
||||
"groupsFound": "Aurkitutako bikoiztu-taldeak{suffix}",
|
||||
"totalWastedSpace": "Alferrik galdutako lekua guztira{suffix}",
|
||||
"wasted": "Alferrik galdua{suffix}",
|
||||
"emptyState": "Idatzi bilaketa-irizpideak eta egin klik Bilatu bikoiztuak botoian fitxategi bikoiztuak identifikatzeko"
|
||||
"emptyState": "Idatzi bilaketa-irizpideak eta egin klik Bilatu bikoiztuak botoian fitxategi bikoiztuak identifikatzeko",
|
||||
"noResults": "Bilaketa amaituta — ez da fitxategi bikoizturik aurkitu."
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Zerbitzaria itzali da.",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Un simple clic pour ouvrir les fichiers",
|
||||
"stickySidebar": "Barre latérale fixe",
|
||||
"darkMode": "Mode sombre",
|
||||
"darkModeSystem": "Suivre le système",
|
||||
"darkModeLight": "Clair",
|
||||
"darkModeDark": "Sombre",
|
||||
"defaultViewMode": "Mode d'affichage par défaut",
|
||||
"defaultViewModeDescription": "Mode d'affichage par défaut sur les nouveaux navigateurs ou appareils. Ne remplace pas les préférences d'affichage déjà enregistrées localement.",
|
||||
"defaultGallerySize": "Taille par défaut des vignettes",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Groupes en double trouvés{suffix}",
|
||||
"totalWastedSpace": "Total de l'espace perdu{suffix}",
|
||||
"wasted": "Gaspillage{suffix}",
|
||||
"emptyState": "Saisissez des critères de recherche et cliquez sur Rechercher les doublons pour identifier les fichiers en double."
|
||||
"emptyState": "Saisissez des critères de recherche et cliquez sur Rechercher les doublons pour identifier les fichiers en double.",
|
||||
"noResults": "Recherche terminée — aucun fichier en double n'a été trouvé."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "לחיצה אחת לפתיחת קבצים",
|
||||
"stickySidebar": "סרגל צדדי קבוע",
|
||||
"darkMode": "מצב כהה",
|
||||
"darkModeSystem": "לפי המערכת",
|
||||
"darkModeLight": "בהיר",
|
||||
"darkModeDark": "כהה",
|
||||
"defaultViewMode": "מצב תצוגה ברירת מחדל",
|
||||
"defaultViewModeDescription": "מצב תצוגה ראשוני בדפדפנים או במכשירים חדשים. אינו מבטל את הגדרות התצוגה שכבר נשמרו באופן מקומי.",
|
||||
"defaultGallerySize": "גודל תמונה ממוזערת ברירת מחדל",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "נמצאו קבוצות כפולות{suffix}",
|
||||
"totalWastedSpace": "סך השטח המבוזבז{suffix}",
|
||||
"wasted": "מבוזבז{suffix}",
|
||||
"emptyState": "הזן את קריטריוני החיפוש ולחץ על 'מצא כפילויות' כדי לזהות קבצים כפולים."
|
||||
"emptyState": "הזן את קריטריוני החיפוש ולחץ על 'מצא כפילויות' כדי לזהות קבצים כפולים.",
|
||||
"noResults": "החיפוש הסתיים — לא נמצאו קבצים כפולים."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "הודעות",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "A fájlok megnyitásához elég egy kattintás",
|
||||
"stickySidebar": "Rögzített oldalsáv",
|
||||
"darkMode": "Sötét mód",
|
||||
"darkModeSystem": "Rendszer követése",
|
||||
"darkModeLight": "Világos",
|
||||
"darkModeDark": "Sötét",
|
||||
"defaultViewMode": "Alapértelmezett megjelenítési mód",
|
||||
"defaultViewModeDescription": "Alapértelmezett megjelenítési mód új böngészőkön vagy eszközökön. Nem írja felül a már helyileg elmentett megjelenítési beállításokat.",
|
||||
"defaultGallerySize": "Alapértelmezett miniatűrméret",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Duplikált csoportokat találtak{suffix}",
|
||||
"totalWastedSpace": "Teljes elpazarolt hely{suffix}",
|
||||
"wasted": "Elpazarolt{suffix}",
|
||||
"emptyState": "Adja meg a keresési feltételeket, és kattintson a Duplikátumok keresése gombra a duplikált fájlok azonosításához."
|
||||
"emptyState": "Adja meg a keresési feltételeket, és kattintson a Duplikátumok keresése gombra a duplikált fájlok azonosításához.",
|
||||
"noResults": "A keresés befejeződött — nem találtak duplikált fájlokat."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Értesítések",
|
||||
|
||||
@@ -612,6 +612,9 @@
|
||||
"singleClick": "Basta un clic per aprire i file",
|
||||
"stickySidebar": "Barra laterale fissa",
|
||||
"darkMode": "Modalità scura",
|
||||
"darkModeSystem": "Segui il sistema",
|
||||
"darkModeLight": "Chiaro",
|
||||
"darkModeDark": "Scuro",
|
||||
"defaultViewMode": "Modalità di visualizzazione predefinita",
|
||||
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
|
||||
"defaultGallerySize": "Dimensione predefinita delle miniature",
|
||||
@@ -1134,7 +1137,8 @@
|
||||
"groupsFound": "Gruppi Duplicati Trovati{suffix}",
|
||||
"totalWastedSpace": "Spazio Sprecato Totale{suffix}",
|
||||
"wasted": "Sprecato{suffix}",
|
||||
"emptyState": "Inserisci i criteri e clicca Trova Duplicati per identificare i file"
|
||||
"emptyState": "Inserisci i criteri e clicca Trova Duplicati per identificare i file",
|
||||
"noResults": "Ricerca completata — non sono stati trovati file duplicati."
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Il server è stato spento.",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "ワンクリックでファイルを開く",
|
||||
"stickySidebar": "固定サイドバー",
|
||||
"darkMode": "ダークモード",
|
||||
"darkModeSystem": "システムに従う",
|
||||
"darkModeLight": "ライト",
|
||||
"darkModeDark": "ダーク",
|
||||
"defaultViewMode": "デフォルトの表示モード",
|
||||
"defaultViewModeDescription": "新しいブラウザやデバイスでの初期表示モードです。ローカルにすでに保存されている表示設定を上書きすることはありません。",
|
||||
"defaultGallerySize": "デフォルトのサムネイルサイズ",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "重複グループが見つかりました{suffix}",
|
||||
"totalWastedSpace": "無駄なスペース{suffix}",
|
||||
"wasted": "無駄{suffix}",
|
||||
"emptyState": "検索条件を入力し、「重複ファイルの検索」をクリックすると、重複ファイルが特定されます。"
|
||||
"emptyState": "検索条件を入力し、「重複ファイルの検索」をクリックすると、重複ファイルが特定されます。",
|
||||
"noResults": "検索が完了しました。重複するファイルは見つかりませんでした。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "お知らせ",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "한 번의 클릭으로 파일 열기",
|
||||
"stickySidebar": "고정 사이드바",
|
||||
"darkMode": "다크 모드",
|
||||
"darkModeSystem": "시스템 설정 따르기",
|
||||
"darkModeLight": "라이트",
|
||||
"darkModeDark": "다크",
|
||||
"defaultViewMode": "기본 보기 모드",
|
||||
"defaultViewModeDescription": "새로운 브라우저나 기기에서 초기 보기 모드로 설정됩니다. 이미 로컬에 저장된 보기 환경설정은 무시되지 않습니다.",
|
||||
"defaultGallerySize": "기본 썸네일 크기",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "중복 그룹 발견{suffix}",
|
||||
"totalWastedSpace": "낭비되는 총 공간{suffix}",
|
||||
"wasted": "낭비{suffix}",
|
||||
"emptyState": "검색 기준을 입력하고 중복 파일 찾기를 클릭하여 중복 파일을 식별합니다."
|
||||
"emptyState": "검색 기준을 입력하고 중복 파일 찾기를 클릭하여 중복 파일을 식별합니다.",
|
||||
"noResults": "검색이 완료되었습니다. 중복 파일은 발견되지 않았습니다."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "알림",
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
"singleClick": "Bestanden openen met één muisklik",
|
||||
"stickySidebar": "Vaste zijbalk",
|
||||
"darkMode": "Donkere modus",
|
||||
"darkModeSystem": "Systeem volgen",
|
||||
"darkModeLight": "Licht",
|
||||
"darkModeDark": "Donker",
|
||||
"defaultViewMode": "Standaardweergavemodus",
|
||||
"defaultViewModeDescription": "Standaardweergavemodus in nieuwe browsers of op nieuwe apparaten. Hiermee worden de lokaal opgeslagen weergavevoorkeuren niet overschreven.",
|
||||
"defaultGallerySize": "Standaardgrootte van miniatuurafbeeldingen",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Dubbele groepen gevonden{suffix}",
|
||||
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
|
||||
"wasted": "Verspild{suffix}",
|
||||
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren"
|
||||
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren",
|
||||
"noResults": "Zoekopdracht voltooid — er zijn geen dubbele bestanden gevonden."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Meldingen",
|
||||
|
||||
@@ -585,6 +585,9 @@
|
||||
"singleClick": "Bestanden openen met één muisklik",
|
||||
"stickySidebar": "Vaste zijbalk",
|
||||
"darkMode": "Donkere modus",
|
||||
"darkModeSystem": "Systeem volgen",
|
||||
"darkModeLight": "Licht",
|
||||
"darkModeDark": "Donker",
|
||||
"showHiddenFiles": "Verborgen bestanden weergeven",
|
||||
"showHiddenFilesDescription": "Toon verborgen bestanden in de lijst (bijv. .thumbnails, ProgramData), maar ze zijn nog steeds handmatig toegankelijk als ze zijn geconfigureerd om te worden geïndexeerd.",
|
||||
"showQuickDownload": "Downloadpictogram weergeven voor snelle toegang",
|
||||
@@ -1165,7 +1168,8 @@
|
||||
"groupsFound": "Dubbele groepen gevonden{suffix}",
|
||||
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
|
||||
"wasted": "Verspild{suffix}",
|
||||
"emptyState": "Voer zoekcriteria in en klik op Dubbele bestanden zoeken om deze te vinden"
|
||||
"emptyState": "Voer zoekcriteria in en klik op Dubbele bestanden zoeken om deze te vinden",
|
||||
"noResults": "Zoekopdracht voltooid — er zijn geen dubbele bestanden gevonden."
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "De server is uitgeschakeld.",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Wystarczy jedno kliknięcie, aby otworzyć pliki",
|
||||
"stickySidebar": "Przyklejony pasek boczny",
|
||||
"darkMode": "Tryb ciemny",
|
||||
"darkModeSystem": "Zgodnie z systemem",
|
||||
"darkModeLight": "Jasny",
|
||||
"darkModeDark": "Ciemny",
|
||||
"defaultViewMode": "Domyślny tryb wyświetlania",
|
||||
"defaultViewModeDescription": "Tryb wyświetlania domyślnego w nowych przeglądarkach lub na nowych urządzeniach. Nie zastępuje preferencji wyświetlania zapisanych już lokalnie.",
|
||||
"defaultGallerySize": "Domyślny rozmiar miniatury",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Znaleziono zduplikowane grupy{suffix}",
|
||||
"totalWastedSpace": "Całkowita zmarnowana przestrzeń{suffix}",
|
||||
"wasted": "Zmarnowany{suffix}",
|
||||
"emptyState": "Wprowadź kryteria wyszukiwania i kliknij Znajdź duplikaty, aby zidentyfikować zduplikowane pliki."
|
||||
"emptyState": "Wprowadź kryteria wyszukiwania i kliknij Znajdź duplikaty, aby zidentyfikować zduplikowane pliki.",
|
||||
"noResults": "Wyszukiwanie zakończone — nie znaleziono zduplikowanych plików."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Basta um clique para abrir os arquivos",
|
||||
"stickySidebar": "Barra lateral fixa",
|
||||
"darkMode": "Modo escuro",
|
||||
"darkModeSystem": "Seguir o sistema",
|
||||
"darkModeLight": "Claro",
|
||||
"darkModeDark": "Escuro",
|
||||
"defaultViewMode": "Modo de visualização padrão",
|
||||
"defaultViewModeDescription": "Modo de visualização inicial em novos navegadores ou dispositivos. Não substitui as preferências de visualização já salvas localmente.",
|
||||
"defaultGallerySize": "Tamanho padrão da miniatura",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Grupos duplicados encontrados{suffix}",
|
||||
"totalWastedSpace": "Total de espaço desperdiçado{suffix}",
|
||||
"wasted": "Desperdiçado{suffix}",
|
||||
"emptyState": "Insira os critérios de pesquisa e clique em Find Duplicates para identificar arquivos duplicados"
|
||||
"emptyState": "Insira os critérios de pesquisa e clique em Find Duplicates para identificar arquivos duplicados",
|
||||
"noResults": "Pesquisa concluída — não foram encontrados arquivos duplicados."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificações",
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
"singleClick": "Basta um clique para abrir ficheiros",
|
||||
"stickySidebar": "Barra lateral fixa",
|
||||
"darkMode": "Modo escuro",
|
||||
"darkModeSystem": "Seguir o sistema",
|
||||
"darkModeLight": "Claro",
|
||||
"darkModeDark": "Escuro",
|
||||
"defaultViewMode": "Modo de visualização predefinido",
|
||||
"defaultViewModeDescription": "Modo de visualização inicial em novos navegadores ou dispositivos. Não substitui as preferências de visualização já guardadas localmente.",
|
||||
"defaultGallerySize": "Tamanho padrão da miniatura",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Grupos duplicados encontrados{suffix}",
|
||||
"totalWastedSpace": "Total de espaço desperdiçado{suffix}",
|
||||
"wasted": "Desperdiçado{suffix}",
|
||||
"emptyState": "Introduza os critérios de pesquisa e clique em Encontrar Duplicados para identificar ficheiros duplicados"
|
||||
"emptyState": "Introduza os critérios de pesquisa e clique em Encontrar Duplicados para identificar ficheiros duplicados",
|
||||
"noResults": "Pesquisa concluída — não foram encontrados ficheiros duplicados."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificações",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Deschideți fișierele cu un singur clic",
|
||||
"stickySidebar": "Bară laterală fixă",
|
||||
"darkMode": "Modul întunecat",
|
||||
"darkModeSystem": "Urmează sistemul",
|
||||
"darkModeLight": "Luminoasă",
|
||||
"darkModeDark": "Întunecată",
|
||||
"defaultViewMode": "Modul de vizualizare implicit",
|
||||
"defaultViewModeDescription": "Modul de afișare inițial pe browserele sau dispozitivele noi. Nu înlocuiește preferințele de afișare salvate deja local.",
|
||||
"defaultGallerySize": "Dimensiunea implicită a miniaturilor",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Grupuri duplicate găsite{suffix}",
|
||||
"totalWastedSpace": "Spațiu total irosit{suffix}",
|
||||
"wasted": "irosit{suffix}",
|
||||
"emptyState": "Introduceți criteriile de căutare și faceți clic pe Find Duplicates pentru a identifica fișierele duplicate"
|
||||
"emptyState": "Introduceți criteriile de căutare și faceți clic pe Find Duplicates pentru a identifica fișierele duplicate",
|
||||
"noResults": "Căutarea s-a încheiat — nu s-au găsit fișiere duplicate."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificări",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Открытие файлов одним щелчком мыши",
|
||||
"stickySidebar": "Фиксированная боковая панель",
|
||||
"darkMode": "Темный режим",
|
||||
"darkModeSystem": "Как в системе",
|
||||
"darkModeLight": "Светлая",
|
||||
"darkModeDark": "Тёмная",
|
||||
"defaultViewMode": "Режим просмотра по умолчанию",
|
||||
"defaultViewModeDescription": "Режим начального отображения в новых браузерах или на новых устройствах. Не переопределяет настройки просмотра, уже сохраненные локально.",
|
||||
"defaultGallerySize": "Размер миниатюры по умолчанию",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Найдены дублирующие группы{suffix}",
|
||||
"totalWastedSpace": "Общая потерянная площадь{suffix}",
|
||||
"wasted": "Впустую{suffix}",
|
||||
"emptyState": "Введите критерии поиска и нажмите кнопку Найти дубликаты, чтобы выявить дубликаты файлов."
|
||||
"emptyState": "Введите критерии поиска и нажмите кнопку Найти дубликаты, чтобы выявить дубликаты файлов.",
|
||||
"noResults": "Поиск завершен — дубликаты файлов не найдены."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Уведомления",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Otvorenie súborov jedným kliknutím",
|
||||
"stickySidebar": "Lepivý bočný panel",
|
||||
"darkMode": "Tmavý režim",
|
||||
"darkModeSystem": "Podľa systému",
|
||||
"darkModeLight": "Svetlý",
|
||||
"darkModeDark": "Tmavý",
|
||||
"defaultViewMode": "Predvolený režim zobrazenia",
|
||||
"defaultViewModeDescription": "Režim počiatočného zobrazenia v nových prehliadačoch alebo na nových zariadeniach. Neprepisuje nastavenia zobrazenia, ktoré sú už uložené lokálne.",
|
||||
"defaultGallerySize": "Predvolená veľkosť náhľadu",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Nájdené duplicitné skupiny{suffix}",
|
||||
"totalWastedSpace": "Celkový premrhaný priestor{suffix}",
|
||||
"wasted": "Plytvanie{suffix}",
|
||||
"emptyState": "Zadajte kritériá vyhľadávania a kliknite na tlačidlo Nájsť duplikáty, aby ste identifikovali duplicitné súbory"
|
||||
"emptyState": "Zadajte kritériá vyhľadávania a kliknite na tlačidlo Nájsť duplikáty, aby ste identifikovali duplicitné súbory",
|
||||
"noResults": "Vyhľadávanie bolo dokončené — neboli nájdené žiadne duplicitné súbory."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Oznámenia",
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
"singleClick": "Öppna filer med ett enda klick",
|
||||
"stickySidebar": "Fast sidomeny",
|
||||
"darkMode": "Mörkt läge",
|
||||
"darkModeSystem": "Följ systemet",
|
||||
"darkModeLight": "Ljust",
|
||||
"darkModeDark": "Mörkt",
|
||||
"defaultViewMode": "Standardvisningsläge",
|
||||
"defaultViewModeDescription": "Standardvisningsläge i nya webbläsare eller på nya enheter. Åsidosätter inte visningsinställningar som redan sparats lokalt.",
|
||||
"defaultGallerySize": "Standardstorlek på miniatyrbilder",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Duplicerade grupper hittades{suffix}",
|
||||
"totalWastedSpace": "Totalt bortkastat utrymme{suffix}",
|
||||
"wasted": "Bortkastad{suffix}",
|
||||
"emptyState": "Ange sökkriterier och klicka på Find Duplicates för att identifiera dubbletter av filer"
|
||||
"emptyState": "Ange sökkriterier och klicka på Find Duplicates för att identifiera dubbletter av filer",
|
||||
"noResults": "Sökningen är klar – inga dubbletter hittades."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Meddelanden",
|
||||
|
||||
@@ -804,6 +804,9 @@
|
||||
"singleClick": "Dosyaları tek tıklamayla aç",
|
||||
"stickySidebar": "Sabit kenar çubuğu",
|
||||
"darkMode": "Koyu mod",
|
||||
"darkModeSystem": "Sistemi takip et",
|
||||
"darkModeLight": "Açık",
|
||||
"darkModeDark": "Koyu",
|
||||
"defaultViewMode": "Varsayılan görüntüleme modu",
|
||||
"defaultViewModeDescription": "Yeni tarayıcılarda veya cihazlarda ilk görüntüleme modu. Yerel olarak önceden kaydedilmiş görüntüleme tercihlerini geçersiz kılmaz.",
|
||||
"defaultGallerySize": "Varsayılan küçük resim boyutu",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Yinelenen Gruplar Bulundu{suffix}",
|
||||
"totalWastedSpace": "Toplam Boşa Harcanan Alan{suffix}",
|
||||
"wasted": "Boşa Harcanan{suffix}",
|
||||
"emptyState": "Yinelenen dosyaları belirlemek için arama kriterlerini girin ve Yinelenenleri Bul'a tıklayın"
|
||||
"emptyState": "Yinelenen dosyaları belirlemek için arama kriterlerini girin ve Yinelenenleri Bul'a tıklayın",
|
||||
"noResults": "Arama tamamlandı — yinelenen dosya bulunamadı."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Bildirimler",
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
"singleClick": "Відкриття файлів одним клацанням миші",
|
||||
"stickySidebar": "Фіксована бічна панель",
|
||||
"darkMode": "Темний режим",
|
||||
"darkModeSystem": "Як у системі",
|
||||
"darkModeLight": "Світла",
|
||||
"darkModeDark": "Темна",
|
||||
"defaultViewMode": "Режим перегляду за замовчуванням",
|
||||
"defaultViewModeDescription": "Режим початкового відображення в нових браузерах або на нових пристроях. Не замінює налаштування відображення, які вже збережено локально.",
|
||||
"defaultGallerySize": "Розмір мініатюри за замовчуванням",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "Знайдено дублікати груп{suffix}",
|
||||
"totalWastedSpace": "Загальна кількість марно витраченого простору{suffix}",
|
||||
"wasted": "Даремно.{suffix}",
|
||||
"emptyState": "Введіть критерії пошуку та натисніть \"Знайти дублікати\", щоб знайти дублікати файлів"
|
||||
"emptyState": "Введіть критерії пошуку та натисніть \"Знайти дублікати\", щоб знайти дублікати файлів",
|
||||
"noResults": "Пошук завершено — дублікатів файлів не виявлено."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Сповіщення",
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
"singleClick": "单击即可打开文件",
|
||||
"stickySidebar": "固定侧边栏",
|
||||
"darkMode": "深色模式",
|
||||
"darkModeSystem": "跟随系统",
|
||||
"darkModeLight": "浅色",
|
||||
"darkModeDark": "深色",
|
||||
"defaultViewMode": "默认视图模式",
|
||||
"defaultViewModeDescription": "在新浏览器或设备上的初始视图模式。不会覆盖已保存在本地的视图偏好设置。",
|
||||
"defaultGallerySize": "默认缩略图大小",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "发现重复组{suffix}",
|
||||
"totalWastedSpace": "浪费空间总计{suffix}",
|
||||
"wasted": "浪费{suffix}",
|
||||
"emptyState": "输入搜索条件,然后单击查找重复文件,以识别重复文件"
|
||||
"emptyState": "输入搜索条件,然后单击查找重复文件,以识别重复文件",
|
||||
"noResults": "搜索完成——未找到重复文件。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
|
||||
@@ -560,6 +560,9 @@
|
||||
"singleClick": "單擊即可開啟檔案",
|
||||
"stickySidebar": "固定側邊欄",
|
||||
"darkMode": "深色模式",
|
||||
"darkModeSystem": "跟隨系統",
|
||||
"darkModeLight": "淺色",
|
||||
"darkModeDark": "深色",
|
||||
"defaultViewMode": "預設檢視模式",
|
||||
"defaultViewModeDescription": "在新瀏覽器或裝置上的初始檢視模式。不會覆寫已儲存於本機的檢視偏好設定。",
|
||||
"defaultGallerySize": "預設縮圖大小",
|
||||
@@ -1116,7 +1119,8 @@
|
||||
"groupsFound": "重複群組數{suffix}",
|
||||
"totalWastedSpace": "可回收空間總量{suffix}",
|
||||
"wasted": "浪費空間{suffix}",
|
||||
"emptyState": "輸入搜尋條件後,按一下 [尋找重複檔案] 即可找出重複檔案。"
|
||||
"emptyState": "輸入搜尋條件後,按一下 [尋找重複檔案] 即可找出重複檔案。",
|
||||
"noResults": "搜尋完成 — 未發現重複檔案。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { createApp } from "vue";
|
||||
import i18n from "@/i18n/index.ts";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { state, getters } from "@/store/index.ts";
|
||||
// biome-ignore lint/correctness/noUnresolvedImports: disabled temporarily
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router/index.ts";
|
||||
|
||||
import "./css/styles.css";
|
||||
import { initPwaInstall } from "@/utils/pwaInstall.js";
|
||||
import { defaultDarkMode, syncDocumentTheme } from "@/utils/theme.js";
|
||||
import { defaultDarkMode, syncDocumentTheme, watchSystemTheme } from "@/utils/theme.js";
|
||||
import { initViewportLayoutListener } from "@/utils/viewport.js";
|
||||
|
||||
initPwaInstall();
|
||||
initViewportLayoutListener();
|
||||
syncDocumentTheme(defaultDarkMode());
|
||||
// Live-update the theme when the OS switches appearance while in "auto" mode.
|
||||
watchSystemTheme((systemDark: boolean) => {
|
||||
// The store is reactive, so updating this snapshot re-renders everything bound
|
||||
// to isDarkMode (sidebar toggle icon, active highlight) as well as the document.
|
||||
state.systemDark = systemDark;
|
||||
syncDocumentTheme(getters.isDarkMode());
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { stateMock, globalVarsMock } = vi.hoisted(() => ({
|
||||
user: { username: '', darkMode: true },
|
||||
shareInfo: null,
|
||||
disableEventThemes: false,
|
||||
systemDark: false,
|
||||
route: { path: '/login' },
|
||||
},
|
||||
globalVarsMock: { darkMode: false, noAuth: false, eventBasedThemes: false },
|
||||
@@ -184,3 +185,85 @@ describe('getters.isDarkMode logged-out behavior', () => {
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters.isDarkMode auto (follow system) behavior', () => {
|
||||
beforeEach(() => {
|
||||
stateMock.shareInfo = null;
|
||||
stateMock.disableEventThemes = false;
|
||||
stateMock.route = { path: '/files/' };
|
||||
globalVarsMock.darkMode = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('resolves "auto" to the system snapshot when logged in', () => {
|
||||
stateMock.user = { username: 'alice', darkMode: 'auto', locale: 'en' };
|
||||
stateMock.systemDark = true;
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
|
||||
stateMock.systemDark = false;
|
||||
expect(getters.isDarkMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves "auto" on a public share too', () => {
|
||||
stateMock.route = { path: '/public/share/hash/testdata/' };
|
||||
stateMock.shareInfo = { enforceDarkLightMode: '' };
|
||||
stateMock.user = { username: 'anonymous', darkMode: 'auto', locale: 'en' };
|
||||
stateMock.systemDark = true;
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves string "dark" and "light" values', () => {
|
||||
stateMock.user = { username: 'alice', darkMode: 'dark', locale: 'en' };
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
|
||||
stateMock.user.darkMode = 'light';
|
||||
expect(getters.isDarkMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anonymous share keeps "auto" unresolved', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('stores the raw preference instead of the resolved boolean', () => {
|
||||
globalVarsMock.darkMode = 'auto';
|
||||
expect(getters.anonymous().darkMode).toBe('auto');
|
||||
|
||||
globalVarsMock.darkMode = 'light';
|
||||
expect(getters.anonymous().darkMode).toBe('light');
|
||||
|
||||
globalVarsMock.darkMode = true;
|
||||
expect(getters.anonymous().darkMode).toBe('dark');
|
||||
});
|
||||
|
||||
it('re-resolves the reactive system snapshot on every read', () => {
|
||||
stateMock.route = { path: '/public/share/hash/testdata/' };
|
||||
stateMock.shareInfo = { enforceDarkLightMode: '' };
|
||||
globalVarsMock.darkMode = 'auto';
|
||||
stateMock.user = getters.anonymous();
|
||||
|
||||
stateMock.systemDark = true;
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
|
||||
// The OS switched appearance while the share page stayed open.
|
||||
stateMock.systemDark = false;
|
||||
expect(getters.isDarkMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-resolves the logged-in user the same way', () => {
|
||||
stateMock.route = { path: '/files/' };
|
||||
stateMock.shareInfo = null;
|
||||
globalVarsMock.darkMode = 'dark';
|
||||
stateMock.user = { username: 'alice', darkMode: 'auto', locale: 'en' };
|
||||
|
||||
stateMock.systemDark = false;
|
||||
expect(getters.isDarkMode()).toBe(false);
|
||||
|
||||
stateMock.systemDark = true;
|
||||
expect(getters.isDarkMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { getTypeInfo, isHtmlMimeType, isRichTextPreviewMimeType } from "@/utils/
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
|
||||
import { getNestedProperty, getObjectProperty } from '@/utils/object.js';
|
||||
import { buildItemUrl, removeLeadingSlash, removePrefix } from '@/utils/url.js';
|
||||
import { defaultDarkMode } from "@/utils/theme.js";
|
||||
import { defaultDarkMode, normalizeDarkMode, resolveDarkMode } from "@/utils/theme.js";
|
||||
import { isMobileLayout } from '@/utils/viewport.js';
|
||||
import type { DisplayPreference, FileListItem, fileListItemIndex } from "./types.ts";
|
||||
|
||||
@@ -245,15 +245,15 @@ export const getters = {
|
||||
if (!getters.isLoggedIn()) {
|
||||
// Public shares honor an initialized anonymous preference so the sidebar
|
||||
// toggle reaches the document. Login and other logged-out routes, and a
|
||||
// share whose user or darkMode boolean is not ready yet, stay on the
|
||||
// share whose user or darkMode preference is not ready yet, stay on the
|
||||
// configured default.
|
||||
const sharePreference = getters.isShare() ? state.user?.darkMode : undefined
|
||||
if (typeof sharePreference === "boolean") {
|
||||
return sharePreference
|
||||
if (sharePreference !== undefined && sharePreference !== null) {
|
||||
return resolveDarkMode(sharePreference, defaultDarkMode(state.systemDark), state.systemDark)
|
||||
}
|
||||
return defaultDarkMode()
|
||||
return defaultDarkMode(state.systemDark)
|
||||
}
|
||||
return state.user.darkMode === true
|
||||
return resolveDarkMode(state.user.darkMode, defaultDarkMode(state.systemDark), state.systemDark)
|
||||
},
|
||||
isLoggedIn: () => {
|
||||
if (state.user === null) {
|
||||
@@ -649,7 +649,10 @@ export const getters = {
|
||||
deleteAfterArchive: true,
|
||||
stickySidebar: true,
|
||||
hideFilesInTree: false,
|
||||
darkMode: defaultDarkMode(),
|
||||
// Keep the raw preference ("auto" stays "auto") so isDarkMode re-resolves
|
||||
// it against the system on every read; storing the resolved boolean would
|
||||
// freeze the theme at page load for public shares.
|
||||
darkMode: normalizeDarkMode(globalVars.darkMode),
|
||||
dateFormat: false,
|
||||
disableViewingExt: "",
|
||||
disableOfficePreviewExt: "",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { detectLocale, setLocale } from "@/i18n/index.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype.js";
|
||||
import { getObjectProperty, setObjectProperty, omitObjectProperty } from '@/utils/object.js';
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
@@ -47,6 +48,61 @@ let navigationSortCache: {
|
||||
items: Partial<FileListItem>[];
|
||||
} | null = null;
|
||||
|
||||
type DarkModePref = "auto" | "dark" | "light";
|
||||
|
||||
/**
|
||||
* Serialized dark-mode preference syncing for the sidebar quick toggle.
|
||||
* Rapid clicks apply the choice optimistically and coalesce into at most one
|
||||
* PATCH per settled request, so the saved preference always converges to the
|
||||
* displayed one even when requests would otherwise race.
|
||||
*/
|
||||
let darkModeSyncQueue: Promise<void> = Promise.resolve();
|
||||
let queuedDarkModeTarget: DarkModePref | null = null;
|
||||
let syncedDarkMode: UserObject["darkMode"] | null = null;
|
||||
|
||||
function queueDarkModeUpdate(value: DarkModePref) {
|
||||
if (queuedDarkModeTarget === null) {
|
||||
syncedDarkMode = state.user ? state.user.darkMode : null;
|
||||
darkModeSyncQueue = darkModeSyncQueue.then(runQueuedDarkModeSync, runQueuedDarkModeSync);
|
||||
}
|
||||
queuedDarkModeTarget = value;
|
||||
// Optimistic: show the chosen mode immediately; the queue reconciles the server.
|
||||
if (state.user && state.user.darkMode !== value) {
|
||||
state.user = { ...state.user, darkMode: value };
|
||||
}
|
||||
}
|
||||
|
||||
async function runQueuedDarkModeSync() {
|
||||
while (queuedDarkModeTarget !== null) {
|
||||
const target = queuedDarkModeTarget;
|
||||
const snapshot = state.user;
|
||||
if (!snapshot || snapshot.darkMode === syncedDarkMode) {
|
||||
queuedDarkModeTarget = null;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await usersApi.update(snapshot, ["darkMode"]);
|
||||
// Only clear the queued target if it is still the one we just synced;
|
||||
// a click made while this request was in flight must survive.
|
||||
if (queuedDarkModeTarget === target) {
|
||||
queuedDarkModeTarget = null;
|
||||
}
|
||||
syncedDarkMode = target;
|
||||
} catch (e) {
|
||||
if (queuedDarkModeTarget === target) {
|
||||
// No newer click survived this failed request: roll the optimistic
|
||||
// state back to the last value known to be on the server and stop.
|
||||
if (state.user && syncedDarkMode !== null && state.user.darkMode !== syncedDarkMode) {
|
||||
state.user = { ...state.user, darkMode: syncedDarkMode };
|
||||
}
|
||||
queuedDarkModeTarget = null;
|
||||
notify.showError(e);
|
||||
}
|
||||
// A newer click is queued: keep it and let the next iteration try it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedNavigationItems(listing: Partial<FileListItem>[], by: string, asc: boolean): Partial<FileListItem>[] {
|
||||
const cache = navigationSortCache;
|
||||
if (cache && cache.listing === listing && cache.length === listing.length && cache.by === by && cache.asc === asc) {
|
||||
@@ -383,7 +439,30 @@ export const mutations = {
|
||||
state.settings = value;
|
||||
},
|
||||
toggleDarkMode() {
|
||||
void mutations.updateCurrentUser({ "darkMode": !state.user.darkMode });
|
||||
// Switch to the explicit opposite of the currently resolved mode. From
|
||||
// "auto", this picks the explicit counterpart of the system preference.
|
||||
const dark = getters.isDarkMode();
|
||||
queueDarkModeUpdate(dark ? "light" : "dark");
|
||||
},
|
||||
cycleDarkMode() {
|
||||
// Cycle the preference auto -> light -> dark -> auto, matching the order
|
||||
// of the theme dropdown in profile settings, so the sidebar quick toggle
|
||||
// and the dropdown stay in sync.
|
||||
//
|
||||
// Anonymous share visitors cannot persist a preference (the users PATCH is
|
||||
// skipped for them), so "auto" would only survive until the next page load
|
||||
// and cycling through it breaks the expected "click twice, back where you
|
||||
// started" behaviour. Keep their quick toggle a plain light <-> dark flip of
|
||||
// the resolved theme.
|
||||
if (state.user?.username === "anonymous") {
|
||||
const dark = getters.isDarkMode();
|
||||
queueDarkModeUpdate(dark ? "light" : "dark");
|
||||
return;
|
||||
}
|
||||
const order = ["auto", "light", "dark"] as const;
|
||||
const current = normalizeDarkMode(state.user?.darkMode) as (typeof order)[number];
|
||||
const next = order[(order.indexOf(current) + 1) % order.length] as (typeof order)[number];
|
||||
queueDarkModeUpdate(next);
|
||||
},
|
||||
toggleSidebar() {
|
||||
state.showSidebar = !state.showSidebar;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { markRaw, reactive } from 'vue';
|
||||
import { detectLocale } from "@/i18n/index.ts";
|
||||
import { globalVars } from "@/utils/constants.js";
|
||||
import { normalizeDarkMode, systemPrefersDark } from "@/utils/theme.js";
|
||||
import type { StoreState } from "./types.ts";
|
||||
|
||||
export const state: StoreState = reactive({
|
||||
disableEventThemes: eventTheme(),
|
||||
systemDark: systemPrefersDark(),
|
||||
tooltip: {
|
||||
show: false,
|
||||
content: "",
|
||||
@@ -87,7 +89,7 @@ export const state: StoreState = reactive({
|
||||
newFileTemplate: [], // custom filenames that will be used as template for new files
|
||||
scopes: [],
|
||||
permissions: {}, // Default to an empty object for permissions
|
||||
darkMode: globalVars.darkMode === true,
|
||||
darkMode: normalizeDarkMode(globalVars.darkMode),
|
||||
disableSettings: false,
|
||||
debugOffice: false, // Debug mode for OnlyOffice integration
|
||||
preferEditorForMarkdown: false,
|
||||
|
||||
@@ -230,7 +230,8 @@ export interface UserObject {
|
||||
disableSearchOptions?: boolean;
|
||||
scopes: UserScope[];
|
||||
permissions: Permissions;
|
||||
darkMode: boolean;
|
||||
/** Theme preference: "auto" (follow system), "dark", "light". Legacy booleans are tolerated. */
|
||||
darkMode: boolean | "auto" | "dark" | "light";
|
||||
disableSettings: boolean;
|
||||
showAdvancedProfile?: boolean;
|
||||
debugOffice: boolean;
|
||||
@@ -289,6 +290,9 @@ export interface RouteObject {
|
||||
|
||||
export interface StoreState {
|
||||
disableEventThemes: boolean;
|
||||
// Reactive snapshot of the OS color-scheme, so "auto" re-resolves when the
|
||||
// system switches appearance. Kept in the reactive store on purpose.
|
||||
systemDark: boolean;
|
||||
tooltip: {
|
||||
show: boolean;
|
||||
content: string;
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"keyboard_arrow_down",
|
||||
"keyboard_arrow_up",
|
||||
"license",
|
||||
"light_mode",
|
||||
"link",
|
||||
"link_off",
|
||||
"list",
|
||||
@@ -146,6 +147,7 @@
|
||||
"replay",
|
||||
"replay_10",
|
||||
"restart",
|
||||
"routine",
|
||||
"save",
|
||||
"saved_search",
|
||||
"search",
|
||||
|
||||
@@ -5,6 +5,7 @@ const { globalsMock } = vi.hoisted(() => ({
|
||||
globalsMock: {
|
||||
textPreviewMimeTypes: [
|
||||
"application/json",
|
||||
"application/rtf",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"application/sql",
|
||||
@@ -66,7 +67,13 @@ describe("isTextPreviewableType", () => {
|
||||
});
|
||||
|
||||
it("is true for text-extension mimetypes that are not text/*", () => {
|
||||
for (const mt of ["application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"]) {
|
||||
for (const mt of ["application/json", "application/rtf", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"]) {
|
||||
expect(isTextPreviewableType(mt), mt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is true for RTF mimetypes", () => {
|
||||
for (const mt of ["application/rtf", "text/rtf", "application/rtf; charset=windows-1252"]) {
|
||||
expect(isTextPreviewableType(mt), mt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,71 @@
|
||||
import { globalVars } from "@/utils/constants.js";
|
||||
|
||||
export function defaultDarkMode() {
|
||||
return globalVars.darkMode === true;
|
||||
export const DARK_MODE_AUTO = "auto";
|
||||
export const DARK_MODE_DARK = "dark";
|
||||
export const DARK_MODE_LIGHT = "light";
|
||||
|
||||
/**
|
||||
* Resolves the server-provided default (globalVars.darkMode) to a boolean.
|
||||
* Accepts legacy booleans as well as the "auto" / "dark" / "light" strings;
|
||||
* "auto" follows the client system preference.
|
||||
*/
|
||||
export function defaultDarkMode(systemDark = systemPrefersDark()) {
|
||||
return resolveDarkMode(globalVars.darkMode, false, systemDark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a darkMode preference value (boolean or string) to a boolean.
|
||||
* `systemDark` lets callers pass a reactive snapshot of the system preference
|
||||
* so "auto" can re-resolve when the OS switches appearance.
|
||||
*/
|
||||
export function resolveDarkMode(value, fallback = false, systemDark = systemPrefersDark()) {
|
||||
if (value === true || value === DARK_MODE_DARK) {
|
||||
return true;
|
||||
}
|
||||
if (value === false || value === DARK_MODE_LIGHT) {
|
||||
return false;
|
||||
}
|
||||
if (value === DARK_MODE_AUTO) {
|
||||
return systemDark;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Reports whether the client system currently prefers dark mode. */
|
||||
export function systemPrefersDark() {
|
||||
if (typeof window === "undefined" || !window.matchMedia) {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for system color-scheme changes and invokes the callback with the
|
||||
* new boolean preference. Returns a cleanup function.
|
||||
*/
|
||||
export function watchSystemTheme(callback) {
|
||||
if (typeof window === "undefined" || !window.matchMedia) {
|
||||
return () => {};
|
||||
}
|
||||
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (event) => callback(event.matches);
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}
|
||||
// Safari < 14 fallback
|
||||
mql.addListener(handler);
|
||||
return () => mql.removeListener(handler);
|
||||
}
|
||||
|
||||
/** Normalizes any accepted darkMode value to the "auto" / "dark" / "light" string form. */
|
||||
export function normalizeDarkMode(value) {
|
||||
if (value === DARK_MODE_AUTO || value === DARK_MODE_DARK || value === DARK_MODE_LIGHT) {
|
||||
return value;
|
||||
}
|
||||
if (value === false) return DARK_MODE_LIGHT;
|
||||
// true and unset/unknown values map to the historical default (dark).
|
||||
return DARK_MODE_DARK;
|
||||
}
|
||||
|
||||
export function syncDocumentTheme(dark) {
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('@/utils/constants', () => ({
|
||||
globalVars: globalVarsMock,
|
||||
}));
|
||||
|
||||
import { defaultDarkMode, syncDocumentTheme } from './theme.js';
|
||||
import { defaultDarkMode, normalizeDarkMode, syncDocumentTheme, systemPrefersDark } from './theme.js';
|
||||
|
||||
describe('theme utils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -47,3 +47,50 @@ describe('theme utils', () => {
|
||||
expect(document.querySelector('meta[name="theme-color"]').getAttribute('content')).toBe('#f5f5f5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto dark mode support', () => {
|
||||
beforeEach(() => {
|
||||
globalVarsMock.darkMode = false;
|
||||
});
|
||||
|
||||
it('defaultDarkMode resolves string values from globalVars', () => {
|
||||
globalVarsMock.darkMode = 'dark';
|
||||
expect(defaultDarkMode()).toBe(true);
|
||||
|
||||
globalVarsMock.darkMode = 'light';
|
||||
expect(defaultDarkMode()).toBe(false);
|
||||
|
||||
globalVarsMock.darkMode = true;
|
||||
expect(defaultDarkMode()).toBe(true);
|
||||
|
||||
globalVarsMock.darkMode = false;
|
||||
expect(defaultDarkMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaultDarkMode with "auto" follows matchMedia', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })));
|
||||
globalVarsMock.darkMode = 'auto';
|
||||
expect(defaultDarkMode()).toBe(true);
|
||||
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })));
|
||||
expect(defaultDarkMode()).toBe(false);
|
||||
|
||||
expect(systemPrefersDark()).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('systemPrefersDark returns false when matchMedia is unavailable', () => {
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
expect(systemPrefersDark()).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('normalizeDarkMode maps legacy booleans and passes through strings', () => {
|
||||
expect(normalizeDarkMode(true)).toBe('dark');
|
||||
expect(normalizeDarkMode(false)).toBe('light');
|
||||
expect(normalizeDarkMode('auto')).toBe('auto');
|
||||
expect(normalizeDarkMode('dark')).toBe('dark');
|
||||
expect(normalizeDarkMode('light')).toBe('light');
|
||||
expect(normalizeDarkMode(undefined)).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { getNestedProperty, getObjectProperty } from "@/utils/object.js";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
|
||||
function boolPtr(val, defaultValue = true) {
|
||||
if (val === undefined || val === null) {
|
||||
@@ -77,7 +78,7 @@ export function sectionsFromFlatUser(user) {
|
||||
disableOptions: !!u.disableSearchOptions,
|
||||
},
|
||||
ui: {
|
||||
darkMode: boolPtr(u.darkMode),
|
||||
darkMode: normalizeDarkMode(u.darkMode),
|
||||
themeColor: u.themeColor || "",
|
||||
customTheme: u.customTheme || "",
|
||||
locale: u.locale || "",
|
||||
@@ -158,7 +159,9 @@ export function applySectionsToFlatUser(user, sections) {
|
||||
|
||||
user.disableSearchOptions = !!search.disableOptions;
|
||||
|
||||
user.darkMode = boolPtr(ui.darkMode);
|
||||
user.darkMode = ui.darkMode === undefined || ui.darkMode === null
|
||||
? normalizeDarkMode(user.darkMode)
|
||||
: normalizeDarkMode(ui.darkMode);
|
||||
user.themeColor = ui.themeColor || "";
|
||||
user.customTheme = ui.customTheme || "";
|
||||
if (ui.locale !== undefined) {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="duplicateGroups.length > 0">
|
||||
<div v-if="duplicateGroups.length > 0 && resultsApplyToInputs">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t('general.results') }}</h2>
|
||||
</div>
|
||||
@@ -103,8 +103,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<i class="material-symbols-outlined">content_copy</i>
|
||||
<p>{{ $t('duplicateFinder.emptyState') }}</p>
|
||||
<template v-if="showNoDuplicateResults">
|
||||
<i v-if="isIncomplete" class="material-symbols-outlined">warning</i>
|
||||
<i v-else class="material-symbols-outlined">check_circle</i>
|
||||
<template v-if="isIncomplete">
|
||||
<p><strong>{{ $t('fileSizeAnalyzer.incompleteResults') }}</strong></p>
|
||||
<p v-if="incompleteReason">{{ incompleteReason }}</p>
|
||||
</template>
|
||||
<p v-else>{{ $t('duplicateFinder.noResults') }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="material-symbols-outlined">content_copy</i>
|
||||
<p>{{ $t('duplicateFinder.emptyState') }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,6 +176,8 @@ const resultPath = ref("/");
|
||||
const resultSource = ref("");
|
||||
const minSizeValue = ref(10);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const searchedCriteria = ref<{ path: string; source: string; minSize: number } | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const duplicateGroups = shallowRef<DuplicateGroup[]>([]);
|
||||
const isIncomplete = ref(false);
|
||||
@@ -176,6 +189,20 @@ const deletedFiles = ref(new Set<string>());
|
||||
const failedFiles = ref(new Map<string, string>());
|
||||
let isInitializing = true;
|
||||
|
||||
const resultsApplyToInputs = computed(() => {
|
||||
if (!searchedCriteria.value) {
|
||||
return false;
|
||||
}
|
||||
const s = searchedCriteria.value;
|
||||
return s.path === searchPath.value &&
|
||||
s.source === selectedSource.value &&
|
||||
s.minSize === minSizeValue.value;
|
||||
});
|
||||
|
||||
const showNoDuplicateResults = computed(() =>
|
||||
hasSearched.value && resultsApplyToInputs.value && duplicateGroups.value.length === 0,
|
||||
);
|
||||
|
||||
const totalWastedSpace = computed(() => {
|
||||
return duplicateGroups.value.reduce((sum, group) => {
|
||||
// Wasted space = size × (count - 1)
|
||||
@@ -272,6 +299,7 @@ async function fetchData() {
|
||||
lastRequestTime = now;
|
||||
const requestPath = searchPath.value;
|
||||
const requestSource = selectedSource.value;
|
||||
const requestMinSize = minSizeValue.value;
|
||||
|
||||
try {
|
||||
// API now expects minSizeMb directly (in megabytes)
|
||||
@@ -283,14 +311,26 @@ async function fetchData() {
|
||||
false // Checksum disabled
|
||||
);
|
||||
|
||||
// Handle new response format with incomplete metadata
|
||||
if (
|
||||
requestPath !== searchPath.value ||
|
||||
requestSource !== selectedSource.value ||
|
||||
requestMinSize !== minSizeValue.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
resultPath.value = requestPath;
|
||||
resultSource.value = requestSource;
|
||||
duplicateGroups.value = result.groups || [];
|
||||
isIncomplete.value = result.incomplete || false;
|
||||
incompleteReason.value = result.reason || "";
|
||||
searchedCriteria.value = {
|
||||
path: requestPath,
|
||||
source: requestSource,
|
||||
minSize: requestMinSize,
|
||||
};
|
||||
|
||||
// Reset selection, deleted, and failed files when new results arrive
|
||||
hasSearched.value = true;
|
||||
clearSelection();
|
||||
deletedFiles.value.clear();
|
||||
failedFiles.value.clear();
|
||||
@@ -299,6 +339,8 @@ async function fetchData() {
|
||||
duplicateGroups.value = [];
|
||||
isIncomplete.value = false;
|
||||
incompleteReason.value = "";
|
||||
hasSearched.value = false;
|
||||
searchedCriteria.value = null;
|
||||
clearSelection();
|
||||
deletedFiles.value.clear();
|
||||
failedFiles.value.clear();
|
||||
|
||||
Reference in new issue
Block a user