Compare commits

..
83 changed files with 2075 additions and 652 deletions

No files matched your search

+11 -11
View File
@@ -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
View File
@@ -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=
+5 -5
View File
@@ -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
}
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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)
+23
View File
@@ -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)
}
+4 -5
View File
@@ -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 {
+31 -8
View File
@@ -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()
+39 -469
View File
@@ -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")
}
}
+264
View File
@@ -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))
}
})
}
+111
View File
@@ -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))
}
}
+204
View File
@@ -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))
+5 -1
View File
@@ -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 != "" {
+1
View File
@@ -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,
+4 -2
View File
@@ -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 {
+4 -4
View File
@@ -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")
}
}
+5 -5
View File
@@ -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
},
},
+3 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
+11 -1
View File
@@ -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
+25 -4
View File
@@ -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",
+25 -4
View File
@@ -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",
+16 -4
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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>
+3
View File
@@ -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;
+20 -5
View File
@@ -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
+5 -1
View File
@@ -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": "الإشعارات",
+5 -1
View File
@@ -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": "Сървърът беше изключен.",
+5 -1
View File
@@ -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í",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Ειδοποιήσεις",
+5 -1
View File
@@ -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.",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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.",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "הודעות",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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.",
+5 -1
View File
@@ -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": "お知らせ",
+5 -1
View File
@@ -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": "알림",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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.",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Уведомления",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Сповіщення",
+5 -1
View File
@@ -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": "通知",
+5 -1
View File
@@ -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": "通知",
+9 -2
View File
@@ -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
View File
@@ -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: "",
+80 -1
View File
@@ -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;
+3 -1
View File
@@ -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,
+5 -1
View File
@@ -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",
+8 -1
View File
@@ -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);
}
});
+66 -2
View File
@@ -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) {
+48 -1
View File
@@ -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');
});
});
+5 -2
View File
@@ -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) {
+47 -5
View File
@@ -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();