czkawka_core-11.0.1/.cargo_vcs_info.json0000644000000001521046102023000135770ustar { "git": { "sha1": "9e40abeae2841cb3fb3e7e5cc7f1bfe049f215a9" }, "path_in_vcs": "czkawka_core" }czkawka_core-11.0.1/Cargo.lock0000644000004312761046102023000115710ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "ab_glyph" version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", ] [[package]] name = "ab_glyph_rasterizer" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adler32" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "aes" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", "cpufeatures 0.2.17", ] [[package]] name = "ahash" version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", "zerocopy", ] [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "aligned" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ "as-slice", ] [[package]] name = "aligned-vec" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" dependencies = [ "equator", ] [[package]] name = "alloc-no-stdlib" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] [[package]] name = "alloca" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" dependencies = [ "cc", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anes" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" dependencies = [ "num-traits", ] [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] [[package]] name = "arg_enum_proc_macro" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "as-slice" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" dependencies = [ "stable_deref_trait", ] [[package]] name = "ashpd" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "522dc9bec59923af17c43c5911cdabbacdb32ed4f955e83ecf592855618b20b5" dependencies = [ "enumflags2", "futures-channel", "futures-util", "rand 0.9.2", "serde", "serde_repr", "tokio", "url", "zbus", ] [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] [[package]] name = "async-recursion" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "async-trait" version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "av-data" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fca67ba5d317924c02180c576157afd54babe48a76ebc66ce6d34bb8ba08308e" dependencies = [ "byte-slice-cast", "bytes", "num-derive", "num-rational", "num-traits", ] [[package]] name = "av-scenechange" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" dependencies = [ "aligned", "anyhow", "arg_enum_proc_macro", "arrayvec", "log", "num-rational", "num-traits", "pastey", "rayon", "thiserror 2.0.18", "v_frame", "y4m", ] [[package]] name = "av1-grain" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" dependencies = [ "anyhow", "arrayvec", "log", "nom 8.0.0", "num-rational", "v_frame", ] [[package]] name = "avif-serialize" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" dependencies = [ "arrayvec", ] [[package]] name = "backtrace" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-link", ] [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "basic-toml" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" dependencies = [ "serde", ] [[package]] name = "bincode" version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ "serde", ] [[package]] name = "bit_field" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bitreader" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" dependencies = [ "cfg-if", ] [[package]] name = "bitstream-io" version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" dependencies = [ "core2", ] [[package]] name = "bitvec" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" dependencies = [ "funty", "radium", "tap", "wyz", ] [[package]] name = "bk-tree" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8283fb8e64b873918f8bc527efa6aff34956296e48ea750a9c909cd47c01546" dependencies = [ "fnv", "triple_accel", ] [[package]] name = "blake3" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", "cpufeatures 0.2.17", ] [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "block-padding" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ "objc2", ] [[package]] name = "brotli" version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor", ] [[package]] name = "brotli-decompressor" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] name = "built" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byte-slice-cast" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytecount" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ "libbz2-rs-sys", ] [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ "cipher", ] [[package]] name = "cc" version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] name = "cfb" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", "uuid", ] [[package]] name = "cfg-expr" version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.0", ] [[package]] name = "chrono" version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", "windows-link", ] [[package]] name = "ciborium" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ "ciborium-io", "ciborium-ll", "serde", ] [[package]] name = "ciborium-io" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] name = "ciborium-ll" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", "half", ] [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", ] [[package]] name = "clap" version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstyle", "clap_lex", ] [[package]] name = "clap_lex" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core2" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" dependencies = [ "memchr", ] [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "cpufeatures" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "criterion" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", "itertools 0.13.0", "num-traits", "oorandom", "page_size", "regex", "serde", "serde_json", "tinytemplate", "walkdir", ] [[package]] name = "criterion-plot" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools 0.13.0", ] [[package]] name = "crossbeam-channel" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "czkawka_core" version = "11.0.1" dependencies = [ "ashpd", "bincode", "bitflags 2.11.0", "bk-tree", "blake3", "crc32fast", "criterion", "crossbeam-channel", "deunicode", "directories-next", "dunce", "file-id", "file-rotate", "filetime", "fun_time", "glibc_musl_version", "hamming-bitwise-fast", "handsome_logger", "humansize", "i18n-embed", "i18n-embed-fl", "image", "image_hasher", "indexmap", "infer", "itertools 0.14.0", "jxl-oxide", "libheif-rs", "libraw-rs", "little_exif", "lofty", "log", "log-panics", "lopdf", "mime_guess", "nom-exif", "once_cell", "open", "os_info", "rand 0.10.0", "rawler", "rayon", "rust-embed", "rustc_version", "rusty-chromaprint", "serde", "serde_json", "static_assertions", "symphonia", "tempfile", "tokio", "trash", "vid_dup_finder_lib", "xxhash-rust", "zip", ] [[package]] name = "darling" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim 0.10.0", "syn 1.0.109", ] [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ "darling_core", "quote", "syn 1.0.109", ] [[package]] name = "dary_heap" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "dav1d" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80c3f80814db85397819d464bb553268992c393b4b3b5554b89c1655996d5926" dependencies = [ "av-data", "bitflags 2.11.0", "dav1d-sys", "static_assertions", ] [[package]] name = "dav1d-sys" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" dependencies = [ "libc", "system-deps", ] [[package]] name = "deranged" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] [[package]] name = "deunicode" version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", "subtle", ] [[package]] name = "directories-next" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" dependencies = [ "cfg-if", "dirs-sys-next", ] [[package]] name = "dirs-sys-next" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", "redox_users", "winapi", ] [[package]] name = "dispatch2" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", "objc2", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "document-features" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecb" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" dependencies = [ "cipher", ] [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-utils" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed327f716d0d351d86c9fd3398d20ee39ad8f681873cc081da2ca1c10fed398a" dependencies = [ "enum-utils-from-str", "failure", "proc-macro2", "quote", "serde_derive_internals", "syn 1.0.109", ] [[package]] name = "enum-utils-from-str" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49be08bad6e4ca87b2b8e74146987d4e5cb3b7512efa50ef505b51a22227ee1" dependencies = [ "proc-macro2", "quote", ] [[package]] name = "enumflags2" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", ] [[package]] name = "enumflags2_derive" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "enumn" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "equator" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ "equator-macro", ] [[package]] name = "equator-macro" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "event-listener" version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "event-listener-strategy" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ "event-listener", "pin-project-lite", ] [[package]] name = "exr" version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ "bit_field", "half", "lebe", "miniz_oxide", "rayon-core", "smallvec", "zune-inflate", ] [[package]] name = "extended" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] name = "failure" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" dependencies = [ "backtrace", ] [[package]] name = "fallible_collections" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" dependencies = [ "hashbrown 0.13.2", ] [[package]] name = "fast_image_resize" version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbc7fe45cf92b43817ff62a3723e862b85bd1d06288f63007f7645d1d2f7a060" dependencies = [ "bytemuck", "cfg-if", "document-features", "image", "num-traits", "thiserror 2.0.18", ] [[package]] name = "fast_image_resize" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" dependencies = [ "bytemuck", "cfg-if", "document-features", "image", "num-traits", "thiserror 2.0.18", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fax" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" dependencies = [ "fax_derive", ] [[package]] name = "fax_derive" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "fdeflate" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] [[package]] name = "ffmpeg_cmdline_utils" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30cbcb92e5f36bda100292a8bf8989631f3b6c4e4b71454ca803a9b837f63441" dependencies = [ "image", "serde", "serde_json", "thiserror 2.0.18", "winapi", ] [[package]] name = "ffmpeg_gst_wrapper" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc2e75be881230e5808200de02c435cfee05b5e0b978ce50cdbcf6527e8d13de" dependencies = [ "cfg-if", "ffmpeg_cmdline_utils", "image", "serde", "thiserror 2.0.18", "url", ] [[package]] name = "file-id" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" dependencies = [ "windows-sys 0.60.2", ] [[package]] name = "file-rotate" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" dependencies = [ "chrono", "flate2", ] [[package]] name = "filetime" version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", ] [[package]] name = "find-crate" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" dependencies = [ "toml 0.5.11", ] [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", "zlib-rs", ] [[package]] name = "fluent" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" dependencies = [ "fluent-bundle", "unic-langid", ] [[package]] name = "fluent-bundle" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" dependencies = [ "fluent-langneg", "fluent-syntax", "intl-memoizer", "intl_pluralrules", "rustc-hash", "self_cell", "smallvec", "unic-langid", ] [[package]] name = "fluent-langneg" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" dependencies = [ "unic-langid", ] [[package]] name = "fluent-syntax" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", "thiserror 2.0.18", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "four-cc" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" [[package]] name = "fun_time" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee194d43605ea83cca7af42af5f9001fab1a8e2220cb8a012e21dda6167fdb0" dependencies = [ "fun_time_derive", "log", ] [[package]] name = "fun_time_derive" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71555fd2db00938d82d29d8fa62a2ae80aed2c162c328d775f79e98d9212f013" dependencies = [ "darling", "log", "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", "futures-io", "parking", "pin-project-lite", ] [[package]] name = "futures-macro" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", "futures-task", "pin-project-lite", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "geo-types" version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" dependencies = [ "approx", "num-traits", "serde", ] [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", ] [[package]] name = "getrandom" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "rand_core 0.10.0", "wasip2", "wasip3", "wasm-bindgen", ] [[package]] name = "gif" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", ] [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glibc_musl_version" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b03526c417228f37a649e2697eb015f81c59817679bf01ac62445ce6a9b19ef" [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "half" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", "zerocopy", ] [[package]] name = "hamming-bitwise-fast" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06d16627a786f2f40f9079bd54a3c7987df493d421f2a6fecca7dc0886ebc7b9" [[package]] name = "handsome_logger" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "088e04bbb44412ba271e360f49b5f5f8d09095380c81a2b6edb9602caf5025b7" dependencies = [ "log", "termcolor", "time", "tz-rs", ] [[package]] name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ "ahash", ] [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", ] [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest", ] [[package]] name = "humansize" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" dependencies = [ "libm", ] [[package]] name = "i18n-config" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" dependencies = [ "basic-toml", "log", "serde", "serde_derive", "thiserror 1.0.69", "unic-langid", ] [[package]] name = "i18n-embed" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" dependencies = [ "arc-swap", "fluent", "fluent-langneg", "fluent-syntax", "i18n-embed-impl", "intl-memoizer", "log", "parking_lot", "rust-embed", "sys-locale", "thiserror 1.0.69", "unic-langid", "walkdir", ] [[package]] name = "i18n-embed-fl" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e598ed73b67db92f61e04672e599eef2991a262a40e1666735b8a86d2e7e9f30" dependencies = [ "find-crate", "fluent", "fluent-syntax", "i18n-config", "i18n-embed", "proc-macro-error2", "proc-macro2", "quote", "strsim 0.11.1", "syn 2.0.117", "unic-langid", ] [[package]] name = "i18n-embed-impl" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" dependencies = [ "find-crate", "i18n-config", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core 0.62.2", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "image" version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "dav1d", "exr", "gif", "image-webp", "moxcms", "mp4parse", "num-traits", "png", "qoi", "ravif", "rayon", "rgb", "tiff", "zune-core 0.5.1", "zune-jpeg 0.5.12", ] [[package]] name = "image-webp" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", "quick-error", ] [[package]] name = "image_hasher" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300d892b049fb36ce62fb515b68aeade53dca784bc02093e359edc6625c479ac" dependencies = [ "base64", "fast_image_resize 6.0.0", "image", "rustdct", "serde", "transpose", ] [[package]] name = "imageproc" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" dependencies = [ "ab_glyph", "approx", "getrandom 0.2.17", "image", "itertools 0.12.1", "nalgebra", "num", "rand 0.8.5", "rand_distr", "rayon", ] [[package]] name = "imgref" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", "serde", "serde_core", ] [[package]] name = "infer" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" dependencies = [ "cfb", ] [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", "generic-array", ] [[package]] name = "interpolate_name" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "intl-memoizer" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" dependencies = [ "type-map", "unic-langid", ] [[package]] name = "intl_pluralrules" version = "7.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" dependencies = [ "unic-langid", ] [[package]] name = "is-docker" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ "once_cell", ] [[package]] name = "is-wsl" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" dependencies = [ "is-docker", "once_cell", ] [[package]] name = "iso6709parse" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5090db9c6a716d1f4eeb729957e889e9c28156061c825cbccd44950cf0f3c66" dependencies = [ "geo-types", "nom 7.1.3", ] [[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" dependencies = [ "jiff-static", "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", "windows-sys 0.61.2", ] [[package]] name = "jiff-static" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "jiff-tzdb" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" [[package]] name = "jiff-tzdb-platform" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" dependencies = [ "jiff-tzdb", ] [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "jxl-bitstream" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" dependencies = [ "tracing", ] [[package]] name = "jxl-coding" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" dependencies = [ "jxl-bitstream", "tracing", ] [[package]] name = "jxl-color" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" dependencies = [ "jxl-bitstream", "jxl-coding", "jxl-grid", "jxl-image", "jxl-oxide-common", "jxl-threadpool", "tracing", ] [[package]] name = "jxl-frame" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" dependencies = [ "jxl-bitstream", "jxl-coding", "jxl-grid", "jxl-image", "jxl-modular", "jxl-oxide-common", "jxl-threadpool", "jxl-vardct", "tracing", ] [[package]] name = "jxl-grid" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0e0ef92d5d60e76bf41098e57e985f523185e08fad54268da448637feca6989" dependencies = [ "tracing", ] [[package]] name = "jxl-image" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" dependencies = [ "jxl-bitstream", "jxl-grid", "jxl-oxide-common", "tracing", ] [[package]] name = "jxl-jbr" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" dependencies = [ "brotli-decompressor", "jxl-bitstream", "jxl-frame", "jxl-grid", "jxl-image", "jxl-modular", "jxl-oxide-common", "jxl-threadpool", "jxl-vardct", "tracing", ] [[package]] name = "jxl-modular" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da758b2f989aafd9eeb39489fe43d7be5a3a0d2ad61cf1bad705eb6990a6053c" dependencies = [ "jxl-bitstream", "jxl-coding", "jxl-grid", "jxl-oxide-common", "jxl-threadpool", "tracing", ] [[package]] name = "jxl-oxide" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8ecd2678ed70c1eda42b811ccb2e25ab836edeb18e7f1178c1f917ed36b772" dependencies = [ "brotli-decompressor", "bytemuck", "image", "jxl-bitstream", "jxl-color", "jxl-frame", "jxl-grid", "jxl-image", "jxl-jbr", "jxl-oxide-common", "jxl-render", "jxl-threadpool", "tracing", ] [[package]] name = "jxl-oxide-common" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" dependencies = [ "jxl-bitstream", ] [[package]] name = "jxl-render" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa0c3100918bd3c41bb0f8ce1f4f1664e48f3032ff8eeab0d6a2cfc3276f462d" dependencies = [ "bytemuck", "jxl-bitstream", "jxl-coding", "jxl-color", "jxl-frame", "jxl-grid", "jxl-image", "jxl-modular", "jxl-oxide-common", "jxl-threadpool", "jxl-vardct", "tracing", ] [[package]] name = "jxl-threadpool" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" dependencies = [ "rayon", "rayon-core", "tracing", ] [[package]] name = "jxl-vardct" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" dependencies = [ "jxl-bitstream", "jxl-coding", "jxl-grid", "jxl-modular", "jxl-oxide-common", "jxl-threadpool", "tracing", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lebe" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libbz2-rs-sys" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libflate" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" dependencies = [ "adler32", "core2", "crc32fast", "dary_heap", "libflate_lz77", ] [[package]] name = "libflate_lz77" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" dependencies = [ "core2", "hashbrown 0.16.1", "rle-decode-fast", ] [[package]] name = "libfuzzer-sys" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" dependencies = [ "arbitrary", "cc", ] [[package]] name = "libheif-rs" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5de2912bca071d0f609cd6cb8d348c475439f69e808726374bea9e079591559e" dependencies = [ "cfg-if", "enumn", "four-cc", "image", "libc", "libheif-sys", ] [[package]] name = "libheif-sys" version = "5.2.0+1.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "107c0d813a0cf5ddf7af2c58a60611f18efdb2830e84d9b37580fb20e6e27a2b" dependencies = [ "cfg-if", "libc", "system-deps", "vcpkg", "walkdir", ] [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libraw-rs" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24ec60aab878560c299c6e70a0c6dc2278a2159ac6fe09650917266b8985387f" dependencies = [ "libraw-rs-sys", ] [[package]] name = "libraw-rs-sys" version = "0.0.4+libraw-0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba094a3b8b04cc42fdeafaff06f81d3b13a7d01cc7a8eae55b943dae1b65c3cc" dependencies = [ "cc", "libc", ] [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.11.0", "libc", "redox_syscall 0.7.1", ] [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "little_exif" version = "0.6.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122" dependencies = [ "brotli", "crc", "log", "miniz_oxide", "paste", "quick-xml", ] [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "lofty" version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "179408be6ddda3771589a4e940b1b5718613fa9986d78f420890d20e2b6fc278" dependencies = [ "byteorder", "data-encoding", "flate2", "lofty_attr", "log", "ogg_pager", "paste", ] [[package]] name = "lofty_attr" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "log-panics" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f9dd8546191c1850ecf67d22f5ff00a935b890d0e84713159a55495cc2ac5f" dependencies = [ "backtrace", "log", ] [[package]] name = "loop9" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ "imgref", ] [[package]] name = "lopdf" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f560f57dfb9142a02d673e137622fd515d4231e51feb8b4af28d92647d83f35b" dependencies = [ "aes", "bitflags 2.11.0", "cbc", "chrono", "ecb", "encoding_rs", "flate2", "getrandom 0.3.4", "indexmap", "itoa", "jiff", "log", "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.2", "rangemap", "rayon", "sha2", "stringprep", "thiserror 2.0.18", "time", "ttf-parser", "weezl", ] [[package]] name = "matrixmultiply" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" dependencies = [ "autocfg", "rawpointer", ] [[package]] name = "maybe-rayon" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", "rayon", ] [[package]] name = "md-5" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", "digest", ] [[package]] name = "md5" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", ] [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", ] [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "mio" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "moxcms" version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", ] [[package]] name = "mp4parse" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63a35203d3c6ce92d5251c77520acb2e57108c88728695aa883f70023624c570" dependencies = [ "bitreader", "byteorder", "fallible_collections", "log", "num-traits", "static_assertions", ] [[package]] name = "multiversion" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" dependencies = [ "multiversion-macros", "target-features", ] [[package]] name = "multiversion-macros" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "target-features", ] [[package]] name = "nalgebra" version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" dependencies = [ "approx", "matrixmultiply", "num-complex", "num-rational", "num-traits", "simba", "typenum", ] [[package]] name = "ndarray" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" dependencies = [ "matrixmultiply", "num-complex", "num-integer", "num-traits", "portable-atomic", "portable-atomic-util", "rawpointer", ] [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", ] [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "nom" version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", ] [[package]] name = "nom-exif" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e78a8215f056e78a6887b4872e61bab5690dec4648b37396b6721be6f89c4ea" dependencies = [ "bytes", "chrono", "iso6709parse", "nom 7.1.3", "regex", "thiserror 2.0.18", "tracing", ] [[package]] name = "nom_locate" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" dependencies = [ "bytecount", "memchr", "nom 8.0.0", ] [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ "num-bigint", "num-complex", "num-integer", "num-iter", "num-rational", "num-traits", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-complex" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", ] [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-iter" version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ "num-bigint", "num-integer", "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", ] [[package]] name = "num_enum" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ "num_enum_derive", "rustversion", ] [[package]] name = "num_enum_derive" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "num_threads" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] [[package]] name = "objc2" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] [[package]] name = "objc2-cloud-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.11.0", "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-data" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", "objc2", ] [[package]] name = "objc2-core-graphics" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", "objc2-io-surface", ] [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-location" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", ] [[package]] name = "objc2-encode" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", "libc", "objc2", "objc2-core-foundation", ] [[package]] name = "objc2-io-surface" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] [[package]] name = "objc2-quartz-core" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-foundation", ] [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", "block2", "objc2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", "objc2-core-location", "objc2-core-text", "objc2-foundation", "objc2-quartz-core", "objc2-user-notifications", ] [[package]] name = "objc2-user-notifications" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ "objc2", "objc2-foundation", ] [[package]] name = "object" version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "ogg_pager" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" dependencies = [ "byteorder", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "is-wsl", "libc", "pathdiff", ] [[package]] name = "ordered-stream" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ "futures-core", "pin-project-lite", ] [[package]] name = "os_info" version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ "android_system_properties", "log", "nix", "objc2", "objc2-foundation", "objc2-ui-kit", "windows-sys 0.61.2", ] [[package]] name = "owned_ttf_parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] [[package]] name = "page_size" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" dependencies = [ "libc", "winapi", ] [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn 2.0.117", ] [[package]] name = "primal-check" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" dependencies = [ "num-integer", ] [[package]] name = "proc-macro-crate" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] [[package]] name = "proc-macro-error-attr2" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ "proc-macro2", "quote", ] [[package]] name = "proc-macro-error2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "profiling" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", "syn 2.0.117", ] [[package]] name = "pxfm" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] [[package]] name = "qoi" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" dependencies = [ "bytemuck", ] [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] [[package]] name = "quote" version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] name = "rand" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ "chacha20", "getrandom 0.4.1", "rand_core 0.10.0", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core 0.6.4", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.5", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.17", ] [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "rand_core" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rand_distr" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", "rand 0.8.5", ] [[package]] name = "rangemap" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rav1e" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" dependencies = [ "aligned-vec", "arbitrary", "arg_enum_proc_macro", "arrayvec", "av-scenechange", "av1-grain", "bitstream-io", "built", "cfg-if", "interpolate_name", "itertools 0.14.0", "libc", "libfuzzer-sys", "log", "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", "num-derive", "num-traits", "paste", "profiling", "rand 0.9.2", "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.18", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" dependencies = [ "avif-serialize", "imgref", "loop9", "quick-error", "rav1e", "rayon", "rgb", ] [[package]] name = "rawler" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a888d0a4bf6a13c112c88d279d2d4a1f60dd8ca520fdd65193de54687cb9d2d" dependencies = [ "backtrace", "bitstream-io", "byteorder", "chrono", "enumn", "glob", "hex", "image", "itertools 0.14.0", "jxl-oxide", "lazy_static", "libflate", "log", "md5", "memmap2", "multiversion", "num", "num_enum", "rayon", "rustc_version", "serde", "thiserror 2.0.18", "toml 0.8.23", "uuid", "weezl", "zerocopy", ] [[package]] name = "rawpointer" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "realfft" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" dependencies = [ "rustfft", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags 2.11.0", ] [[package]] name = "redox_syscall" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ "bitflags 2.11.0", ] [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] [[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rgb" version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" [[package]] name = "rle-decode-fast" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] name = "rubato" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" dependencies = [ "num-complex", "num-integer", "num-traits", "realfft", ] [[package]] name = "rust-embed" version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", "walkdir", ] [[package]] name = "rust-embed-impl" version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", "syn 2.0.117", "walkdir", ] [[package]] name = "rust-embed-utils" version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "sha2", "walkdir", ] [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustdct" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551" dependencies = [ "rustfft", ] [[package]] name = "rustfft" version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" dependencies = [ "num-complex", "num-integer", "num-traits", "primal-check", "strength_reduce", "transpose", ] [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-chromaprint" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e4234523e38d9c12201955f8216e1a60313e64c5077f4e1cf49b0db77bd7e8" dependencies = [ "rubato", "rustfft", ] [[package]] name = "safe_arch" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" dependencies = [ "bytemuck", ] [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "self_cell" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "serde_derive_internals" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dbab34ca63057a1f15280bdf3c39f2b1eb1b54c17e98360e511637aef7418c6" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "serde_repr" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "serde_spanned" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] [[package]] name = "serde_spanned" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ "errno", "libc", ] [[package]] name = "simba" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" dependencies = [ "approx", "num-complex", "num-traits", "paste", "wide", ] [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simd_helpers" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" dependencies = [ "quote", ] [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" [[package]] name = "stringprep" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ "unicode-bidi", "unicode-normalization", "unicode-properties", ] [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symphonia" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", "symphonia-bundle-flac", "symphonia-bundle-mp3", "symphonia-codec-aac", "symphonia-codec-adpcm", "symphonia-codec-alac", "symphonia-codec-pcm", "symphonia-codec-vorbis", "symphonia-core", "symphonia-format-caf", "symphonia-format-isomp4", "symphonia-format-mkv", "symphonia-format-ogg", "symphonia-format-riff", "symphonia-metadata", ] [[package]] name = "symphonia-bundle-flac" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" dependencies = [ "log", "symphonia-core", "symphonia-metadata", "symphonia-utils-xiph", ] [[package]] name = "symphonia-bundle-mp3" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" dependencies = [ "lazy_static", "log", "symphonia-core", "symphonia-metadata", ] [[package]] name = "symphonia-codec-aac" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" dependencies = [ "lazy_static", "log", "symphonia-core", ] [[package]] name = "symphonia-codec-adpcm" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" dependencies = [ "log", "symphonia-core", ] [[package]] name = "symphonia-codec-alac" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" dependencies = [ "log", "symphonia-core", ] [[package]] name = "symphonia-codec-pcm" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" dependencies = [ "log", "symphonia-core", ] [[package]] name = "symphonia-codec-vorbis" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" dependencies = [ "log", "symphonia-core", "symphonia-utils-xiph", ] [[package]] name = "symphonia-core" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" dependencies = [ "arrayvec", "bitflags 1.3.2", "bytemuck", "lazy_static", "log", ] [[package]] name = "symphonia-format-caf" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" dependencies = [ "log", "symphonia-core", "symphonia-metadata", ] [[package]] name = "symphonia-format-isomp4" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" dependencies = [ "encoding_rs", "log", "symphonia-core", "symphonia-metadata", "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-mkv" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" dependencies = [ "lazy_static", "log", "symphonia-core", "symphonia-metadata", "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-ogg" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" dependencies = [ "log", "symphonia-core", "symphonia-metadata", "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-riff" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" dependencies = [ "extended", "log", "symphonia-core", "symphonia-metadata", ] [[package]] name = "symphonia-metadata" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" dependencies = [ "encoding_rs", "lazy_static", "log", "symphonia-core", ] [[package]] name = "symphonia-utils-xiph" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" dependencies = [ "symphonia-core", "symphonia-metadata", ] [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "sys-locale" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ "libc", ] [[package]] name = "system-deps" version = "7.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" dependencies = [ "cfg-expr", "heck", "pkg-config", "toml 0.9.12+spec-1.1.0", "version-compare", ] [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "target-features" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" [[package]] name = "target-lexicon" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "termcolor" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ "winapi-util", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl 2.0.18", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "thiserror-impl" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "tiff" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", "zune-jpeg 0.4.21", ] [[package]] name = "time" version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "js-sys", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "serde_core", "zerovec", ] [[package]] name = "tinytemplate" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" dependencies = [ "serde", "serde_json", ] [[package]] name = "tinyvec" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", "socket2", "tracing", "windows-sys 0.61.2", ] [[package]] name = "toml" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", ] [[package]] name = "toml" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] [[package]] name = "toml" version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", ] [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] [[package]] name = "toml_parser" version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] [[package]] name = "toml_write" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "transpose" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" dependencies = [ "num-integer", "strength_reduce", ] [[package]] name = "trash" version = "5.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" dependencies = [ "chrono", "libc", "log", "objc2", "objc2-foundation", "once_cell", "percent-encoding", "scopeguard", "urlencoding", "windows", ] [[package]] name = "triple_accel" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622b09ce2fe2df4618636fb92176d205662f59803f39e70d1c333393082de96c" [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "type-map" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ "rustc-hash", ] [[package]] name = "typed-path" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "tz-rs" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" [[package]] name = "uds_windows" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ "memoffset", "tempfile", "winapi", ] [[package]] name = "unic-langid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" dependencies = [ "unic-langid-impl", ] [[package]] name = "unic-langid-impl" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" dependencies = [ "serde", "tinystr", ] [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", "serde_derive", ] [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", ] [[package]] name = "v_frame" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" dependencies = [ "aligned-vec", "num-traits", "wasm-bindgen", ] [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vid_dup_finder_common" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b601345173cab95df37a54b3351a77f85a9d11429487310b6a2e49ac37bc1942" dependencies = [ "fast_image_resize 5.5.0", "image", "imageproc", "itertools 0.14.0", "rand 0.9.2", "winapi", ] [[package]] name = "vid_dup_finder_lib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bcf6135b99bca822ea095fc485066bf8c0f8788f575d6808a12619ba721b38" dependencies = [ "bitvec", "cfg-if", "enum-utils", "ffmpeg_gst_wrapper", "image", "itertools 0.14.0", "ndarray", "rand 0.9.2", "rustdct", "serde", "thiserror 2.0.18", "vid_dup_finder_common", ] [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasip3" version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-encoder" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", "wasmparser", ] [[package]] name = "wasm-metadata" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser", ] [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", ] [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wide" version = "0.7.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" dependencies = [ "bytemuck", "safe_arch", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" dependencies = [ "windows-core 0.56.0", "windows-targets 0.52.6", ] [[package]] name = "windows-core" version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" dependencies = [ "windows-implement 0.56.0", "windows-interface 0.56.0", "windows-result 0.1.2", "windows-targets 0.52.6", ] [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", "windows-link", "windows-result 0.4.1", "windows-strings", ] [[package]] name = "windows-implement" version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-interface" version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "wit-bindgen-rust-macro", ] [[package]] name = "wit-bindgen-core" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", "wit-parser", ] [[package]] name = "wit-bindgen-rust" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", ] [[package]] name = "wit-bindgen-rust-macro" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] [[package]] name = "wit-component" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser", "wit-parser", ] [[package]] name = "wit-parser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser", ] [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" dependencies = [ "tap", ] [[package]] name = "xxhash-rust" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "y4m" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "synstructure", ] [[package]] name = "zbus" version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-recursion", "async-trait", "enumflags2", "event-listener", "futures-core", "futures-lite", "hex", "libc", "ordered-stream", "rustix", "serde", "serde_repr", "tokio", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", "zvariant", ] [[package]] name = "zbus_macros" version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", ] [[package]] name = "zbus_names" version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow", "zvariant", ] [[package]] name = "zerocopy" version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "serde", "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zip" version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" dependencies = [ "aes", "bzip2", "constant_time_eq", "crc32fast", "flate2", "getrandom 0.4.1", "hmac", "indexmap", "memchr", "pbkdf2", "sha1", "time", "typed-path", "zeroize", "zopfli", ] [[package]] name = "zlib-rs" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", "log", "simd-adler32", ] [[package]] name = "zune-core" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-inflate" version = "0.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" dependencies = [ "simd-adler32", ] [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core 0.4.12", ] [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ "zune-core 0.5.1", ] [[package]] name = "zvariant" version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", "url", "winnow", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", "zvariant_utils", ] [[package]] name = "zvariant_utils" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", "winnow", ] czkawka_core-11.0.1/Cargo.toml0000644000000167511046102023000116110ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2024" rust-version = "1.92.0" name = "czkawka_core" version = "11.0.1" authors = ["Rafał Mikrut "] build = "build.rs" autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Core of Czkawka app" homepage = "https://github.com/qarmin/czkawka" readme = "README.md" license = "MIT" repository = "https://github.com/qarmin/czkawka" [features] blake_pure = ["blake3/pure"] default = [] heif = ["dep:libheif-rs"] libavif = [ "image/avif-native", "image/avif", ] libraw = ["dep:libraw-rs"] xdg_portal_trash = [ "ashpd", "tokio", ] [lib] name = "czkawka_core" path = "src/lib.rs" [[bench]] name = "hash_calculation_benchmark" path = "benches/hash_calculation_benchmark.rs" harness = false [dependencies.ashpd] version = "0.12.1" optional = true [dependencies.bincode] version = "<2.0" [dependencies.bitflags] version = "2.6" [dependencies.bk-tree] version = "0.5" [dependencies.blake3] version = "1.5" [dependencies.crc32fast] version = "1.4" [dependencies.crossbeam-channel] version = "0.5" [dependencies.deunicode] version = "1.6.2" [dependencies.directories-next] version = "2.0" [dependencies.dunce] version = "1.0.5" [dependencies.file-rotate] version = "0.8.0" [dependencies.filetime] version = "0.2.26" [dependencies.fun_time] version = "0.3" features = ["log"] [dependencies.glibc_musl_version] version = "0.1.0" [dependencies.hamming-bitwise-fast] version = "1.0" [dependencies.handsome_logger] version = "0.9" [dependencies.humansize] version = "2.1" [dependencies.i18n-embed] version = "0.16" features = [ "fluent-system", "desktop-requester", ] [dependencies.i18n-embed-fl] version = "0.10" [dependencies.image] version = "0.25" features = [ "bmp", "dds", "exr", "ff", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp", "rayon", ] default-features = false [dependencies.image_hasher] version = "3.0" features = ["fast_resize_unstable"] [dependencies.indexmap] version = "2.11" [dependencies.infer] version = "0.19" [dependencies.itertools] version = "0.14" [dependencies.jxl-oxide] version = "0.12.0" features = ["image"] [dependencies.libheif-rs] version = "2" features = [ "v1_17", "image", ] optional = true default-features = false [dependencies.libraw-rs] version = "0.0.4" optional = true [dependencies.little_exif] version = "0.6.20" [dependencies.lofty] version = "0.23" [dependencies.log] version = "0.4.22" [dependencies.log-panics] version = "2.1.0" features = ["with-backtrace"] [dependencies.lopdf] version = "0.39.0" [dependencies.mime_guess] version = "2.0" [dependencies.nom-exif] version = "2.1.0" [dependencies.once_cell] version = "1.20" [dependencies.open] version = "5.3" [dependencies.os_info] version = "3" default-features = false [dependencies.rand] version = "0.10.0" [dependencies.rawler] version = "0.7.0" [dependencies.rayon] version = "1.10" [dependencies.rust-embed] version = "8.5" features = ["debug-embed"] [dependencies.rusty-chromaprint] version = "0.3" [dependencies.serde] version = "1.0" [dependencies.serde_json] version = "1.0" [dependencies.static_assertions] version = "1.1.0" [dependencies.symphonia] version = "0.5" features = ["all"] [dependencies.tempfile] version = "3.13" [dependencies.tokio] version = "1.49.0" optional = true [dependencies.vid_dup_finder_lib] version = "0.4" [dependencies.xxhash-rust] version = "0.8" features = ["xxh3"] [dependencies.zip] version = "8.1" features = [ "aes-crypto", "bzip2", "deflate", "time", ] default-features = false [dev-dependencies.criterion] version = "0.8" features = [] default-features = false [build-dependencies.glibc_musl_version] version = "0.1.0" [build-dependencies.rustc_version] version = "0.4" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies.trash] version = "5.1" [target."cfg(windows)".dependencies.file-id] version = "0.2.2" [lints.clippy] allow_attributes = "warn" assertions_on_result_states = "warn" bool_to_int_with_if = "warn" branches_sharing_code = "warn" collapsible_else_if = "allow" collection_is_never_read = "warn" dbg_macro = "warn" debug_assert_with_mut_call = "warn" doc_broken_link = "warn" elidable_lifetime_names = "warn" empty_enum_variants_with_brackets = "warn" enum_glob_use = "warn" enum_variant_names = "allow" equatable_if_let = "warn" error_impl_error = "warn" expl_impl_clone_on_copy = "warn" explicit_into_iter_loop = "warn" explicit_iter_loop = "warn" fallible_impl_from = "warn" filter_map_next = "warn" flat_map_option = "warn" float_cmp = "warn" from_iter_instead_of_collect = "warn" ignore_without_reason = "warn" ignored_unit_patterns = "warn" implicit_clone = "warn" index_refutable_slice = "warn" indexing_slicing = "warn" invalid_upcast_comparisons = "warn" ip_constant = "warn" iter_filter_is_ok = "warn" iter_filter_is_some = "warn" iter_on_empty_collections = "warn" iter_on_single_items = "allow" iter_with_drain = "warn" large_stack_arrays = "warn" large_types_passed_by_value = "warn" literal_string_with_formatting_args = "warn" lossy_float_literal = "warn" macro_use_imports = "warn" manual_assert = "warn" manual_instant_elapsed = "warn" manual_is_variant_and = "warn" manual_let_else = "warn" manual_midpoint = "warn" manual_ok_or = "warn" map_unwrap_or = "warn" match_bool = "warn" match_same_arms = "warn" match_wildcard_for_single_variants = "warn" mut_mut = "warn" mutex_atomic = "warn" mutex_integer = "warn" needless_bitwise_bool = "warn" needless_collect = "warn" needless_continue = "warn" needless_for_each = "warn" needless_pass_by_ref_mut = "warn" needless_pass_by_value = "warn" needless_raw_strings = "warn" non_std_lazy_statics = "warn" nonstandard_macro_braces = "warn" option_as_ref_cloned = "warn" path_buf_push_overwrite = "warn" pathbuf_init_then_push = "warn" print_stderr = "warn" print_stdout = "warn" pub_underscore_fields = "warn" question_mark = "warn" range_minus_one = "warn" range_plus_one = "warn" redundant_clone = "warn" redundant_else = "warn" ref_binding_to_reference = "warn" ref_option_ref = "warn" same_functions_in_if_condition = "warn" semicolon_if_nothing_returned = "warn" set_contains_or_insert = "warn" stable_sort_primitive = "warn" string_add_assign = "warn" string_slice = "warn" suspicious_operation_groupings = "warn" suspicious_xor_used_as_pow = "warn" todo = "warn" too_many_arguments = "allow" trait_duplication_in_bounds = "warn" trivial_regex = "warn" trivially_copy_pass_by_ref = "warn" type_complexity = "allow" type_repetition_in_bounds = "warn" undocumented_unsafe_blocks = "warn" unimplemented = "warn" uninlined_format_args = "warn" unnecessary_box_returns = "warn" unnecessary_join = "warn" unnecessary_semicolon = "warn" unnecessary_wraps = "warn" unnested_or_patterns = "warn" unreachable = "allow" unused_async = "warn" unused_result_ok = "warn" unused_rounding = "warn" unused_self = "warn" unwrap_used = "warn" use_self = "warn" used_underscore_binding = "warn" useless_let_if_seq = "warn" verbose_file_reads = "warn" wildcard_imports = "warn" czkawka_core-11.0.1/Cargo.toml.orig000064400000000000000000000066741046102023000152530ustar 00000000000000[package] name = "czkawka_core" version = "11.0.1" authors = ["Rafał Mikrut "] edition = "2024" rust-version = "1.92.0" description = "Core of Czkawka app" license = "MIT" homepage = "https://github.com/qarmin/czkawka" repository = "https://github.com/qarmin/czkawka" build = "build.rs" [dependencies] humansize = "2.1" rayon = "1.10" crossbeam-channel = "0.5" # For stable iteration over hashmaps. indexmap = "2.11" # For saving/loading config files to specific directories directories-next = "2.0" # Needed by similar images image_hasher = { version = "3.0", features = ["fast_resize_unstable"] } bk-tree = "0.5" image = { version = "0.25", default-features = false, features = ["bmp", "dds", "exr", "ff", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp", "rayon"] } hamming-bitwise-fast = "1.0" # Needed by same music bitflags = "2.6" lofty = "0.23" # Needed by broken files zip = { version = "8.1", features = ["aes-crypto", "bzip2", "deflate", "time"], default-features = false } lopdf = "0.39.0" # Needed by audio similarity feature rusty-chromaprint = "0.3" symphonia = { version = "0.5", features = ["all"] } # Hashes for duplicate files blake3 = "1.5" crc32fast = "1.4" xxhash-rust = { version = "0.8", features = ["xxh3"] } tempfile = "3.13" # Video Duplicates vid_dup_finder_lib = "0.4" filetime = "0.2.26" # For extracting video properties using ffprobe CLI # https://github.com/theduke/ffprobe-rs/issues/33 #ffprobe = "0.4.0" # Saving/Loading Cache serde = "1.0" bincode = "<2.0" serde_json = "1.0" # Language i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester"] } i18n-embed-fl = "0.10" rust-embed = { version = "8.5", features = ["debug-embed"] } once_cell = "1.20" # Raw image files rawler = "0.7.0" libraw-rs = { version = "0.0.4", optional = true } jxl-oxide = { version = "0.12.0", features = ["image"] } # Checking for invalid extensions mime_guess = "2.0" infer = "0.19" # Newer version of libheif-rs, which is not compatible with Ubuntu 22.04, and works only o libheif-rs = { version = "2", optional = true, default-features = false, features = ["v1_17", "image"] } nom-exif = "2.1.0" # EXIF data cleaning little_exif = "0.6.20" dunce = "1.0.5" os_info = { version = "3", default-features = false } log = "0.4.22" handsome_logger = "0.9" fun_time = { version = "0.3", features = ["log"] } itertools = "0.14" static_assertions = "1.1.0" file-rotate = "0.8.0" open = "5.3" log-panics = { version = "2.1.0", features = ["with-backtrace"] } deunicode = "1.6.2" glibc_musl_version = "0.1.0" rand = "0.10.0" ashpd = { version = "0.12.1", optional = true } tokio = { version = "1.49.0", optional = true } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] trash = "5.1" [target.'cfg(windows)'.dependencies] file-id = "0.2.2" [build-dependencies] rustc_version = "0.4" glibc_musl_version = "0.1.0" [dev-dependencies] criterion = { version = "0.8", default-features = false, features = [] } [[bench]] name = "hash_calculation_benchmark" harness = false [features] default = [] heif = ["dep:libheif-rs"] libraw = ["dep:libraw-rs"] libavif = ["image/avif-native", "image/avif"] blake_pure = ["blake3/pure"] # Allows to use trash on Linux when using xdg-portal, needed by e.g. flatpak where normal trash access always fails # No-op on other OSes, it is slower and provides less helpful error messages xdg_portal_trash = ["ashpd", "tokio"] [lints] workspace = true czkawka_core-11.0.1/LICENSE_CC_BY_4_TEST_FILES000064400000000000000000000324001046102023000163160ustar 00000000000000All icons and audio files, in this project are licensed under Creative Commons Attribution 4.0 International (CC BY 4.0). Copyright (c) 2020-2026 Rafał Mikrut - test_resources/*/*.png - test_resources/*/*.mp3 (generated by AI) License: CC-BY-4.0 Creative Commons Attribution 4.0 International Public License . By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. . Section 1 -- Definitions. . a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. . b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. . c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. . d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. . e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. . f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. . g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. . h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. . i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. . j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. . k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. . Section 2 -- Scope. . a. License grant. . 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: . a. reproduce and Share the Licensed Material, in whole or in part; and . b. produce, reproduce, and Share Adapted Material. . 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. . 3. Term. The term of this Public License is specified in Section 6(a). . 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a) (4) never produces Adapted Material. . 5. Downstream recipients. . a. Offer from the Licensor -- Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. . b. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. . 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). . b. Other rights. . 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. . 2. Patent and trademark rights are not licensed under this Public License. . 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. . Section 3 -- License Conditions. . Your exercise of the Licensed Rights is expressly made subject to the following conditions. . a. Attribution. . 1. If You Share the Licensed Material (including in modified form), You must: . a. retain the following if it is supplied by the Licensor with the Licensed Material: . i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); . ii. a copyright notice; . iii. a notice that refers to this Public License; . iv. a notice that refers to the disclaimer of warranties; . v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; . b. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and . c. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. . 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. . 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. . 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. . Section 4 -- Sui Generis Database Rights. . Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: . a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; . b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and . c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. . For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. . Section 5 -- Disclaimer of Warranties and Limitation of Liability. . a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. . b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. . c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. . Section 6 -- Term and Termination. . a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. . b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: . 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or . 2. upon express reinstatement by the Licensor. . For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. . c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. . d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. . Section 7 -- Other Terms and Conditions. . a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. . b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. . Section 8 -- Interpretation. . a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. . b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. . c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. . d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. czkawka_core-11.0.1/LICENSE_MIT000064400000000000000000000020621046102023000140650ustar 00000000000000MIT License Copyright (c) 2020-2026 Rafał Mikrut Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.czkawka_core-11.0.1/README.md000064400000000000000000000000761046102023000136310ustar 00000000000000# Czkawka Core Core of Czkawka GUI/CLI and Krokiet projects. czkawka_core-11.0.1/benches/hash_calculation_benchmark.rs000064400000000000000000000046151046102023000216450ustar 00000000000000use std::env::temp_dir; use std::fs::File; use std::hint::black_box; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use criterion::{Criterion, criterion_group, criterion_main}; use czkawka_core::common::model::HashType; use czkawka_core::tools::duplicate::{DuplicateEntry, hash_calculation}; fn setup_test_file(size: u64) -> PathBuf { let path = temp_dir().join("test_file"); let mut file = File::create(&path).expect("Failed to create test file"); file.write_all(&vec![0u8; size as usize]).expect("Failed to write to test file"); path } fn get_file_entry(size: u64) -> DuplicateEntry { let path = setup_test_file(size); DuplicateEntry { path, modified_date: 0, size, hash: String::new(), } } fn benchmark_hash_calculation_vec(c: &mut Criterion) { let file_entry = get_file_entry(FILE_SIZE); let function_name = format!("hash_calculation_vec_file_{FILE_SIZE}_buffer_{BUFFER_SIZE}"); c.bench_function(&function_name, |b| { b.iter(|| { let mut buffer = vec![0u8; BUFFER_SIZE]; hash_calculation( black_box(&mut buffer), black_box(&file_entry), black_box(HashType::Blake3), &Arc::default(), &Arc::default(), ) .expect("Failed to calculate hash"); }); }); } fn benchmark_hash_calculation_arr(c: &mut Criterion) { let file_entry = get_file_entry(FILE_SIZE); let function_name = format!("hash_calculation_arr_file_{FILE_SIZE}_buffer_{BUFFER_SIZE}"); c.bench_function(&function_name, |b| { b.iter(|| { let mut buffer = [0u8; BUFFER_SIZE]; hash_calculation( black_box(&mut buffer), black_box(&file_entry), black_box(HashType::Blake3), &Arc::default(), &Arc::default(), ) .expect("Failed to calculate hash"); }); }); } criterion_group!(benches, benchmark_hash_calculation_vec<{16 * 1024 * 1024}, {16 * 1024}>, benchmark_hash_calculation_vec<{16 * 1024 * 1024}, {1024 * 1024}>, benchmark_hash_calculation_arr<{16 * 1024 * 1024}, {16 * 1024}>, benchmark_hash_calculation_arr<{16 * 1024 * 1024}, {1024 * 1024}>, ); criterion_main!(benches); czkawka_core-11.0.1/build.rs000064400000000000000000000051651046102023000140230ustar 00000000000000fn main() { let rust_version = match rustc_version::version_meta() { Ok(meta) => { let rust_v = meta.semver.to_string(); let rust_date = meta.commit_date.unwrap_or_default(); format!("{rust_v} ({rust_date})") } Err(_) => "".to_string(), }; println!("cargo:rustc-env=RUST_VERSION_INTERNAL={rust_version}"); if let Ok(encoded) = std::env::var("CARGO_ENCODED_RUSTFLAGS") { println!("cargo:rustc-env=UUSED_RUSTFLAGS={encoded}"); } // Get Git commit hash let git_commit = std::process::Command::new("git") .args(["rev-parse", "HEAD"]) .output() .ok() .and_then(|output| if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }) .map_or_else(|| "".to_string(), |s| s.trim().to_string()); println!("cargo:rustc-env=CZKAWKA_GIT_COMMIT={git_commit}"); // Get short Git commit hash let git_commit_short = if git_commit != "" && git_commit.len() >= 10 { git_commit.chars().take(10).collect::() } else { git_commit }; println!("cargo:rustc-env=CZKAWKA_GIT_COMMIT_SHORT={git_commit_short}"); // Commit date let git_commit_date = std::process::Command::new("git") .args(["log", "-1", "--format=%cd", "--date=format:%Y-%m-%d"]) .output() .ok() .and_then(|output| if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }) .map_or_else(|| "".to_string(), |s| s.trim().to_string()); println!("cargo:rustc-env=CZKAWKA_GIT_COMMIT_DATE={git_commit_date}"); // Official build flag if std::env::var("CZKAWKA_OFFICIAL_BUILD") == Ok("1".to_string()) { println!("cargo:rustc-env=CZKAWKA_OFFICIAL_BUILD=1"); } else { println!("cargo:rustc-env=CZKAWKA_OFFICIAL_BUILD=0"); } let using_cranelift = std::env::var("CARGO_PROFILE_RELEASE_CODEGEN_UNITS") == Ok("1".to_string()) || std::env::var("CARGO_PROFILE_DEV_CODEGEN_BACKEND") == Ok("cranelift".to_string()); if using_cranelift { println!("cargo:rustc-env=USING_CRANELIFT=1"); } if cfg!(target_os = "linux") { if let Ok(ver) = glibc_musl_version::get_os_libc_versions() { println!("cargo:rustc-env=CZKAWKA_LIBC_VERSIONS={ver}"); } if cfg!(target_env = "gnu") { println!("cargo:rustc-env=CZKAWKA_LIBC=glibc"); } else if cfg!(target_env = "musl") { println!("cargo:rustc-env=CZKAWKA_LIBC=musl"); } else { println!("cargo:rustc-env=CZKAWKA_LIBC=unknown"); } } } czkawka_core-11.0.1/data/com.github.qarmin.czkawka.desktop000064400000000000000000000027611046102023000216400ustar 00000000000000[Desktop Entry] Categories=System;FileTools Exec=czkawka_gui Icon=com.github.qarmin.czkawka StartupWMClass=czkawka_gui Terminal=false TryExec=czkawka_gui Type=Application Name=Czkawka Name[it]=Singhiozzo Name[pt_BR]=Comparador de Arquivos Duplicados Czkawka Comment=Multi functional app to clean OS which allow to find duplicates, empty folders, similar files etc. Comment[it]=Programma multifunzionale per pulire il sistema, che permette di trovare file duplicati, cartelle vuote, file simili, ecc... Comment[pt_BR]=O ‘Czkawka’, que em idioma português significa ‘soluço’, é um programa que permite comparar e encontrar (buscar ou localizar ou pesquisar) arquivos duplicados, pastas ou diretórios vazios, arquivos equivalentes (semelhantes ou similares), criar arquivos de verificação da integridade (hash) de vários tipos de arquivos diferentes (por exemplo, arquivos de imagens, músicas, vídeos, etc.), mover para a lixeira os arquivos duplicados, excluir permanentemente os arquivos duplicados, etc., possibilita realizar a limpeza do sistema operacional Comment[zh_CN]=可用于清理文件副本、空文件夹、相似文件等的系统清理工具 Comment[zh_TW]=可用於清理重複檔案、空資料夾、相似檔案等的系統清理工具 Keywords=Hiccup;duplicate;same;similar;cleaner;copy;copies;compare;files; Keywords[pt_BR]=czkawka;hiccup;soluço;arquivos;ficheiros;duplicado;igual;iguais;equivalentes;similares;semelhantes;limpar;limpeza;mais limpo;cópia;cópias;comparar;pastas; czkawka_core-11.0.1/data/com.github.qarmin.czkawka.metainfo.xml000064400000000000000000000031151046102023000225620ustar 00000000000000 com.github.qarmin.czkawka Czkawka Multi functional app to find duplicates, empty folders, similar images, broken files etc. CC0-1.0 MIT

Czkawka is simple, fast and easy to use app to remove unnecessary files from your computer.

com.github.qarmin.czkawka.desktop https://user-images.githubusercontent.com/41945903/147875238-7f82fa27-c6dd-47e7-87ed-e253fb2cbc3e.png https://user-images.githubusercontent.com/41945903/147875239-bcf9776c-885d-45ac-ba82-5a426d8e1647.png https://user-images.githubusercontent.com/41945903/147875243-e654e683-37f7-46fa-8321-119a4c5775e7.png Rafał Mikrut Rafał Mikrut https://github.com/qarmin/czkawka https://github.com/qarmin/czkawka/issues https://github.com/sponsors/qarmin https://crowdin.com/project/czkawka
czkawka_core-11.0.1/data/icons/com.github.qarmin.czkawka-symbolic.svg000064400000000000000000000120161046102023000237120ustar 00000000000000 czkawka_core-11.0.1/data/icons/com.github.qarmin.czkawka.Devel.svg000064400000000000000000000421761046102023000231430ustar 00000000000000 czkawka_core-11.0.1/data/icons/com.github.qarmin.czkawka.svg000064400000000000000000000240101046102023000220700ustar 00000000000000 czkawka_core-11.0.1/data/icons/io.github.qarmin.krokiet.svg000064400000000000000000000735171046102023000217560ustar 00000000000000 czkawka_core-11.0.1/data/io.github.qarmin.krokiet.desktop000064400000000000000000000005471046102023000215060ustar 00000000000000[Desktop Entry] Categories=System;FileTools Exec=krokiet Icon=io.github.qarmin.krokiet StartupWMClass=krokiet Terminal=false TryExec=krokiet Type=Application Name=Krokiet Comment=Krokiet - multi-functional app to find duplicates, empty folders, similar files and many more. Keywords=Krokiet;duplicate;same;similar;cleaner;copy;copies;compare;files;krokiet czkawka_core-11.0.1/data/io.github.qarmin.krokiet.metainfo.xml000064400000000000000000000034541046102023000224360ustar 00000000000000 io.github.qarmin.krokiet Krokiet Multi functional app to find duplicates, similar images and many more CC0-1.0 GPL-3.0-only

Krokiet is a multi functional app that finds:

  • Duplicates
  • Similar images
  • Similar audio files
  • Similar videos
  • Empty folders
  • Empty files
  • Broken files
  • Temporary files
  • Big files
  • Invalid symlinks
  • Bad names
  • Videos that can be optimized/cropped
  • Exif tags
io.github.qarmin.krokiet.desktop https://github.com/user-attachments/assets/720e98c3-598a-41aa-a04b-0c0c1d8a28e6 https://github.com/user-attachments/assets/c95e51bf-1ae0-49ec-af92-0195efc98e5d https://github.com/user-attachments/assets/4fe7bec3-4d67-48bb-91bc-91e7d3b82bdc Rafał Mikrut https://github.com/qarmin/czkawka https://github.com/qarmin/czkawka/issues https://github.com/sponsors/qarmin https://crowdin.com/project/czkawka
czkawka_core-11.0.1/i18n/ar/czkawka_core.ftl000064400000000000000000000271151046102023000167100ustar 00000000000000# Core core_similarity_original = الأصل core_similarity_very_high = عالية جدا core_similarity_high = مرتفع core_similarity_medium = متوسط core_similarity_small = صغير core_similarity_very_small = صغير جدا core_similarity_minimal = الحد الأدنى core_cannot_open_dir = لا يمكن فتح dir { $dir }، السبب { $reason } core_cannot_read_entry_dir = لا يمكن قراءة الإدخال في dir { $dir }، السبب { $reason } core_cannot_read_metadata_dir = لا يمكن قراءة البيانات الوصفية في dir { $dir }، السبب { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = يبدو أن الملف { $name } قد تم تعديله قبل يونكس Epoch core_folder_modified_before_epoch = يبدو أن المجلد { $name } قد تم تعديله قبل يونكس Epoch core_file_no_modification_date = غير قادر على الحصول على تاريخ التعديل من الملف { $name }، السبب { $reason } core_folder_no_modification_date = غير قادر على الحصول على تاريخ التعديل من المجلد { $name }، السبب { $reason } core_cannot_start_scan_no_included_paths = لا يمكن بدء المسح، لأن لا توجد مسارات مضمنة core_skip_exist_check_all_included_paths_nonexistent = لا يمكن بدء المسح، لأن جميع المسارات المدرجة غير موجودة core_missing_no_chosen_included_path = لم يتم اختيار مسار مضمن صالح (كانت المسارات المضمنة المستبعدة تستبعد جميع المسارات المضمنة) core_reference_included_paths_same = لا يمكن بدء المسح حيث تكون جميع المسارات المدرجة الصالحة أيضًا مسارات مرجعية، حاول التحقق من الصحة أو تعطيل المسارات المرجعية core_path_must_exists = يجب أن يكون المسار المقدم موجودًا، مع تجاهل { $path } core_must_be_directory_or_file = يجب أن يشير المسار المقدم إلى دليل أو ملف صالح، مع تجاهل { $path } core_excluded_paths_pointless_slash = باستثناء / لا معنى له، لأنه يعني عدم فحص الملفات core_paths_unable_to_get_device_id = تعذر الحصول على معرف الجهاز من المجلد { $path } core_needs_allowed_extensions_limited_by_tool = لا يمكن بدء المسح، عندما تم استبعاد جميع الإضافات المتاحة في هذا الأداة ({ $extensions }) من المسح core_needs_allowed_extensions = لا يمكن بدء المسح، عندما تم استبعاد جميع الإضافات من المسح core_needs_to_set_at_least_one_broken_option = لا يمكن بدء المسح، عندما لا يتم تعيين خيار "معطل" للمسح core_needs_to_set_at_least_one_bad_name_option = لا يمكن بدء المسح، عندما لا يتم تعيين خيار الاسم السيئ للبحث عنه core_ffmpeg_not_found = لا يمكن العثور على تثبيت مناسب لـ FFmpeg أو FFprobe. هذه برامج خارجية يجب تثبيتها يدويًا. core_ffmpeg_not_found_windows = تأكد من أن ffmpeg.exe و ffprobe.exe متوفرتان في PATH أو يتم وضعهما مباشرة في نفس المجلد مع التطبيق القابل للتنفيذ core_invalid_symlink_infinite_recursion = التكرار اللامتناهي core_invalid_symlink_non_existent_destination = ملف الوجهة غير موجود core_messages_limit_reached_characters = تجاوز عدد الرسائل الحد الأقصى المحدد ({ $current }/{ $limit } حرفا)، لذا تم اقتطاع الخروج. لقراءة الإخراج الكامل، قم بتعطيل خيار الحد في الإعدادات. core_messages_limit_reached_lines = تجاوز عدد الرسائل الحد الأقصى المحدد ({ $current }/{ $limit } سطر)، لذا تم اقتطاع الخروج. لقراءة الإخراج الكامل، قم بتعطيل خيار الحد في الإعدادات. core_error_moving_to_trash = خطأ أثناء نقل "{ $file }" إلى سلة المحذوفات: { $error } core_error_removing = خطأ أثناء حذف "{ $file }": { $error } core_no_similarity_method_selected = لا يمكن العثور على ملفات موسيقية مماثلة بدون طريقة تشابه محددة core_failed_to_spawn_command = فشل أمر الإطلاق: { $reason } core_failed_to_check_process_status = فشل التحقق من حالة العملية: { $reason } core_failed_to_wait_for_process = فشل الانتظار للعملية: { $reason } core_failed_to_read_video_properties = فشل في قراءة خصائص الفيديو: { $reason } core_failed_to_execute_ffmpeg = فشل تنفيذ ffmpeg: { $reason } core_ffmpeg_failed_with_status = فشل ffmpeg مع الحالة { $status }: { $stderr } (الأمر: { $command }) core_failed_to_load_image_frame = فشل تحميل إطار الصورة: { $reason } core_failed_to_extract_frame = فشل استخراج الإطار في { $time } ثانية من "{ $file }": { $reason } core_failed_to_save_thumbnail = فشل حفظ썸 فين لـ "{ $file }": { $reason } core_failed_get_frame_at_timestamp = فشل في الحصول على الإطار في الطابع الزمني { $timestamp } من "{ $file }": { $reason } core_failed_get_frame_from_file = فشل في الحصول على الإطار من "{ $file }" في الطابع الزمني { $timestamp }: { $reason } core_invalid_crop_rectangle = غير صالح مستطيل المحصول: يسار={ $left }، أعلى={ $top }، يمين={ $right }، أسفل={ $bottom } core_failed_to_crop_video_file = فشل قص الفيديو "{ $file }": { $reason } core_cropped_video_not_created = الملف المرئي المقتطع لم يتم إنشاؤه: { $temp } core_unable_check_hash_of_file = تعذر التحقق من تجزئة الملف "{ $file }"، والسبب { $reason } core_error_checking_hash_of_file = حدث خطأ عند التحقق من تجزئة الملف "{ $file }"، السبب { $reason } core_image_zero_dimensions = الصورة لها عرض أو ارتفاع يساوي صفر "{ $path }" core_image_open_failed = لا يمكن فتح ملف الصورة "{ $path }": { $reason } core_not_directory_remove = محاولة إزالة المجلد "{ $path }" والذي ليس مجلدًا core_cannot_read_directory = لا يمكن قراءة الدليل "{ $path }" core_cannot_read_entry_from_directory = لا يمكن قراءة الإدخال من الدليل "{ $path }" core_folder_contains_file_inside = المجلد يحتوي على الملف "{ $entry }" داخل "{ $folder }" core_unknown_directory_entry = تعذر تحديد نوع الملف لإدخال الدليل "{ $entry }" داخل "{ $path }" core_video_width_exceeds_limit = عرض الفيديو { $width } يتجاوز الحد الأقصى لـ { $limit } core_video_height_exceeds_limit = فيديو الارتفاع { $height } يتجاوز الحد الأقصى لـ { $limit } core_failed_to_process_video = فشل معالجة ملف الفيديو { $file }: { $reason } core_optimized_file_larger = الملف المحسن { $optimized } (الحجم: { $new_size }) ليس أصغر من الأصلي { $original } (الحجم: { $original_size }) core_unknown_codec = ترميز غير معروف: { $codec } core_invalid_video_optimizer_mode = وضع مُحسِّن الفيديو غير صالح: '{ $mode }'. القيم المسموح بها: transcode, crop core_folder_does_not_exist = المجلد غير موجود: { $folder } core_path_not_directory = المسار ليس مجلدًا: { $folder } core_test_error_for_folder = خطأ في الاختبار للمجلد: { $folder } core_unknown_exif_tag_group = مجموعة بيانات EXIF غير المعروفة: { $tag } core_error_comparing_fingerprints = خطأ أثناء مقارنة بصمات الأصابع: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = فشل إنشاء صورة مصغرة لـ "{ $file }": الصور المستخرجة لها أبعاد مختلفة core_failed_to_generate_thumbnail = فشل إنشاء الصورة المصغرة لـ "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = فشل استخراج الإطار في { $time } ثانية من "{ $file }": { $reason } core_video_file_does_not_exist = الملف المرئي غير موجود (يمكن حذفه بين المسح/الخطوات اللاحقة): "{ $path }" core_image_too_large = الصورة كبيرة جداً ({ $width }x{ $height }) - أكثر من المدعوم { $max } بكسل core_failed_to_get_video_metadata = فشل الحصول على بيانات الفيديو للملف "{ $file }": { $reason } core_failed_to_get_video_codec = فشل الحصول على ترميز الفيديو للملف "{ $file }" core_failed_to_get_video_duration = تعذر الحصول على مدة الفيديو للملف "{ $file }" core_failed_to_get_video_dimensions = فشل الحصول على أبعاد الفيديو للملف "{ $file }" core_frame_dimensions_mismatch = أبعاد الإطار لعلامة الوقت { $timestamp } لا تتطابق مع أبعاد الإطار الأول ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = تعذر تحميل البيانات من ملف التخزين المؤقت { $file }، والسبب { $reason } core_failed_to_load_data_from_json_cache = تعذر تحميل البيانات من ملف ذاكرة التخزين المؤقت JSON { $file}، والسبب { $reason} core_failed_to_replace_with_optimized = فشل استبدال الملف "{ $file }" بإصدار مُحسّن: { $reason } core_failed_to_write_data_to_cache = لا يمكن كتابة البيانات إلى ملف التخزين المؤقت "{ $file }"، والسبب { $reason } core_properly_saved_cache_entries = تم حفظها بشكل صحيح في الملف { $count } إدخالات ذاكرة تخزين مؤقت. core_video_processing_stopped_by_user = تم إيقاف معالجة الفيديو بواسطة المستخدم core_thumbnail_generation_stopped_by_user = إنشاء الصور المصغرة توقف بواسطة المستخدم core_failed_to_optimize_video = فشل تحسين الفيديو "{ $file }": { $reason } core_failed_to_crop_video = فشل قص الفيديو "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = فشل الحصول على بيانات التعريف للملف المحسن "{ $file }": { $reason } core_cannot_create_config_folder = لا يمكن إنشاء مجلد الإعدادات "{ $folder }"، والسبب { $reason } core_cannot_create_cache_folder = لا يمكن إنشاء مجلد ذاكرة التخزين المؤقت "{ $folder }"، والسبب { $reason } core_cannot_create_or_open_cache_file = لا يمكن إنشاء أو فتح ملف التخزين المؤقت "{ $file }"، والسبب { $reason } core_cannot_set_config_cache_path = لا يمكن تعيين مسار التكوين/التخزين المؤقت - لن يتم استخدام التكوين والتخزين المؤقت. core_invalid_extension_contains_space = { $extension } ليس امتدادًا صالحًا لأنه يحتوي على مساحة فارغة داخلية core_invalid_extension_contains_dot = { $extension } ليس امتدادًا صالحًا لأنه يحتوي على نقطة داخلية czkawka_core-11.0.1/i18n/bg/czkawka_core.ftl000064400000000000000000000323711046102023000166760ustar 00000000000000# Core core_similarity_original = Оригинален core_similarity_very_high = Много високо core_similarity_high = Високо core_similarity_medium = Средно core_similarity_small = Малко core_similarity_very_small = Много малък core_similarity_minimal = Минимално core_cannot_open_dir = Не може да се отвори папка { $dir }, причината е { $reason } core_cannot_read_entry_dir = Не може да се прочете папка { $dir }, причината е { $reason } core_cannot_read_metadata_dir = Не могат да се прочетат мета-данните в папка { $dir }, причината е { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = File { $name } seems to have been modified before the Unix Epoch core_folder_modified_before_epoch = Folder { $name } seems to have been modified before the Unix Epoch core_file_no_modification_date = Невъзможно е да се извлече дата на промяна от файл { $name }, причината е { $reason } core_folder_no_modification_date = Невъзможно е да се извлече дата на промяна от папка { $name }, причината е { $reason } core_cannot_start_scan_no_included_paths = Не може да се стартира сканирането, защото няма включени пътища core_skip_exist_check_all_included_paths_nonexistent = Не може да се стартира сканирането, защото всички включени пътища не съществуват core_missing_no_chosen_included_path = Няма избран валиден път, включен (изключените пътища биха могли да са изключили всички включени пътища) core_reference_included_paths_same = Не може да се стартира сканиране, където всички валидни включени пътища са също и препратени пътища, опитайте се да валидирате или да деактивирате препратените пътища core_path_must_exists = Предоставеният път трябва да съществува, пренебрегвайки { $path } core_must_be_directory_or_file = Предоставеният път трябва да сочи валиден директория или файл, пренебрегвайки { $path } core_excluded_paths_pointless_slash = Изключването / е безсмислено, защото означава, че няма да бъдат сканирани файлове core_paths_unable_to_get_device_id = Не може да се получи идентификатор на устройството от папката { $path } core_needs_allowed_extensions_limited_by_tool = Не може да се стартира сканирането, когато всички налични разширения в този инструмент ({ $extensions }) бяха изключени от сканирането core_needs_allowed_extensions = Не може да се стартира сканирането, когато всички разширения са били изключени от сканирането core_needs_to_set_at_least_one_broken_option = Не може да се стартира сканиране, когато не е зададена опция за сканиране на повредени core_needs_to_set_at_least_one_bad_name_option = Не може да се стартира сканирането, когато не е зададена опцията за лошо име за сканиране core_ffmpeg_not_found = Не може да се намери подходяща инсталация на FFmpeg или FFprobe. Те са външни програми, които трябва да бъдат инсталирани ръчно. core_ffmpeg_not_found_windows = Бъдете сигурни, че ffmpeg.exe и ffprobe.exe са налични в PATH или са разположени напрямок в същата папка като изпълнимият файл на апplикацията core_invalid_symlink_infinite_recursion = Безкрайна рекурсия core_invalid_symlink_non_existent_destination = Несъществуващ дестинационен файл core_messages_limit_reached_characters = Number of messages exceeded the set limit ({ $current }/{ $limit } characters), so the output was truncated. To read the full output, disable the limiting option in settings. core_messages_limit_reached_lines = Number of messages exceeded the set limit ({ $current }/{ $limit } lines), so the output was truncated. To read the full output, disable the limiting option in settings. core_error_moving_to_trash = Грешка при преместване на "{ $file }" в кош: { $error } core_error_removing = Огледална грешка при изтриване на "{ $file }": { $error } core_no_similarity_method_selected = Не можете да намерите подобри музикални файлове без избран метод за сличност core_failed_to_spawn_command = Не успя да стартира командата: { $reason } core_failed_to_check_process_status = Не успях да проверя статуса на процеса: { $reason } core_failed_to_wait_for_process = Не успях да изчакам процеса: { $reason } core_failed_to_read_video_properties = Не успях да прочета свойствата на видеото: { $reason } core_failed_to_execute_ffmpeg = Не успях да изпълня ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg се провали с статус { $status }: { $stderr } (команда: { $command }) core_failed_to_load_image_frame = Не успях да заредя кадъра на изображението: { $reason } core_failed_to_extract_frame = Не успях да извлека кадъра на { $time } секунди от "{ $file }": { $reason } core_failed_to_save_thumbnail = Не успях да запазя миниатюра за "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Не успях да получа кадъра в момента { $timestamp } от "{ $file }": { $reason } core_failed_get_frame_from_file = Не успях да получа кадъра от "{ $file }" в момента { $timestamp }: { $reason } core_invalid_crop_rectangle = Невалиден правоъгълник на отрязък: ляво={ $left }, горно={ $top }, дясно={ $right }, долно={ $bottom } core_failed_to_crop_video_file = Не успях да изрежа видео файла "{ $file }": { $reason } core_cropped_video_not_created = Отреденият видео файл не беше създаден: { $temp } core_unable_check_hash_of_file = Не може да се провери хеша на файла "{ $file }", причина { $reason } core_error_checking_hash_of_file = Грешка възникна при проверка на хеша на файла "{ $file }", причина { $reason } core_image_zero_dimensions = Изображението има нулева ширина или височина "{ $path }" core_image_open_failed = Не може да се отвори файла с изображение "{ $path }": { $reason } core_not_directory_remove = Опитвам да премахна папката "{ $path }" която не е директория core_cannot_read_directory = Не може да се прочете директорията "{ $path }" core_cannot_read_entry_from_directory = Не мога да прочета записа от директорията "{ $path }" core_folder_contains_file_inside = Папка съдържа файл "{ $entry }" в "{ $folder }" core_unknown_directory_entry = Не може да се определи типа на файла на входа на директорията "{ $entry }" в "{ $path }" core_video_width_exceeds_limit = Видео ширина { $width } надхвърля лимита на { $limit } core_video_height_exceeds_limit = Видео височина { $height } надхвърля лимита от { $limit } core_failed_to_process_video = Не успях да обработя видео файла { $file }: { $reason } core_optimized_file_larger = Оптимизиран файл { $optimized } (размер: { $new_size }) не е по-малък от оригиналния { $original } (размер: { $original_size }) core_unknown_codec = Неизвестен кодек: { $codec } core_invalid_video_optimizer_mode = Невалиден режим на оптимизация на видео: '{ $mode }'. Разрешени стойности: transcode, crop core_folder_does_not_exist = Папка не съществува: { $folder } core_path_not_directory = Пътят не е директория: { $folder } core_test_error_for_folder = Грешка при тест за папка: { $folder } core_unknown_exif_tag_group = Неизвестна EXIF група таг: { $tag } core_error_comparing_fingerprints = Грешка при сравняване на пръстови отпечатъци: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Не успях да генерирам миниатюра за "{ $file }": извлечените кадри имат различни размери core_failed_to_generate_thumbnail = Не успях да генерирам миниатюра за "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Не успях да извлека кадъра на { $time } секунди от "{ $file }": { $reason } core_video_file_does_not_exist = Видео файлът не съществува (може да бъде премахнат между сканирането/по-късните стъпки): "{ $path }" core_image_too_large = Изображението е твърде голямо ({ $width }x{ $height }) - повече от поддържаните { $max } пиксела core_failed_to_get_video_metadata = Не успях да получа метаданните на видеото за файла "{ $file }": { $reason } core_failed_to_get_video_codec = Не успях да получа видео кодек за файла "{ $file }" core_failed_to_get_video_duration = Не успях да получа продължителността на видеото за файла "{ $file }" core_failed_to_get_video_dimensions = Не успях да получа размерите на видеото за файла "{ $file }" core_frame_dimensions_mismatch = Размерите на кадъра за времето { $timestamp } не съвпадат с размерите на първия кадър ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Не успях да заредя данните от кеш файла { $file }, причина { $reason } core_failed_to_load_data_from_json_cache = Не успях да заредя данните от JSON кеш файла { $file }, причина { $reason } core_failed_to_replace_with_optimized = Не успях да заменя файла "{ $file }" с оптимизираната версия: { $reason } core_failed_to_write_data_to_cache = Не може да се запише данни към кеш файла "{ $file }", причина { $reason } core_properly_saved_cache_entries = Правилно запазени в файл { $count } кеш записи. core_video_processing_stopped_by_user = Видео обработката беше спряна от потребителя core_thumbnail_generation_stopped_by_user = Генерирането на миниатюри беше спряно от потребителя core_failed_to_optimize_video = Не успях да оптимизирам видео "{ $file }": { $reason } core_failed_to_crop_video = Не успя да се изреже видеото "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Не успях да получа метаданните на оптимизирания файл "{ $file }": { $reason } core_cannot_create_config_folder = Не може да се създаде папка с конфигурация "{ $folder }", причина { $reason } core_cannot_create_cache_folder = Не може да се създаде кешираща папка "{ $folder }", причина { $reason } core_cannot_create_or_open_cache_file = Не може да се създаде или отвори кеширания файл "{ $file }", причина { $reason } core_cannot_set_config_cache_path = Не може да се зададе път към config/cache - config и cache няма да бъдат използвани. core_invalid_extension_contains_space = { $extension } не е валиден разширение, защото съдържа празно пространство вътре core_invalid_extension_contains_dot = { $extension } не е валиден разширение, защото съдържа точка вътре czkawka_core-11.0.1/i18n/cs/czkawka_core.ftl000064400000000000000000000230601046102023000167060ustar 00000000000000# Core core_similarity_original = Originál core_similarity_very_high = Velmi vysoká core_similarity_high = Vysoká core_similarity_medium = Střední core_similarity_small = Malá core_similarity_very_small = Velmi malá core_similarity_minimal = Minimální core_cannot_open_dir = Nelze otevřít adresář { $dir }, důvod { $reason } core_cannot_read_entry_dir = Nelze načíst záznam v adresáři { $dir }, důvod { $reason } core_cannot_read_metadata_dir = Nelze načíst metadata v adresáři { $dir }, důvod { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Soubor { $name } se zdá být před Unix Epoch upraven core_folder_modified_before_epoch = Složka { $name } se zdá být upravena před Unix Epoch core_file_no_modification_date = Nelze získat datum úpravy ze souboru { $name }, důvod { $reason } core_folder_no_modification_date = Nelze získat datum úpravy ze složky { $name }, důvod { $reason } core_cannot_start_scan_no_included_paths = Nemožno spustit skenování, protože nejsou zahrnuty žádné cesty core_skip_exist_check_all_included_paths_nonexistent = Nemožno zahájit skenování, protože všechny zahrnuté cesty neexistují core_missing_no_chosen_included_path = Neosáhlý zahrnutý cíl nebyl vybrán (vyloučený cesty mohly vyloučit všechny zahrnuté cesty) core_reference_included_paths_same = Nelze spustit sken, kde jsou všechny platné zahrnuté cesty také odkazované cesty, zkuste ověřit nebo vypnout odkazované cesty core_must_be_directory_or_file = Zadaná cesta musí ukazovat na platný adresář nebo soubor, ignoruje { $path } core_excluded_paths_pointless_slash = Vyloučení / je zbytečné, protože to znamená, že nebude naskenováno žádných souborů core_paths_unable_to_get_device_id = Nemožno získat ID zařízení z adresáře { $path } core_needs_allowed_extensions_limited_by_tool = Nelze spustit sken, když byly všechny dostupné rozšíření v tomto nástroji ({ $extensions }) vyloučeny ze skenu core_needs_allowed_extensions = Nedaří se spustit sken, když byly všechny rozšíření vyloučeny ze skenu core_needs_to_set_at_least_one_broken_option = Nemožno spustit sken, ak nie je nastavená možnosť zlomeného skenovania core_needs_to_set_at_least_one_bad_name_option = Nemožné spustit sken, pokud není nastavená možnost špatného jména pro skenování core_ffmpeg_not_found = Nemohu najít správnou instalaci FFmpeg nebo FFprobe. Jedná se o externí programy, které je třeba nainstalovat ručně. core_ffmpeg_not_found_windows = Ujistěte se, že ffmpeg.exe a ffprobe.exe jsou k dispozici v PATH nebo jsou umístěny přímo ve stejné složce jako spustitelný soubor aplikace core_invalid_symlink_infinite_recursion = Nekonečná rekurze core_invalid_symlink_non_existent_destination = Neexistující cílový soubor core_messages_limit_reached_characters = Počet zpráv překročil nastavený limit ({ $current }/{ $limit } znaků), takže výstup byl zkrácen. Chcete-li číst celý výstup, zakažte v nastavení omezovací možnost. core_messages_limit_reached_lines = Počet zpráv překročil nastavený limit ({ $current }/{ $limit } řádky), takže výstup byl zkrácen. Chcete-li číst celý výstup, zakažte v nastavení omezovací možnost. core_error_moving_to_trash = Chyba při přesouvání "{ $file }" do koše: { $error } core_error_removing = Chyba při odstraňování "{ $file }": { $error } core_no_similarity_method_selected = Nemůže najít podobné hudební soubory bez vybrané metody podobnosti core_failed_to_spawn_command = Selhalo spuštění příkazu: { $reason } core_failed_to_wait_for_process = Selhalo čekání na proces: { $reason } core_failed_to_read_video_properties = Selhalo načtení vlastností videa: { $reason } core_failed_to_execute_ffmpeg = Selhalo provedení ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg selhal s stavem { $status }: { $stderr } (příkaz: { $command }) core_failed_to_load_image_frame = Selhalo načtení snímku obrazu: { $reason } core_failed_to_extract_frame = Selhalo načtení snímku v { $time } sekundách z "{ $file }": { $reason } core_failed_to_save_thumbnail = Selhalo uložení miniatury pro "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Selhalo získání snímku v časové značce { $timestamp } z "{ $file }": { $reason } core_failed_get_frame_from_file = Selhalo získání snímku z "{ $file }" v časové značce { $timestamp }: { $reason } core_invalid_crop_rectangle = Neplatná obdélníková oblast pro zrání: levý={ $left }, horní={ $top }, pravý={ $right }, dolní={ $bottom } core_failed_to_crop_video_file = Selhalo oříznutí video souboru "{ $file }": { $reason } core_cropped_video_not_created = Zkrácený video soubor nebyl vytvořen: { $temp } core_unable_check_hash_of_file = Nemožno zkontrolovat soubor "{ $file }", důvod { $reason } core_error_checking_hash_of_file = Chyba nastala při kontrole souhrnu souboru "{ $file }", důvod { $reason } core_image_zero_dimensions = Obraz má nulovou šířku nebo výšku "{ $path }" core_image_open_failed = Nemožno otevřít soubor s obrázkem "{ $path }": { $reason } core_not_directory_remove = Pokouším se odstranit složku "{ $path }" která není adresář core_cannot_read_directory = Nelze číst adresář "{ $path }" core_cannot_read_entry_from_directory = Nelze přečíst záznam z adresáře "{ $path }" core_folder_contains_file_inside = Složka obsahuje soubor "{ $entry }" uvnitř "{ $folder }" core_unknown_directory_entry = Nemožno určit typ súboru záznamu adresára "{ $entry }" v "{ $path }" core_video_width_exceeds_limit = Video šířka { $width } překračuje limit { $limit } core_video_height_exceeds_limit = Video výška { $height } překračuje limit { $limit } core_failed_to_process_video = Selhalo zpracování video souboru { $file }: { $reason } core_optimized_file_larger = Optimalizovaný soubor { $optimized } (velikost: { $new_size }) není menší než originální { $original } (velikost: { $original_size }) core_unknown_codec = Neznámý kodek: { $codec } core_invalid_video_optimizer_mode = Neplatý režim optimalizátoru videa: '{ $mode }'. Umožněné hodnoty: transkodovat, oříznout core_folder_does_not_exist = Složka neexistuje: { $folder } core_path_not_directory = Cesta není adresář: { $folder } core_test_error_for_folder = Test chyba pro složku: { $folder } core_unknown_exif_tag_group = Neznámá EXIF skupina značek: { $tag } core_error_comparing_fingerprints = Chyba při porovnávání otisků prstů: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Selhalo generování miniatury pro "{ $file }": extrahované snímky mají různé rozměry core_failed_to_generate_thumbnail = Selhalo při generování miniatury pro "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Selhalo načtení snímku v { $time } sekundách z "{ $file }": { $reason } core_video_file_does_not_exist = Video soubor neexistuje (může být odstraněn mezi skenem/pozdějšími kroky): "{ $path }" core_image_too_large = Obraz je příliš velký ({ $width }x{ $height }) - více než podporovaných { $max } pixelů core_failed_to_get_video_metadata = Selhalo načtení metadat videa pro soubor "{ $file }": { $reason } core_failed_to_get_video_codec = Selhalo načtení video kódu pro soubor "{ $file }" core_failed_to_get_video_duration = Selhalo načtení délky videa pro soubor "{ $file }" core_failed_to_get_video_dimensions = Selhalo načtení rozměrů souboru "{ $file }" core_frame_dimensions_mismatch = Rozměry snímku pro časové razítko { $timestamp } se neshodují s rozměry prvního snímku ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Nepodařilo se načíst data z mezipaměťového souboru { $file }, důvod { $reason } core_failed_to_load_data_from_json_cache = Nepodařilo se načíst data z JSON mezipaměťového souboru { $file }, důvod { $reason } core_failed_to_replace_with_optimized = Selhalo při nahrazení souboru "{ $file }" optimalizovanou verzí: { $reason } core_failed_to_write_data_to_cache = Nelze zapisovat data do souboru dočasné paměti "{ $file }", důvod { $reason } core_properly_saved_cache_entries = Správně uloženo do souboru { $count } políček mezipaměti. core_video_processing_stopped_by_user = Video zpracování bylo uživatelem zastaveno core_thumbnail_generation_stopped_by_user = Generování miniatur bylo zastaveno uživatelem core_failed_to_optimize_video = Selhalo při optimalizaci videa "{ $file }": { $reason } core_failed_to_crop_video = Selhalo oříznutí videa "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Selhalo načtení metadat optimalizovaného souboru "{ $file }": { $reason } core_cannot_create_config_folder = Nemožno vytvořit složku „{ $folder }“, důvod { $reason } core_cannot_create_cache_folder = Nemůže být vytvořena složka pro ukládání "{ $folder }", důvod { $reason } core_cannot_create_or_open_cache_file = Nemožno vytvořit nebo otevřít mezipaměťový soubor "{ $file }", důvod { $reason } core_cannot_set_config_cache_path = Nelze nastavit cestu k souboru s konfigurací/cache - konfigurace a cache nebude použity. core_invalid_extension_contains_space = { $extension } není platná přípona, protože obsahuje prázdné místo uvnitř core_invalid_extension_contains_dot = { $extension } není platná přípona, protože obsahuje tečku uvnitř core_path_must_exists = Zadaná cesta musí existovat, bez ohledu na { $path } core_failed_to_check_process_status = Nepodařilo se zkontrolovat stav procesu: { $reason }czkawka_core-11.0.1/i18n/de/czkawka_core.ftl000064400000000000000000000242631046102023000166770ustar 00000000000000# Core core_similarity_original = Original core_similarity_very_high = Sehr Hoch core_similarity_high = Hoch core_similarity_medium = Mittel core_similarity_small = Klein core_similarity_very_small = Sehr klein core_similarity_minimal = Minimalistisch core_cannot_open_dir = Verzeichnis { $dir } kann nicht geöffnet werden, Grund { $reason } core_cannot_read_entry_dir = Kann Eintrag in Verzeichnis { $dir } nicht lesen, Grund { $reason } core_cannot_read_metadata_dir = Metadaten können in Verzeichnis { $dir } nicht gelesen werden, Grund { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Datei { $name } scheint vor der Unix-Epoche geändert worden zu sein core_folder_modified_before_epoch = Ordner { $name } scheint vor der Unix-Epoche geändert worden zu sein core_file_no_modification_date = Konnte das Änderungsdatum von Datei { $name } nicht abrufen, Grund { $reason } core_folder_no_modification_date = Konnte das Änderungsdatum aus dem Ordner { $name } nicht abrufen, Grund { $reason } core_cannot_start_scan_no_included_paths = Kann den Scan nicht starten, da keine enthaltenen Pfade vorhanden sind core_skip_exist_check_all_included_paths_nonexistent = Kann den Scan nicht starten, da alle enthaltenen Pfade nicht existieren core_missing_no_chosen_included_path = Kein gültiger einzubander Pfad ausgewählt (ausgeschlossene Pfade hätten alle einzubanderten Pfade ausschließen können) core_reference_included_paths_same = Kann den Scan nicht starten, wo alle gültigen eingeschlossenen Pfade auch referenzierte Pfade sind, bitte validieren oder die referenzierten Pfade deaktivieren core_excluded_paths_pointless_slash = Ausgeschlossen / ist zwecklos, weil es bedeutet, dass keine Dateien gescannt werden core_needs_allowed_extensions_limited_by_tool = Kann den Scan nicht starten, wenn alle verfügbaren Erweiterungen in diesem Tool ({ $extensions }) vom Scan ausgeschlossen wurden core_needs_allowed_extensions = Kann den Scan nicht starten, wenn alle Erweiterungen vom Scan ausgeschlossen wurden core_needs_to_set_at_least_one_broken_option = Kann den Scan nicht starten, wenn keine Option „defektes Element“ zum Scannen festgelegt ist core_needs_to_set_at_least_one_bad_name_option = Kann den Scan nicht starten, wenn keine Option „schlechter Name“ zum Scannen festgelegt ist core_ffmpeg_not_found = Kann die korrekte Installation von FFmpeg oder FFprobe nicht finden. Dies sind externe Programme, die manuell installiert werden müssen. core_ffmpeg_not_found_windows = Stellen Sie sicher, dass ffmpeg.exe und ffprobe.exe in PATH verfügbar sind oder direkt im selben Ordner wie die Programmdatei der App liegen core_invalid_symlink_infinite_recursion = Endlose Rekursion core_invalid_symlink_non_existent_destination = Nicht existierende Zieldatei core_messages_limit_reached_characters = Anzahl der Nachrichten überschritten die festgelegte Grenze ({ $current }/{ $limit } Zeichen), so dass die Ausgabe abgeschnitten wurde. Um die vollständige Ausgabe zu lesen, deaktivieren Sie die Limitierungsoption in den Einstellungen. core_messages_limit_reached_lines = Anzahl der Nachrichten überschritten das festgelegte Limit ({ $current }/{ $limit } Zeilen), so dass die Ausgabe abgeschnitten wurde. Um die vollständige Ausgabe zu lesen, deaktivieren Sie die Limitierungsoption in den Einstellungen. core_error_moving_to_trash = Fehler beim Verschieben von "{ $file }" in den Papierkorb: { $error } core_error_removing = Fehler beim Entfernen von "{ $file }": { $error } core_no_similarity_method_selected = Kann keine ähnlichen Musikdateien ohne eine ausgewählte Similarity-Methode finden core_failed_to_spawn_command = Fehlgeschlagenes Spawnen des Befehls: { $reason } core_failed_to_check_process_status = Fehlgeschlagen bei der Überprüfung des Prozessstatus: { $reason } core_failed_to_wait_for_process = Fehlgeschlagenes Warten auf Prozess: { $reason } core_failed_to_read_video_properties = Fehlgeschlagen beim Lesen der Videoeigenschaften: { $reason } core_failed_to_execute_ffmpeg = Fehlgeschlagenes Ausführen von ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg fehlgeschlagen mit Status { $status }: { $stderr } (Befehl: { $command }) core_failed_to_load_image_frame = Fehlgeschlagenes Laden des Bildrahmens: { $reason } core_failed_to_extract_frame = Fehlgeschlagenes Extrahieren des Frames bei { $time } Sekunden aus "{ $file }": { $reason } core_failed_to_save_thumbnail = Fehlgeschlagen beim Speichern des Miniaturansichts für "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Fehlgeschlagenes Abrufen des Frames bei Zeitstempel { $timestamp } von "{ $file }": { $reason } core_failed_get_frame_from_file = Fehlgeschlagenes Abrufen des Frames von "{ $file }" zu Zeitstempel { $timestamp }: { $reason } core_invalid_crop_rectangle = Ungültiges Zuschneide-Rechteck: left={ $left }, top={ $top }, right={ $right }, bottom={ $bottom } core_failed_to_crop_video_file = Fehlgeschlagenes Zuschneiden der Videodatei "{ $file }": { $reason } core_cropped_video_not_created = Das Video-Datei wurde nicht erstellt: { $temp } core_unable_check_hash_of_file = Kann Hash von Datei "{ $file }" nicht überprüft werden, Grund { $reason } core_error_checking_hash_of_file = Fehler beim Prüfen des Hash von Datei "{ $file }", Grund { $reason } core_image_zero_dimensions = Bild hat breite Null oder Höhe "{ $path }" core_image_open_failed = Kann das Bildfile "{ $path }" nicht öffnen: { $reason } core_not_directory_remove = Versuche, Ordner "{ $path }" zu entfernen, der kein Verzeichnis ist core_cannot_read_directory = Kann den Verzeichnis "{ $path }" nicht lesen core_cannot_read_entry_from_directory = Kann den Eintrag nicht aus dem Verzeichnis "{ $path }" lesen core_folder_contains_file_inside = Ordner enthält Datei "{ $entry }" innerhalb "{ $folder }" core_unknown_directory_entry = Kann den Dateityp des Verzeichniseintrags "{ $entry }" innerhalb "{ $path }" nicht bestimmen core_video_width_exceeds_limit = Video Breite { $width } überschreitet die Grenze von { $limit } core_video_height_exceeds_limit = Videohöhe { $height } überschreitet die Grenze von { $limit } core_failed_to_process_video = Fehlgeschlagenes Verarbeiten der Videodatei { $file }: { $reason } core_optimized_file_larger = Optimierter Datei { $optimized } (Größe: { $new_size }) ist nicht kleiner als Original { $original } (Größe: { $original_size }) core_unknown_codec = Unbekannter Codec: { $codec } core_invalid_video_optimizer_mode = Ungültiger Video-Optimierungsmodus: '{ $mode }'. Erlaubte Werte: transkodieren, zuschneiden core_folder_does_not_exist = Ordner existiert nicht: { $folder } core_path_not_directory = Der Pfad ist keine Verzeichnis: { $folder } core_test_error_for_folder = Testfehler für Ordner: { $folder } core_unknown_exif_tag_group = Unbekanntes EXIF-Tag-Gruppen: { $tag } core_error_comparing_fingerprints = Fehler beim Vergleichen von Fingerabdrücken: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Fehlgeschlagen, Miniatur für "{ $file }" zu generieren: extrahierte Frames haben unterschiedliche Dimensionen core_failed_to_generate_thumbnail = Fehlgeschlagenes Generieren des Miniaturansichts für "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Fehlgeschlagenes Extrahieren des Frames bei { $time } Sekunden aus "{ $file }": { $reason } core_video_file_does_not_exist = Video-Datei existiert nicht (kann zwischen Scan/späteren Schritten entfernt werden): "{ $path }" core_image_too_large = Bild ist zu groß ({ $width }x{ $height }) - mehr als unterstützt { $max } Pixel core_failed_to_get_video_metadata = Fehlgeschlagen beim Abrufen der Videodaten für Datei "{ $file }": { $reason } core_failed_to_get_video_codec = Fehlgeschlagenes Abrufen des Videocodecs für die Datei "{ $file }" core_failed_to_get_video_duration = Fehlgeschlagen, die Video-Dauer für die Datei "{ $file }" zu erhalten core_failed_to_get_video_dimensions = Fehlgeschlagen, Video-Abmessungen für Datei "{ $file }" zu erhalten core_frame_dimensions_mismatch = Rahmenmaße für Zeitstempel { $timestamp } stimmen nicht mit den ersten Rahmenmaßen ({ $first_w }x{ $first_h }) überein core_failed_to_load_data_from_cache = Fehler beim Laden von Daten aus der Cache-Datei { $file }, Grund { $reason } core_failed_to_load_data_from_json_cache = Fehler beim Laden von Daten aus der JSON-Cache-Datei { $file }, Grund { $reason } core_failed_to_replace_with_optimized = Fehlgeschlagenes Ersetzen der Datei "{ $file }" mit der optimierten Version: { $reason } core_failed_to_write_data_to_cache = Kann keine Daten in die Cache-Datei "{ $file }" schreiben, Grund { $reason } core_properly_saved_cache_entries = Ordentlich in Datei { $count } Cache-Einträge gespeichert. core_video_processing_stopped_by_user = Video-Verarbeitung wurde durch Benutzer gestoppt core_thumbnail_generation_stopped_by_user = Erstellung von Vorschaubildern wurde durch Benutzer gestoppt core_failed_to_optimize_video = Fehlgeschlagenes Optimieren des Videos "{ $file }": { $reason } core_failed_to_crop_video = Fehlgeschlagenes Zuschneiden des Videos "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Fehlgeschlagenes Abrufen der Metadaten der optimierten Datei "{ $file }": { $reason } core_cannot_create_config_folder = Kann die Konfigurationsordner "{ $folder }" nicht erstellen, Grund { $reason } core_cannot_create_cache_folder = Kann den Cache-Ordner "{ $folder }" nicht erstellen, Grund { $reason } core_cannot_create_or_open_cache_file = Kann die Cache-Datei "{ $file }" nicht erstellen oder öffnen, Grund { $reason } core_cannot_set_config_cache_path = Kann die Konfiguration/Cache-Pfad nicht setzen - Konfiguration und Cache werden nicht verwendet. core_invalid_extension_contains_space = { $extension } ist keine gültige Erweiterung, da sie Leerzeichen enthält core_invalid_extension_contains_dot = { $extension } ist keine gültige Erweiterung, da sie einen Punkt enthält core_path_must_exists = Der angegebene Pfad muss existieren, wobei { $path } ignoriert wird core_must_be_directory_or_file = Der angegebene Pfad muss auf ein gültiges Verzeichnis oder eine Datei verweisen, wobei { $path } ignoriert wird core_paths_unable_to_get_device_id = Gerät-ID konnte nicht aus dem Ordner { $path } abgerufen werdenczkawka_core-11.0.1/i18n/el/czkawka_core.ftl000064400000000000000000000346341046102023000167120ustar 00000000000000# Core core_similarity_original = Αρχικό core_similarity_very_high = Πολύ Υψηλή core_similarity_high = Υψηλή core_similarity_medium = Μεσαίο core_similarity_small = Μικρό core_similarity_very_small = Πολύ Μικρό core_similarity_minimal = Ελάχιστα core_cannot_open_dir = Αδυναμία ανοίγματος dir { $dir }, λόγος { $reason } core_cannot_read_entry_dir = Αδυναμία ανάγνωσης καταχώρησης στον κατάλογο { $dir }, λόγος { $reason } core_cannot_read_metadata_dir = Αδύνατη η ανάγνωση μεταδεδομένων στον κατάλογο { $dir }, λόγος { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Το αρχείο { $name } φαίνεται να έχει τροποποιηθεί πριν το Unix Epoch core_folder_modified_before_epoch = Φάκελος { $name } φαίνεται να έχει τροποποιηθεί πριν το Unix Epoch core_file_no_modification_date = Δεν είναι δυνατή η λήψη ημερομηνίας τροποποίησης από το αρχείο { $name }, λόγος { $reason } core_folder_no_modification_date = Δεν είναι δυνατή η λήψη ημερομηνίας τροποποίησης από το φάκελο { $name }, λόγος { $reason } core_cannot_start_scan_no_included_paths = Δεν μπορεί να ξεκινήσει η σάρωση, επειδή δεν περιλαμβάνονται καθόλου οι διαδρομές core_skip_exist_check_all_included_paths_nonexistent = Δεν μπορεί να ξεκινήσει η σάρωση, επειδή οι μισθοί διαδρομές που περιλαμβάνονται δεν υπάρχουν core_missing_no_chosen_included_path = Δεν επιλέχθηκε έγκυρος συμπεριλαμβανόμενος δρόμος (οι αποκλεισμένοι δρόμοι θα μπορούσαν να έχουν αποκλείσει όλους τους συμπεριλαμβανόμενους δρόμους) core_reference_included_paths_same = Δεν μπορεί να ξεκινήσει η σάρωση όπου όλα τα έγκυρα συμπεριλημμένα μονοπάτια είναι επίσης μονοπάτια αναφοράς, προσπαθήστε να επικυρώσετε ή να απενεργοποιήσετε τα μονοπάτια αναφοράς core_path_must_exists = Παρασχόμενος ο δρόμος πρέπει να υπάρχει, αγνοώντας το { $path } core_must_be_directory_or_file = Παρέχεται ο δρόμος πρέπει να δείχνει προς έναν έγκυρο κατάλογο ή αρχείο, αγνοώντας { $path } core_excluded_paths_pointless_slash = Αποκλείοντας / είναι μάταιο, γιατί σημαίνει ότι κανένα αρχείο δεν θα σαρωθεί core_paths_unable_to_get_device_id = Δεν μπορώ να λάβω το ID συσκευής από τον φάκελο { $path } core_needs_allowed_extensions_limited_by_tool = Δεν μπορεί να ξεκινήσει η σάρωση, όταν όλα τα πρόσθετα διαθέσιμα σε αυτό το εργαλείο ({ $extensions }) έχουν αποκλειστεί από την σάρωση core_needs_allowed_extensions = Δεν μπορεί να ξεκινήσει η σάρωση, όταν έχουν αποκλειστεί όλες οι προσθήκες από τη σάρωση core_needs_to_set_at_least_one_broken_option = Δεν μπορεί να ξεκινήσει η σάρωση, όταν δεν έχει οριστεί η επιλογή "κατεστραμμένο" για σάρωση core_needs_to_set_at_least_one_bad_name_option = Δεν μπορεί να ξεκινήσει η σάρωση, όταν δεν έχει ρυθμιστεί η επιλογή για κακό όνομα για σάρωση core_ffmpeg_not_found = Δεν μπορεί να βρεθεί μια σωστή εγκατάσταση του FFmpeg ή FFprobe. Αυτά είναι εξωτερικά προγράμματα που πρέπει να εγκατασταθούν χειροκίνητα. core_ffmpeg_not_found_windows = Να είστε βέβαιος ότι ffmpeg.exe και ffprobe.exe είναι διαθέσιμα σε PATH ή τοποθετούνται απευθείας στον ίδιο φάκελο με το εκτελέσιμο app core_invalid_symlink_infinite_recursion = Άπειρη αναδρομή core_invalid_symlink_non_existent_destination = Αρχείο ανύπαρκτου προορισμού core_messages_limit_reached_characters = Ο αριθμός μηνυμάτων υπερέβη το καθορισμένο όριο ({ $current }/{ $limit } χαρακτήρες), οπότε η έξοδος περικόπηκε. Για να διαβάσετε την πλήρη έξοδο, απενεργοποιήστε την επιλογή περιορισμού στις ρυθμίσεις. core_messages_limit_reached_lines = Ο αριθμός μηνυμάτων υπερέβη το καθορισμένο όριο ({ $current }/{ $limit } γραμμές), οπότε η έξοδος περικόπηκε. Για να διαβάσετε την πλήρη έξοδο, απενεργοποιήστε την επιλογή περιορισμού στις ρυθμίσεις. core_error_moving_to_trash = Σφάλμα κατά μετακίνηση "{ $file }" στον κάλαπο”. { $error } core_error_removing = Σφάλμα κατά την αφαίρεση "{ $file }": { $error } core_no_similarity_method_selected = Δεν μπορεί να βρεθούν παρόμοια μουσικά αρχεία χωρίς μια επιλεγμένη μέθοδο ομοιότητας core_failed_to_spawn_command = Αποτυχία εκκίνησης εντολής: { $reason } core_failed_to_check_process_status = Αποτυχία ελέγχου της κατάστασης της διαδικασίας: { $reason } core_failed_to_wait_for_process = Αποτυχία αναμονής για τη διαδικασία: { $reason } core_failed_to_read_video_properties = Αποτυχία ανάγνωσης ιδιοτήτων βίντεο: { $reason } core_failed_to_execute_ffmpeg = Αποτυχία εκτέλεσης ffmpeg: { $reason } core_ffmpeg_failed_with_status = έπεσε η ffmpeg με την κατάσταση { $status }: { $stderr } (εντολή: { $command }) core_failed_to_load_image_frame = Αποτυχία φόρτωσης πλαισίου εικόνας: { $reason } core_failed_to_extract_frame = Αποτυχία εξαγωγής καρέ στις { $time } δευτερόλεπτα από το "{ $file }": { $reason } core_failed_to_save_thumbnail = Αποτυχία αποθήκευσης μικρογραφίας για "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Αποτυχία ανάκτησης καρέ στην σφραγίδα χρόνου { $timestamp } από το "{ $file }": { $reason } core_failed_get_frame_from_file = Αποτυχία λήψης πλαισίου από "{ $file }" στην σφραγίδα χρόνου { $timestamp }: { $reason } core_invalid_crop_rectangle = Μη έγκυρος ορθογώνιο καλλιέργειας: αριστερά={ $left }, άνω={ $top }, δεξιά={ $right }, κάτω={ $bottom } core_failed_to_crop_video_file = Αποτυχία κοπής του αρχείου βίντεο "{ $file }": { $reason } core_cropped_video_not_created = Το αρχείο βίντεο που κόπηκε δεν δημιουργήθηκε: { $temp } core_unable_check_hash_of_file = Δεν μπορώ να ελέγξω το hash του αρχείου "{ $file }", λόγος { $reason } core_error_checking_hash_of_file = Σφάλμα συνέβη κατά την επαλήθευση του hash του αρχείου "{ $file }", λόγος { $reason } core_image_zero_dimensions = Η εικόνα έχει μηδενικό πλάτος ή ύψος "{ $path }" core_image_open_failed = Δεν μπορεί να ανοίξει το αρχείο εικόνας "{ $path }": { $reason } core_not_directory_remove = Προσπαθώντας να διαγράψω τον φάκελο "{ $path }" που δεν είναι κατάλογος core_cannot_read_directory = Δεν μπορώ να διαβάσω τον κατάλογο "{ $path }" core_cannot_read_entry_from_directory = Δεν μπορώ να διαβάσω την εγγραφή από τον κατάλογο "{ $path }" core_folder_contains_file_inside = Ο φάκελος περιέχει το αρχείο "{ $entry }" μέσα στο "{ $folder }" core_unknown_directory_entry = Δεν μπορεί να προσδιοριστεί ο τύπος αρχείου της καταχώρησης καταλόγου "{ $entry }" μέσα στο "{ $path }" core_video_width_exceeds_limit = Βίντεο πλάτος { $width } υπερβαίνει το όριο του { $limit } core_video_height_exceeds_limit = Βίντεο ύψος { $height } υπερβαίνει το όριο των { $limit } core_failed_to_process_video = Αποτυχία επεξεργασίας αρχείου βίντεο { $file }: { $reason } core_optimized_file_larger = Βελτιστοποιημένο αρχείο { $optimized } (μέγεθος: { $new_size }) δεν είναι μικρότερο από το αρχικό { $original } (μέγεθος: { $original_size }) core_unknown_codec = Άγνωστος κωδικοποιητής: { $codec } core_invalid_video_optimizer_mode = Μη έγκυρος τρόπος βελτιστοποίησης βίντεο: '{ $mode }'. Επιτρεπτές τιμές: transcode, crop core_folder_does_not_exist = Ο φάκελος δεν υπάρχει: { $folder } core_path_not_directory = Το μονοπάτι δεν είναι κατάλογος: { $folder } core_test_error_for_folder = Σφάλμα δοκιμής για φάκελο: { $folder } core_unknown_exif_tag_group = Άγνωστη ομάδα ετικετών EXIF: { $tag } core_error_comparing_fingerprints = Σφάλμα κατά σύγκριση δακτυλικών αποτυπωμάτων: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Αποτυχία δημιουργίας μικρογραφίας για "{ $file }": τα εξωθημένα πλάνα έχουν διαφορετικές διαστάσεις core_failed_to_generate_thumbnail = Αποτυχία δημιουργίας μικρογραφίας για "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Αποτυχία εξαγωγής καρέ στις { $time } δευτερόλεπτα από το "{ $file }": { $reason } core_video_file_does_not_exist = Το αρχείο βίντεο δεν υπάρχει (μπορεί να αφαιρεθεί μεταξύ σάρωσης/μετέπειτα βημάτων): "{ $path }" core_image_too_large = Η εικόνα είναι πολύ μεγάλη ({ $width }x{ $height }) - περισσότερο από το υποστηριζόμενο { $max } pixels core_failed_to_get_video_metadata = Αποτυχία ανάκτησης μεταδεδομένων βίντεο για το αρχείο "{ $file }": { $reason } core_failed_to_get_video_codec = Αποτυχία ανάκτησης του codec βίντεο για το αρχείο "{ $file }" core_failed_to_get_video_duration = Αποτυχία λήψης της διάρκειας βίντεο για το αρχείο "{ $file }" core_failed_to_get_video_dimensions = Αποτυχία λήψης διαστάσεων βίντεο για το αρχείο "{ $file }" core_frame_dimensions_mismatch = Οι διαστάσεις του πλαισίου για την σφραγίδα χρόνου { $timestamp } δεν ταιριάζουν με τις διαστάσεις του πρώτου πλαισίου ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Αποτυχία φόρτωσης δεδομένων από το αρχείο cache { $file }, λόγος { $reason } core_failed_to_load_data_from_json_cache = Αποτυχία φόρτωσης δεδομένων από το αρχείο cache json { $file }, λόγος { $reason } core_failed_to_replace_with_optimized = Αποτυχία αντικατάστασης του αρχείου "{ $file }" με την βελτιστοποιημένη έκδοση: { $reason } core_failed_to_write_data_to_cache = Δεν μπορεί να γραφτεί δεδομένα στο αρχείο cache "{ $file }", λόγος { $reason } core_properly_saved_cache_entries = Αποθηκεύτηκε σωστά στο αρχείο { $count } καταχωρήσεις cache. core_video_processing_stopped_by_user = Η επεξεργασία βίντεο σταμάτησε από τον χρήστη core_thumbnail_generation_stopped_by_user = Δημιουργία μικρογραφιών σταματήθηκε από χρήστη core_failed_to_optimize_video = Αποτυχία βελτιστοποίησης βίντεο "{ $file }": { $reason } core_failed_to_crop_video = Αποτυχία κοπής βίντεο "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Αποτυχία ανάκτησης μεταδεδομένων του βελτιστοποιημένου αρχείου "{ $file }": { $reason } core_cannot_create_config_folder = Δεν μπορεί να δημιουργηθεί ο φάκελος διαμόρφωσης "{ $folder }", λόγος { $reason } core_cannot_create_cache_folder = Δεν μπορεί να δημιουργηθεί ο φάκελος προσωρινής αποθήκευσης "{ $folder }", λόγος { $reason } core_cannot_create_or_open_cache_file = Δεν μπορεί να δημιουργηθεί ή να ανοίξει το αρχείο cache "{ $file }", λόγος { $reason } core_cannot_set_config_cache_path = Δεν μπορεί να ρυθμιστεί ο/η διαδρομή config/cache - η config και η cache δεν θα χρησιμοποιηθούν. core_invalid_extension_contains_space = { $extension } δεν είναι έγκυρος τύπος επέκτασης επειδή περιέχει κενό διάστημα μέσα core_invalid_extension_contains_dot = Το { $extension } δεν είναι έγκυρος τύπος επέκτασης επειδή περιέχει τελεία μέσα czkawka_core-11.0.1/i18n/en/czkawka_core.ftl000064400000000000000000000222431046102023000167050ustar 00000000000000# Core core_similarity_original = Original core_similarity_very_high = Very High core_similarity_high = High core_similarity_medium = Medium core_similarity_small = Small core_similarity_very_small = Very Small core_similarity_minimal = Minimal core_cannot_open_dir = Cannot open dir {$dir}, reason {$reason} core_cannot_read_entry_dir = Cannot read entry in dir {$dir}, reason {$reason} core_cannot_read_metadata_dir = Cannot read metadata in dir {$dir}, reason {$reason} core_cannot_read_metadata_file = Cannot read metadata of file {$file}, reason {$reason} core_file_modified_before_epoch = File {$name} seems to have been modified before the Unix Epoch core_folder_modified_before_epoch = Folder {$name} seems to have been modified before the Unix Epoch core_file_no_modification_date = Unable to get modification date from file {$name}, reason {$reason} core_folder_no_modification_date = Unable to get modification date from folder {$name}, reason {$reason} core_cannot_start_scan_no_included_paths = Cannot start scan, because there are no included paths core_skip_exist_check_all_included_paths_nonexistent = Cannot start scan, because all included paths do not exist core_missing_no_chosen_included_path = No valid included path was chosen(excluded paths could have excluded all included paths) core_reference_included_paths_same = Cannot start scan where all valid included paths are also referenced paths, try to validate or disable referenced paths core_path_must_exists = Provided path must exist, ignoring { $path } core_must_be_directory_or_file = Provided path must point to a vaild directory or file, ignoring { $path } core_excluded_paths_pointless_slash = Excluding / is pointless, because it means no files will be scanned core_paths_unable_to_get_device_id = Unable to get device id from folder { $path } core_needs_allowed_extensions_limited_by_tool = Cannot start scan, when all extensions available in this tool ({ $extensions }) were excluded from scan core_needs_allowed_extensions = Cannot start scan, when all extensions were excluded from scan core_needs_to_set_at_least_one_broken_option = Cannot start scan, when there is no broken option set to scan for core_needs_to_set_at_least_one_bad_name_option = Cannot start scan, when there is no bad name option set to scan for core_ffmpeg_not_found = Cannot find a proper installation of FFmpeg or FFprobe. These are external programs that must be installed manually. core_ffmpeg_not_found_windows = Be sure that ffmpeg.exe and ffprobe.exe are available in PATH or are placed directly in the same folder as the app executable core_invalid_symlink_infinite_recursion = Infinite recursion core_invalid_symlink_non_existent_destination = Non-existent destination file core_messages_limit_reached_characters = Number of messages exceeded the set limit ({$current}/{$limit} characters), so the output was truncated. To read the full output, disable the limiting option in settings. core_messages_limit_reached_lines = Number of messages exceeded the set limit ({$current}/{$limit} lines), so the output was truncated. To read the full output, disable the limiting option in settings. core_error_moving_to_trash = Error while moving "{ $file }" to the trash: { $error } core_error_removing = Error while removing "{ $file }": { $error } core_no_similarity_method_selected = Cannot find similar music files without a selected similarity method core_failed_to_spawn_command = Failed to spawn command: { $reason } core_failed_to_check_process_status = Failed to check process status: { $reason } core_failed_to_wait_for_process = Failed to wait for process: { $reason } core_failed_to_read_video_properties = Failed to read video properties: { $reason } core_failed_to_execute_ffmpeg = Failed to execute ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg failed with status { $status }: { $stderr } (command: { $command }) core_failed_to_load_image_frame = Failed to load image frame: { $reason } core_failed_to_extract_frame = Failed to extract frame at { $time } seconds from "{ $file }": { $reason } core_failed_to_save_thumbnail = Failed to save thumbnail for "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Failed to get frame at timestamp { $timestamp } from "{ $file }": { $reason } core_failed_get_frame_from_file = Failed to get frame from "{ $file }" at timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Invalid crop rectangle: left={ $left }, top={ $top }, right={ $right }, bottom={ $bottom } core_failed_to_crop_video_file = Failed to crop video file "{ $file }": { $reason } core_cropped_video_not_created = Cropped video file was not created: { $temp } core_unable_check_hash_of_file = Unable to check hash of file "{ $file }", reason { $reason } core_error_checking_hash_of_file = Error happened when checking hash of file "{ $file }", reason { $reason } core_image_zero_dimensions = Image has zero width or height "{ $path }" core_image_open_failed = Cannot open image file "{ $path }": { $reason } core_not_directory_remove = Trying to remove folder "{ $path }" which is not a directory core_cannot_read_directory = Cannot read directory "{ $path }" core_cannot_read_entry_from_directory = Cannot read entry from directory "{ $path }" core_folder_contains_file_inside = Folder contains file "{ $entry }" inside "{ $folder }" core_unknown_directory_entry = Unable to determine file type of directory entry "{ $entry }" inside "{ $path }" core_video_width_exceeds_limit = Video width { $width } exceeds the limit of { $limit } core_video_height_exceeds_limit = Video height { $height } exceeds the limit of { $limit } core_failed_to_process_video = Failed to process video file { $file }: { $reason } core_optimized_file_larger = Optimized file { $optimized } (size: { $new_size }) is not smaller than original { $original } (size: { $original_size }) core_unknown_codec = Unknown codec: { $codec } core_invalid_video_optimizer_mode = Invalid video optimizer mode: '{ $mode }'. Allowed values: transcode, crop core_folder_does_not_exist = Folder does not exist: { $folder } core_path_not_directory = Path is not a directory: { $folder } core_test_error_for_folder = Test error for folder: { $folder } core_unknown_exif_tag_group = Unknown EXIF tag group: { $tag } core_error_comparing_fingerprints = Error while comparing fingerprints: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Failed to generate thumbnail for "{ $file }": extracted frames have different dimensions core_failed_to_generate_thumbnail = Failed to generate thumbnail for "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Failed to extract frame at { $time } seconds from "{ $file }": { $reason } core_video_file_does_not_exist = Video file does not exist (could be removed between scan/later steps): "{ $path }" core_image_too_large = Image is too large ({ $width }x{ $height }) - more than supported { $max } pixels core_failed_to_get_video_metadata = Failed to get video metadata for file "{ $file }": { $reason } core_failed_to_get_video_codec = Failed to get video codec for file "{ $file }" core_failed_to_get_video_duration = Failed to get video duration for file "{ $file }" core_failed_to_get_video_dimensions = Failed to get video dimensions for file "{ $file }" core_frame_dimensions_mismatch = Frame dimensions for timestamp { $timestamp } do not match the first frame dimensions ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Failed to load data from cache file { $file }, reason { $reason } core_failed_to_load_data_from_json_cache = Failed to load data from json cache file { $file }, reason { $reason } core_failed_to_replace_with_optimized = Failed to replace file "{ $file }" with optimized version: { $reason } core_failed_to_write_data_to_cache = Cannot write data to cache file "{ $file }", reason { $reason } core_properly_saved_cache_entries = Properly saved to file { $count } cache entries. core_video_processing_stopped_by_user = Video processing was stopped by user core_thumbnail_generation_stopped_by_user = Thumbnail generation was stopped by user core_failed_to_optimize_video = Failed to optimize video "{ $file }": { $reason } core_failed_to_crop_video = Failed to crop video "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Failed to get metadata of optimized file "{ $file }": { $reason } core_cannot_create_config_folder = Cannot create config folder "{ $folder }", reason { $reason } core_cannot_create_cache_folder = Cannot create cache folder "{ $folder }", reason { $reason } core_cannot_create_or_open_cache_file = Cannot create or open cache file "{ $file }", reason { $reason } core_cannot_set_config_cache_path = Cannot set config/cache path - config and cache will not be used. core_invalid_extension_contains_space = { $extension } is not a valid extension because it contains empty space inside core_invalid_extension_contains_dot = { $extension } is not a valid extension because it contains dot inside core_ffmpeg_unknown_encoder = Cannot encode { $file } using the { $encoder } encoder. The current FFmpeg build does not support this encoder. Use a different FFmpeg version with the required codec support or select another encoder. core_ffmpeg_error = FFmpeg error while processing { $file }, status code { $code }, reason { $reason }czkawka_core-11.0.1/i18n/es-ES/czkawka_core.ftl000064400000000000000000000241631046102023000172220ustar 00000000000000# Core core_similarity_original = Original core_similarity_very_high = Muy alta core_similarity_high = Alta core_similarity_medium = Medio core_similarity_small = Pequeño core_similarity_very_small = Muy pequeño core_similarity_minimal = Mínimo core_cannot_open_dir = No se puede abrir el directorio { $dir }, razón { $reason } core_cannot_read_entry_dir = No se puede leer la entrada en directorio { $dir }, razón { $reason } core_cannot_read_metadata_dir = No se pueden leer metadatos en el directorio { $dir }, razón { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = El archivo { $name } parece haber sido modificado antes del Epoch Unix core_folder_modified_before_epoch = La carpeta { $name } parece haber sido modificada antes del Epoch Unix core_file_no_modification_date = No se puede obtener la fecha de modificación del archivo { $name }, razón { $reason } core_folder_no_modification_date = No se puede obtener la fecha de modificación de la carpeta { $name }, razón { $reason } core_cannot_start_scan_no_included_paths = No se puede iniciar el escaneo, porque no hay rutas incluidas core_skip_exist_check_all_included_paths_nonexistent = No se puede iniciar el escaneo, porque todas las rutas incluidas no existen core_missing_no_chosen_included_path = No ruta incluida válida fue elegida (las rutas excluidas podrían haber excluido todas las rutas incluidas) core_reference_included_paths_same = No se puede iniciar el escaneo donde todas las rutas incluidas válidas también son rutas referenciadas, intente validar o deshabilitar las rutas referenciadas core_path_must_exists = Se debe proporcionar la ruta especificada, ignorando { $path } core_must_be_directory_or_file = Proporcionado el camino debe apuntar a un directorio o archivo válido, ignorando { $path } core_excluded_paths_pointless_slash = Excluyendo / es inútil, porque significa que no se escanean archivos core_paths_unable_to_get_device_id = Imposible obtener el id del dispositivo del directorio { $path } core_needs_allowed_extensions_limited_by_tool = No se puede iniciar el escaneo, cuando todas las extensiones disponibles en esta herramienta ({ $extensions }) fueron excluidas del escaneo core_needs_allowed_extensions = No se puede iniciar el escaneo, cuando todas las extensiones fueron excluidas del escaneo core_needs_to_set_at_least_one_broken_option = No se puede iniciar el escaneo, cuando no está configurada la opción de roto para escanear core_needs_to_set_at_least_one_bad_name_option = No se puede iniciar el escaneo, cuando no está configurada la opción de nombre incorrecto para escanear core_ffmpeg_not_found = No se puede encontrar una instalación adecuada de FFmpeg o FFprobe. Estos son programas externos que deben instalarse manualmente. core_ffmpeg_not_found_windows = Asegúrese de que ffmpeg.exe y ffprobe.exe están disponibles en PATH o se colocan directamente en la misma carpeta que el ejecutable de la aplicación core_invalid_symlink_infinite_recursion = Recursión infinita core_invalid_symlink_non_existent_destination = Archivo de destino inexistente core_messages_limit_reached_characters = El número de mensajes excedió el límite establecido (caracteres{ $current }/{ $limit } ), por lo que la salida fue truncada. Para leer la salida completa, deshabilite la opción de limitación en los ajustes. core_messages_limit_reached_lines = Número de mensajes excedido el límite establecido ({ $current }/{ $limit } líneas), por lo que la salida fue truncada. Para leer la salida completa, deshabilite la opción de limitación en los ajustes. core_error_moving_to_trash = Error al mover "{ $file }" a la papelera: { $error } core_error_removing = Error al eliminar "{ $file }": { $error } core_no_similarity_method_selected = No se pueden encontrar archivos de música similares sin un método de similitud seleccionado core_ffmpeg_failed_with_status = ffmpeg falló con estado { $status }: { $stderr } (comando: { $command }) core_failed_to_extract_frame = Falló al extraer el fotograma en { $time } segundos de "{ $file }": { $reason } core_failed_to_save_thumbnail = Falló al guardar el miniagujete para "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Error al obtener el fotograma en el sello de tiempo { $timestamp } de "{ $file }": { $reason } core_failed_get_frame_from_file = No se pudo obtener el fotograma de "{ $file }" en el sello de tiempo { $timestamp }: { $reason } core_invalid_crop_rectangle = ¡Rectángulo de recorte inválido: izquierda={ $left }, arriba={ $top }, derecha={ $right }, abajo={ $bottom } core_failed_to_crop_video_file = El recorte del archivo de video "{ $file }" falló: { $reason } core_cropped_video_not_created = El archivo de video recortado no fue creado: { $temp } core_unable_check_hash_of_file = Imposible verificar el hash del archivo "{ $file }", la razón { $reason } core_error_checking_hash_of_file = Error ocurrió al verificar el hash del archivo "{ $file }", razón { $reason } core_image_zero_dimensions = La imagen tiene un ancho o alto de cero "{ $path }" core_image_open_failed = No se puede abrir el archivo de imagen "{ $path }": { $reason } core_not_directory_remove = Intentando eliminar la carpeta "{ $path }" que no es un directorio core_cannot_read_directory = No se puede leer el directorio "{ $path }" core_cannot_read_entry_from_directory = No se puede leer la entrada del directorio "{ $path }" core_folder_contains_file_inside = La carpeta contiene el archivo "{ $entry }" dentro de "{ $folder }" core_unknown_directory_entry = No se puede determinar el tipo de archivo de la entrada del directorio "{ $entry }" dentro de "{ $path }" core_video_width_exceeds_limit = Video ancho { $width } excede el límite de { $limit } core_video_height_exceeds_limit = Video altura { $height } excede el límite de { $limit } core_failed_to_process_video = No se pudo procesar el archivo de video { $file }: { $reason } core_optimized_file_larger = Archivo optimizado { $optimized } (tamaño: { $new_size }) no es más pequeño que el original { $original } (tamaño: { $original_size }) core_unknown_codec = Códec desconocido: { $codec } core_invalid_video_optimizer_mode = El modo optimizador de video no es válido: '{ $mode }'. Los valores permitidos: transcodificar, recortar core_folder_does_not_exist = La carpeta no existe: { $folder } core_path_not_directory = La ruta no es un directorio: { $folder } core_test_error_for_folder = Error de prueba para la carpeta: { $folder } core_unknown_exif_tag_group = Grupo de etiquetas EXIF desconocido: { $tag } core_error_comparing_fingerprints = Error al comparar huellas dactilares: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Falló al generar miniatura para "{ $file }": los fotogramas extraídos tienen diferentes dimensiones core_failed_to_generate_thumbnail = No se pudo generar miniatura para "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Falló al extraer el fotograma en { $time } segundos de "{ $file }": { $reason } core_video_file_does_not_exist = El archivo de video no existe (puede ser eliminado entre las etapas de escaneo/más tarde): "{ $path }" core_image_too_large = La imagen es demasiado grande ({ $width }x{ $height }) - más que los soportados { $max } píxeles core_failed_to_get_video_metadata = No se pudo obtener los metadatos del video para el archivo "{ $file }": { $reason } core_failed_to_get_video_codec = No se pudo obtener el códec de video para el archivo "{ $file }" core_failed_to_get_video_duration = No se pudo obtener la duración del video para el archivo "{ $file }" core_failed_to_get_video_dimensions = No se pudo obtener las dimensiones del video para el archivo "{ $file }" core_frame_dimensions_mismatch = Las dimensiones del fotograma para la marca de tiempo { $timestamp } no coinciden con las dimensiones del primer fotograma ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = No se pudo cargar los datos del archivo de caché { $file }, la razón { $reason } core_failed_to_load_data_from_json_cache = No se pudo cargar los datos del archivo de caché json { $file }, motivo { $reason } core_failed_to_replace_with_optimized = No se pudo reemplazar el archivo "{ $file }" con la versión optimizada: { $reason } core_failed_to_write_data_to_cache = No se puede escribir datos al archivo de caché "{ $file }", la razón { $reason } core_properly_saved_cache_entries = Guardado correctamente a archivo { $count } entradas de caché. core_video_processing_stopped_by_user = El procesamiento de video fue detenido por el usuario core_thumbnail_generation_stopped_by_user = La generación de miniaturas fue detenida por el usuario core_failed_to_optimize_video = Falló optimizar el video "{ $file }": { $reason } core_failed_to_crop_video = Falló al recortar el video "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = No se pudo obtener los metadatos del archivo optimizado "{ $file }": { $reason } core_cannot_create_config_folder = No se puede crear la carpeta de configuración "{ $folder }", la razón { $reason } core_cannot_create_cache_folder = No se puede crear la carpeta de caché "{ $folder }", la razón { $reason } core_cannot_create_or_open_cache_file = No se puede crear o abrir el archivo de caché "{ $file }", la razón { $reason } core_cannot_set_config_cache_path = No se puede establecer la ruta de configuración/caché - la configuración y la caché no se utilizarán. core_invalid_extension_contains_space = { $extension } no es una extensión válida porque contiene espacios en blanco dentro core_invalid_extension_contains_dot = { $extension } no es una extensión válida porque contiene un punto dentro core_failed_to_spawn_command = No se pudo ejecutar el comando: { $reason } core_failed_to_check_process_status = No se pudo verificar el estado del proceso: { $reason } core_failed_to_wait_for_process = No se pudo esperar a que finalizara el proceso: { $reason } core_failed_to_read_video_properties = No se pudieron leer las propiedades del video: { $reason } core_failed_to_execute_ffmpeg = No se pudo ejecutar ffmpeg: { $reason } core_failed_to_load_image_frame = No se pudo cargar el fotograma de la imagen: { $reason }czkawka_core-11.0.1/i18n/fa/czkawka_core.ftl000064400000000000000000000312021046102023000166640ustar 00000000000000# Core core_similarity_original = اصولی core_similarity_very_high = بسیار بلند core_similarity_high = ارتفاع core_similarity_medium = میانبر core_similarity_small = کوچک core_similarity_very_small = بسیار کوچک core_similarity_minimal = 最少istantly converted to Persian: مینیمال core_cannot_open_dir = نمی‌توانم مسیر { $dir } را باز کنم، دلیل آن { $reason } core_cannot_read_entry_dir = نمی‌توانید درایه‌ای از پوشه { $dir } را بخوانید، دلیل آن { $reason } core_cannot_read_metadata_dir = می‌توانید مетا داده در پوشه { $dir } را خواند، با دلیل "{ $reason }" core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = فایل { $name } به نظر می‌رسد قبل از زمان‌بندی سیستم عکس آشامیده شده است core_folder_modified_before_epoch = دایره‌نامه { $name } به نظر می‌رسد قبل از زمان‌پا‌شناخت عینک لوبیا برمدرمود شده است core_file_no_modification_date = نemی‌توانم تاریخ تغییرات فایل { $name } را دریافت کنم، دلیل { $reason } core_folder_no_modification_date = نemی‌توانم تاریخ بروزرسanی از پوشه { $name } را دریافته‌ام، دلیل "{ $reason }" core_cannot_start_scan_no_included_paths = امکان شروع اسکن وجود ندارد، زیرا هیچ مسیرهای گنجانده شده‌ای وجود ندارد core_skip_exist_check_all_included_paths_nonexistent = امکان شروع اسکن وجود ندارد، زیرا تمام مسیرهای گنجانیده شده وجود ندارند core_missing_no_chosen_included_path = مسیر گنجانده شده معتبری انتخاب نشد (مسیرهای رد شده می‌توانستند تمام مسیرهای گنجانده شده را رد کنند) core_reference_included_paths_same = امکان شروع اسکن وجود ندارد، جایی که تمام مسیرهای گنجانده شده معتبر نیز مسیرهای ارجاعی هستند، لطفاً اعتبار سنجی را انجام دهید یا مسیرهای ارجاعی را غیرفعال کنید core_path_must_exists = مسیر ارائه شده باید وجود داشته باشد، نادیده گرفتن { $path } core_must_be_directory_or_file = مسیر ارائه شده باید به یک دایرکتوری یا فایل معتبر اشاره کند، با نادیده گرفتن { $path } core_excluded_paths_pointless_slash = исключить / бессмысленно, потому что это означает, что файлы не будут сканироваться core_paths_unable_to_get_device_id = امکان دریافت شناسه دستگاه از پوشه { $path } وجود ندارد core_needs_allowed_extensions_limited_by_tool = امکان شروع اسکن وجود ندارد، زمانی که تمام افزونه‌های موجود در این ابزار ({ $extensions }) از اسکن حذف شده‌اند core_needs_allowed_extensions = امکان شروع اسکن وجود ندارد، زمانی که تمام افزونه‌ها از اسکن حذف شده‌اند core_needs_to_set_at_least_one_broken_option = امکان شروع اسکن وجود ندارد، زمانی که گزینه "شکسته" برای اسکن تنظیم نشده باشد core_needs_to_set_at_least_one_bad_name_option = امکان شروع اسکن وجود ندارد، زمانی که گزینه نام نامناسب تنظیم نشده باشد تا برای اسکن جستجو شود core_ffmpeg_not_found = امکان یافتن یک نصب مناسب از FFmpeg یا FFprobe وجود ندارد. این‌ها برنامه‌های خارجی هستند که باید به صورت دستی نصب شوند. core_ffmpeg_not_found_windows = با توجه به نگارش همان طور که در متن داده شده است، مطمئن شوید که ffmpeg.exe و ffprobe.exe در PATH موجود هستند یا در آن پوشه که شامل اجرایabled exe اپلیکیشن است قرار داده شده‌اند core_invalid_symlink_infinite_recursion = بازگشت نامتناهی core_invalid_symlink_non_existent_destination = فایل مقصد مفقود core_messages_limit_reached_characters = تعداد پیام‌هایی که بیش از حاشیه مقرر ({ $current }/{ $limit } کاراکتر) بودند، باعث قطع شدن خروجی شد. برای مشاهده کامل خروجی، گزینه محدود سازی را در تنظیمات غیرفعال کنید. core_messages_limit_reached_lines = تعداد پیام‌ها حاشیه مقرر ({ $current }/{ $limit } 行) را بیشینه کرد، بنابراین خروجی کوتاه شده است. برای مطالعه خروجی کامل، گزینه محدود کردن را در تنظیمات غیرفعال کنید. core_error_moving_to_trash = خطا در منتقل کردن "{ $file }" به سبد حذف شد: { $error } core_error_removing = خطا در حذف "{ $file }": { $error } core_no_similarity_method_selected = فیلدهای موسیقی مشابه را بدون انتخاب روش مشابهی پیدا نمی‌توانید بیابید core_failed_to_spawn_command = ناموفق بود تا دستورالعمل تولید شود: { $reason } core_failed_to_check_process_status = ناموفق بودن بررسی وضعیت فرآیند: { $reason } core_failed_to_wait_for_process = ناموفق بود برای منتظر ماندن از فرآیند: { $reason } core_failed_to_read_video_properties = ناموفقیت در خواندن ویژگی‌های ویدیو: { $reason } core_failed_to_execute_ffmpeg = ناموفق بود تا ffmpeg اجرا شود: { $reason } core_ffmpeg_failed_with_status = ffmpeg با وضعیت { $status } ناموفق بود: { $stderr } (دستور: { $command }) core_failed_to_load_image_frame = ناموفقیت بارگذاری فریم تصویر: { $reason } core_failed_to_extract_frame = ناموفق بود دریافت فریم در { $time } ثانیه از "{ $file }": { $reason } core_failed_to_save_thumbnail = ناموفق بود برای ذخیره تصویر کوچک برای "{ $file }": { $reason } core_failed_get_frame_at_timestamp = ناموفق برای دریافت فریم در زمان‌بندی { $timestamp } از "{ $file }": { $reason } core_failed_get_frame_from_file = ناموفق برای دریافت فریم از "{ $file }" در زمان‌بندی { $timestamp }: { $reason } core_invalid_crop_rectangle = عدم معتبر بودن مستطیل کشتزار: چپ={ $left }، بالا={ $top }، راست={ $right }، پایین={ $bottom } core_failed_to_crop_video_file = فشل برش فایل ویدیویی "{ $file }": { $reason } core_cropped_video_not_created = فایل ویدیوی برش خورده ایجاد نشد: { $temp } core_unable_check_hash_of_file = امکان بررسی هش فایل "{ $file }" وجود ندارد، دلیل { $reason } core_error_checking_hash_of_file = خطای رخ داده هنگام بررسی هش فایل "{ $file }"، دلیل { $reason } core_image_zero_dimensions = تصویر دارای عرض یا ارتفاع صفر "{ $path }" core_image_open_failed = امکان باز کردن فایل تصویر "{ $path }": { $reason } core_not_directory_remove = در حال حذف پوشه "{ $path }" که یک دایرکتوری نیست core_cannot_read_directory = امکان خواندن دایرکتوری "{ $path }" وجود ندارد core_cannot_read_entry_from_directory = Could not read entry from directory "{ $path }" core_folder_contains_file_inside = فایل "{ $entry }" داخل پوشه "{ $folder }" وجود دارد core_unknown_directory_entry = امکان تعیین نوع فایل ورودی دایرکتوری "{ $entry }" داخل "{ $path }" وجود ندارد core_video_width_exceeds_limit = Video عرض { $width } از حد { $limit } تجاوز می‌کند core_video_height_exceeds_limit = Video ارتفاع { $height } از حد { $limit } تجاوز می‌کند core_failed_to_process_video = فشل پردازش فایل ویدیویی { $file }: { $reason } core_optimized_file_larger = فایل بهینه‌شده { $optimized } (حجم: { $new_size }) کوچکتر از فایل اصلی { $original } (حجم: { $original_size }) نیست core_unknown_codec = کدک ناشناخته: { $codec } core_invalid_video_optimizer_mode = حالت بهینه‌سازی ویدیو نامعتبر: '{ $mode }'. مقادیر مجاز: transcode, crop core_folder_does_not_exist = فোলدر وجود ندارد: { $folder } core_path_not_directory = مسیر معتبر نیست: { $folder } core_test_error_for_folder = خطای آزمایشی برای پوشه: { $folder } core_unknown_exif_tag_group = گروه برچسب EXIF ناشناخته: { $tag } core_error_comparing_fingerprints = خطای مقایسه اثر انگشت: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = ناموفق بود ایجاد پیش‌نمایه‌ی برای "{ $file }": فریم‌های استخراج‌شده ابعاد متفاوتی دارند core_failed_to_generate_thumbnail = ناموفق بود ایجاد پیش‌نمایه‌ی "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = ناموفق بود دریافت فریم در { $time } ثانیه از "{ $file }": { $reason } core_video_file_does_not_exist = فایل ویدیویی وجود ندارد (می‌توان آن را بین اسکن/مراحل بعدی حذف کرد): "{ $path }" core_image_too_large = تصویر خیلی بزرگ است ({ $width }x{ $height }) - بیش از حد مجاز { $max } پیکسل core_failed_to_get_video_metadata = ناموفق بود دریافت اطلاعات ویدئویی برای فایل "{ $file }": { $reason } core_failed_to_get_video_codec = ناموفق بود دریافت کدک ویدیویی برای فایل "{ $file }" core_failed_to_get_video_duration = ناموفق بود دریافت مدت زمان ویدیو برای فایل "{ $file }" core_failed_to_get_video_dimensions = ناموفق بود دریافت ابعاد ویدیو برای فایل "{ $file }" core_frame_dimensions_mismatch = ابعاد فریم برای زمان‌بندی { $timestamp } با ابعاد فریم اول ({ $first_w }x{ $first_h }) مطابقت ندارند core_failed_to_load_data_from_cache = ناموفق بود تا داده‌ها را از فایل کش { $file } بارگیری شود، دلیل { $reason } core_failed_to_load_data_from_json_cache = ناموفق بود تا داده‌ها را از فایل کش JSON { $file} بارگیری شود، دلیل { $reason } core_failed_to_replace_with_optimized = ناموفق بود فایل "{ $file }" با نسخه بهینه جایگزین شود: { $reason } core_failed_to_write_data_to_cache = امکان نوشتن داده‌ها به فایل کش "{ $file }" وجود ندارد، دلیل { $reason } core_properly_saved_cache_entries = ذخیره شده به درستی در فایل { $count } ورودی کش. core_video_processing_stopped_by_user = پردازش ویدیو توسط کاربر متوقف شد core_thumbnail_generation_stopped_by_user = تولید پیش‌نمایی متوقف شد توسط کاربر core_failed_to_optimize_video = ناموفق بود برای بهینه‌سازی ویدیو "{ $file }": { $reason } core_failed_to_crop_video = ناموفق بود برش ویدیو "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = ناموفق بود دریافت اطلاعات متا داده شده از فایل بهینه شده "{ $file }": { $reason } core_cannot_create_config_folder = امکان ایجاد پوشه تنظیمات "{ $folder }" وجود ندارد، دلیل { $reason } core_cannot_create_cache_folder = امکان ایجاد پوشه کش "{ $folder }" وجود ندارد، دلیل { $reason } core_cannot_create_or_open_cache_file = امکان ایجاد یا باز کردن فایل کش "{ $file }" وجود ندارد، دلیل { $reason } core_cannot_set_config_cache_path = امکان تنظیم مسیر config/cache وجود ندارد - config و cache استفاده نخواهند شد. core_invalid_extension_contains_space = { $extension } یک پسوند معتبر نیست زیرا حاوی فاصله خالی در داخل است core_invalid_extension_contains_dot = { $extension } یک پسوند معتبر نیست زیرا شامل نقطه داخل آن است czkawka_core-11.0.1/i18n/fr/czkawka_core.ftl000064400000000000000000000247551046102023000167240ustar 00000000000000# Core core_similarity_original = Originale core_similarity_very_high = Très haute core_similarity_high = Haute core_similarity_medium = Moyenne core_similarity_small = Basse core_similarity_very_small = Très basse core_similarity_minimal = Minimale core_cannot_open_dir = Impossible d’ouvrir le répertoire { $dir }. Raison : { $reason } core_cannot_read_entry_dir = Impossible de lire l'entrée dans le répertoire { $dir }. Raison : { $reason } core_cannot_read_metadata_dir = Impossible de lire les métadonnées dans le répertoire { $dir }. Raison  : { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Le fichier { $name } semble avoir été modifié avant l'époque Unix core_folder_modified_before_epoch = Le dossier { $name } semble avoir été modifié avant l'époque Unix core_file_no_modification_date = Impossible d'obtenir la date de modification du fichier { $name }. Raison  : { $reason } core_folder_no_modification_date = Impossible d'obtenir la date de modification du dossier { $name }. Raison : { $reason } core_cannot_start_scan_no_included_paths = Impossible de démarrer l'analyse, car il n'y a pas de chemins inclus core_skip_exist_check_all_included_paths_nonexistent = Impossible de démarrer l'analyse, car tous les chemins inclus n'existent pas core_missing_no_chosen_included_path = Aucune voie incluse valide n'a été choisie (les voies exclues auraient pu exclure toutes les voies incluses) core_reference_included_paths_same = Impossible de démarrer l'analyse où tous les chemins inclus valides sont également des chemins référencés, essayez de valider ou de désactiver les chemins référencés core_path_must_exists = Le chemin fourni doit exister, en ignorant { $path } core_must_be_directory_or_file = Le chemin fourni doit pointer vers un répertoire ou un fichier valide, en ignorant { $path } core_excluded_paths_pointless_slash = Exclure / est inutile, car cela signifie que aucun fichier ne sera scanné core_paths_unable_to_get_device_id = Impossible d’obtenir l’identifiant de l’appareil à partir du dossier { $path } core_needs_allowed_extensions_limited_by_tool = Impossible de démarrer l'analyse, lorsque toutes les extensions disponibles dans cet outil ({ $extensions }) ont été exclues de l'analyse core_needs_allowed_extensions = Impossible de démarrer l'analyse, lorsque toutes les extensions ont été exclues de l'analyse core_needs_to_set_at_least_one_broken_option = Impossible de démarrer l'analyse, lorsqu'aucune option de détection de panne n'est définie pour l'analyse core_needs_to_set_at_least_one_bad_name_option = Impossible de démarrer l'analyse, lorsqu'aucune option de mauvais nom n'est définie pour l'analyse core_ffmpeg_not_found = Impossible de trouver une installation appropriée de FFmpeg ou FFprobe. Ce sont des programmes externes qui doivent être installés manuellement. core_ffmpeg_not_found_windows = Assurez-vous que ffmpeg.exe et ffprobe.exe sont disponibles en PATH ou sont placés directement dans le même dossier que l'exécutable de l'application core_invalid_symlink_infinite_recursion = Récursion infinie core_invalid_symlink_non_existent_destination = Fichier de destination inexistant core_messages_limit_reached_characters = Le nombre de messages a dépassé la limite définie ({ $current }/{ $limit } caractères), donc la sortie a été tronquée. Pour lire la sortie complète, désactivez l'option de limitation dans les paramètres. core_messages_limit_reached_lines = Le nombre de messages a dépassé la limite définie (lignes{ $current }/{ $limit } ) donc la sortie a été tronquée. Pour lire la sortie complète, désactivez l'option de limitation dans les paramètres. core_error_moving_to_trash = Erreur lors du déplacement de "{ $file }" vers la poubelle : { $error } core_error_removing = Erreur lors de la suppression de "{ $file }": { $error } core_no_similarity_method_selected = Impossible de trouver des fichiers musicaux similaires sans une méthode de similarité sélectionnée core_ffmpeg_failed_with_status = ffmpeg a échoué avec le statut { $status } : { $stderr } (commande : { $command }) core_failed_to_load_image_frame = Erreur de chargement du cadre d'image : { $reason } core_failed_to_extract_frame = Échec de l'extraction du cadre à { $time } secondes depuis "{ $file }": { $reason } core_failed_to_save_thumbnail = Échec de sauvegarde de la miniature pour "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Échec de récupération du cadre au timestamp { $timestamp } depuis "{ $file }": { $reason } core_failed_get_frame_from_file = Échec de récupération du cadre à partir de "{ $file }" à l'horodatage { $timestamp } : { $reason } core_invalid_crop_rectangle = Rectangle de culture non valide : gauche={ $left }, haut={ $top }, droite={ $right }, bas={ $bottom } core_failed_to_crop_video_file = Échec du recadrage du fichier vidéo "{ $file }": { $reason } core_cropped_video_not_created = Fichier vidéo coupé non créé : { $temp } core_unable_check_hash_of_file = Impossible de vérifier le hachage du fichier "{ $file }", la raison est { $reason } core_error_checking_hash_of_file = Erreur survenue lors de la vérification du haché du fichier "{ $file }", la raison { $reason } core_image_zero_dimensions = Image a zéro largeur ou hauteur "{ $path }" core_image_open_failed = Impossible d'ouvrir le fichier image "{ $path }": { $reason } core_not_directory_remove = Essayer de supprimer le dossier "{ $path }" qui n'est pas un répertoire core_cannot_read_directory = Impossible de lire le répertoire "{ $path }" core_cannot_read_entry_from_directory = Impossible de lire l’entrée du répertoire "{ $path }" core_folder_contains_file_inside = Le dossier contient le fichier "{ $entry }" à l'intérieur "{ $folder }" core_unknown_directory_entry = Impossible de déterminer le type de fichier de l'entrée de répertoire "{ $entry }" dans "{ $path }" core_video_width_exceeds_limit = La largeur de la vidéo { $width } dépasse la limite de { $limit } core_video_height_exceeds_limit = Vidéo hauteur { $height } dépasse la limite de { $limit } core_failed_to_process_video = Échec du traitement du fichier vidéo { $file }: { $reason } core_unknown_codec = Codec inconnu : { $codec } core_invalid_video_optimizer_mode = Mode d'optimisation vidéo non valide : '{ $mode }'. Valeurs autorisées : transcode, crop core_folder_does_not_exist = Le dossier n’existe pas : { $folder } core_path_not_directory = Le chemin n'est pas un répertoire : { $folder } core_test_error_for_folder = Erreur de test pour le dossier : { $folder } core_unknown_exif_tag_group = Groupe de balises EXIF inconnu : { $tag } core_failed_to_generate_thumbnail_frames_different_dimensions = Échec de génération de miniature pour "{ $file }": les images extraites ont des dimensions différentes core_failed_to_generate_thumbnail = Échec de la génération de miniature pour "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Échec de l'extraction du cadre à { $time } secondes depuis "{ $file }": { $reason } core_video_file_does_not_exist = Fichier vidéo introuvable (peut être supprimé entre les étapes de numérisation/plus tard) : "{ $path }" core_image_too_large = L'image est trop grande ({ $width }x{ $height }) - plus que le supporté { $max } pixels core_failed_to_get_video_metadata = Échec de récupération des métadonnées vidéo pour le fichier "{ $file }": { $reason } core_failed_to_get_video_codec = Échec de récupération du codec vidéo pour le fichier "{ $file }" core_failed_to_get_video_duration = Impossible d’obtenir la durée de la vidéo pour le fichier "{ $file }" core_failed_to_get_video_dimensions = Impossible d'obtenir les dimensions de la vidéo pour le fichier "{ $file }" core_frame_dimensions_mismatch = Les dimensions du cadre pour le timestamp { $timestamp } ne correspondent pas aux dimensions du premier cadre ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Échec de chargement des données depuis le fichier de cache { $file }, la raison { $reason } core_failed_to_load_data_from_json_cache = Échec de chargement des données à partir du fichier de cache JSON { $file }, la raison { $reason } core_failed_to_replace_with_optimized = Échec du remplacement du fichier "{ $file }" par la version optimisée : { $reason } core_failed_to_write_data_to_cache = Impossible d'écrire des données dans le fichier de cache "{ $file }", la raison { $reason } core_properly_saved_cache_entries = Sauvegardé correctement dans le fichier { $count } entrées de cache. core_video_processing_stopped_by_user = Le traitement vidéo a été arrêté par l'utilisateur core_thumbnail_generation_stopped_by_user = La génération de miniatures a été arrêtée par l'utilisateur core_failed_to_optimize_video = Échec de l'optimisation de la vidéo "{ $file }": { $reason } core_failed_to_crop_video = Échec du recadrage de la vidéo "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Échec de récupération des métadonnées du fichier optimisé "{ $file }": { $reason } core_cannot_create_config_folder = Impossible de créer le dossier de configuration "{ $folder }", la raison est { $reason } core_cannot_create_cache_folder = Impossible de créer le dossier de cache "{ $folder }", la raison { $reason } core_cannot_create_or_open_cache_file = Impossible de créer ou d'ouvrir le fichier de cache "{ $file }", la raison { $reason } core_cannot_set_config_cache_path = Impossible de définir le chemin de config/cache - la config et le cache ne seront pas utilisés. core_invalid_extension_contains_space = { $extension } n'est pas une extension valide car elle contient des espaces vides à l'intérieur core_invalid_extension_contains_dot = { $extension } n'est pas une extension valide car elle contient un point à l'intérieur core_failed_to_spawn_command = Impossible de lancer la commande : { $reason } core_failed_to_check_process_status = Impossible de vérifier l'état du processus : { $reason } core_failed_to_wait_for_process = Impossible d'attendre la fin du processus : { $reason } core_failed_to_read_video_properties = Impossible de lire les propriétés de la vidéo : { $reason } core_failed_to_execute_ffmpeg = Impossible d'exécuter ffmpeg : { $reason } core_optimized_file_larger = Le fichier optimisé { $optimized } (taille : { $new_size }) n'est pas plus petit que le fichier original { $original } (taille : { $original_size }) core_error_comparing_fingerprints = Erreur lors de la comparaison des empreintes : { $reason }czkawka_core-11.0.1/i18n/it/czkawka_core.ftl000064400000000000000000000240601046102023000167160ustar 00000000000000# Core core_similarity_original = Originali core_similarity_very_high = Altissima core_similarity_high = Alta core_similarity_medium = Media core_similarity_small = Piccola core_similarity_very_small = Piccolissima core_similarity_minimal = Minima core_cannot_open_dir = Impossibile aprire cartella { $dir }, motivo { $reason } core_cannot_read_entry_dir = Impossibile leggere elemento nella cartella { $dir }, ragione { $reason } core_cannot_read_metadata_dir = Impossibile leggere metadati nella cartella { $dir }, ragione { $reason } core_cannot_read_metadata_file = Impossibile leggere i metadati del file { $file } , ragione { $reason } core_file_modified_before_epoch = Il file { $name } sembra essere stato modificato prima dell'Epoch Unix core_folder_modified_before_epoch = La cartella { $name } sembra essere stata modificata prima dell'Epoch Unix core_file_no_modification_date = Impossibile recuperare data di modifica dal file { $name }, ragione { $reason } core_folder_no_modification_date = Impossibile recuperare data di modifica dalla cartella { $name }, ragione { $reason } core_cannot_start_scan_no_included_paths = Impossibile avviare la scansione, perché non ci sono percorsi inclusi core_skip_exist_check_all_included_paths_nonexistent = Impossibile avviare la scansione, perché tutti i percorsi inclusi non esistono core_missing_no_chosen_included_path = Non è stato incluso nessun percorso valido (i percorsi esclusi potrebbero aver escluso tutti i percorsi inclusi) core_reference_included_paths_same = Impossibile avviare la scansione dove tutti i percorsi inclusi validi sono anche percorsi di riferimento, provare a ricontrollare o a disabilitare i percorsi di riferimento core_path_must_exists = Percorso fornito non esistente, ignoro { $path } core_must_be_directory_or_file = Il percorso fornito deve puntare a una cartella o file validi, ignoro { $path } core_excluded_paths_pointless_slash = Escludendo / è inutile, perché significa che nessun file verrà scansionato core_paths_unable_to_get_device_id = Impossibile ottenere l'id del dispositivo dalla cartella { $path } core_needs_allowed_extensions_limited_by_tool = Impossibile avviare la scansione, quando tutte le estensioni disponibili in questo strumento ({ $extensions }) sono state escluse dalla scansione core_needs_allowed_extensions = Impossibile avviare la scansione, quando tutte le estensioni sono state escluse dalla scansione core_needs_to_set_at_least_one_broken_option = Impossibile avviare la scansione, quando non è impostata l'opzione "broken" per la scansione core_needs_to_set_at_least_one_bad_name_option = Impossibile avviare la scansione, quando non è impostata l'opzione "nome errato" per la scansione core_ffmpeg_not_found = Non riesco a trovare un'installazione appropriata di FFmpeg o FFprobe. Questi sono programmi esterni che devono essere installati manualmente. core_ffmpeg_not_found_windows = Assicurati che ffmpeg.exe e ffprobe.exe siano disponibili in PATH o siano posizionati direttamente nella stessa cartella dell'eseguibile dell'app core_invalid_symlink_infinite_recursion = Ricorsione infinita core_invalid_symlink_non_existent_destination = File di destinazione inesistente core_messages_limit_reached_characters = Il numero di messaggi ha superato il limite impostato ( caratteri{ $current }/{ $limit } ), quindi l'output è stato troncato. Per leggere l'output completo, disabilitare l'opzione di limitazione nelle impostazioni. core_messages_limit_reached_lines = Il numero di messaggi ha superato il limite impostato ( linee{ $current }/{ $limit } ), quindi l'output è stato troncato. Per leggere l'output completo, disabilitare l'opzione di limitazione nelle impostazioni. core_error_moving_to_trash = Errore durante lo spostamento di "{ $file }" nel cestino: { $error } core_error_removing = Errore durante la rimozione "{ $file }": { $error } core_no_similarity_method_selected = Non riesco a trovare file musicali simili senza un metodo di similarità selezionato core_failed_to_spawn_command = Fallito generare comando: { $reason } core_failed_to_check_process_status = Impossibile controllare lo stato del processo: { $reason } core_failed_to_wait_for_process = Impossibile attendere il processo: { $reason } core_failed_to_read_video_properties = Impossibile leggere le proprietà del video: { $reason } core_failed_to_execute_ffmpeg = Impossibile eseguire ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg fallito con stato { $status }: { $stderr } (comando: { $command }) core_failed_to_load_image_frame = Impossibile caricare il frame dell'immagine: { $reason } core_failed_to_extract_frame = Fallito nell'estrarre il frame a { $time } secondi da "{ $file }": { $reason } core_failed_to_save_thumbnail = Impossibile salvare l'anteprima per "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Impossibile ottenere il frame al timestamp { $timestamp } da "{ $file }": { $reason } core_failed_get_frame_from_file = Impossibile ottenere il frame da "{ $file }" al timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Rettangolo di riempimento non valido: sinistra={ $left }, in alto={ $top }, destra={ $right }, in basso={ $bottom } core_failed_to_crop_video_file = Impossibile ritagliare il file video "{ $file }": { $reason } core_cropped_video_not_created = Il file video ritagliato non è stato creato: { $temp } core_unable_check_hash_of_file = Impossibile controllare l'hash del file "{ $file }", motivo { $reason } core_error_checking_hash_of_file = Errore avvenuto durante il controllo dell'hash del file "{ $file }", motivo { $reason } core_image_zero_dimensions = L'immagine ha zero larghezza o altezza "{ $path }" core_image_open_failed = Impossibile aprire il file immagine "{ $path }": { $reason } core_not_directory_remove = Tentativo di rimuovere la cartella "{ $path }" che non è una directory core_cannot_read_directory = Impossibile leggere la directory "{ $path }" core_cannot_read_entry_from_directory = Impossibile leggere l'entrata dal directory "{ $path }" core_folder_contains_file_inside = La cartella contiene il file "{ $entry }" all'interno di "{ $folder }" core_unknown_directory_entry = Impossibile determinare il tipo di file dell'inserimento della directory "{ $entry }" all'interno di "{ $path }" core_video_width_exceeds_limit = Video larghezza { $width } supera il limite di { $limit } core_video_height_exceeds_limit = Video altezza { $height } supera il limite di { $limit } core_failed_to_process_video = Impossibile elaborare il file video { $file }: { $reason } core_optimized_file_larger = File ottimizzato { $optimized } (dimensione: { $new_size }) non è più piccolo dell'originale { $original } (dimensione: { $original_size }) core_unknown_codec = Codec sconosciuto: { $codec } core_invalid_video_optimizer_mode = Modalità ottimizzatore video non valida: '{ $mode }'. Valori ammessi: transcodifica, ritaglio core_folder_does_not_exist = La cartella non esiste: { $folder } core_path_not_directory = Il percorso non è una directory: { $folder } core_test_error_for_folder = Errore di test per cartella: { $folder } core_unknown_exif_tag_group = Gruppo di tag EXIF sconosciuto: { $tag } core_error_comparing_fingerprints = Errore durante il confronto delle impronte digitali: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Impossibile generare l'anteprima per "{ $file }": i fotogrammi estratti hanno dimensioni diverse core_failed_to_generate_thumbnail = Impossibile generare l'anteprima per "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Fallito nell'estrarre il frame a { $time } secondi da "{ $file }": { $reason } core_video_file_does_not_exist = File video non esistente (può essere rimosso tra le fasi di scansione/successive): "{ $path }" core_image_too_large = L'immagine è troppo grande ({ $width }x{ $height }) - più di { $max } pixel supportati core_failed_to_get_video_metadata = Impossibile ottenere i metadati video per il file "{ $file }": { $reason } core_failed_to_get_video_codec = Impossibile ottenere il codec video per il file "{ $file }" core_failed_to_get_video_duration = Impossibile ottenere la durata del video per il file "{ $file }" core_failed_to_get_video_dimensions = Impossibile ottenere le dimensioni del video per il file "{ $file }" core_frame_dimensions_mismatch = Dimensioni del fotogramma per timestamp { $timestamp } non corrispondono alle dimensioni del primo fotogramma ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Impossibile caricare i dati dal file di cache { $file }, motivo { $reason } core_failed_to_load_data_from_json_cache = Impossibile caricare i dati dal file di cache json { $file }, motivo { $reason } core_failed_to_replace_with_optimized = Impossibile sostituire il file "{ $file }" con la versione ottimizzata: { $reason } core_failed_to_write_data_to_cache = Impossibile scrivere i dati nel file di cache "{ $file }", motivo { $reason } core_properly_saved_cache_entries = Salvatato correttamente nel file { $count } voci di cache. core_video_processing_stopped_by_user = L'elaborazione video è stata interrotta dall'utente core_thumbnail_generation_stopped_by_user = Generazione miniatura interrotta dall'utente core_failed_to_optimize_video = Impossibile ottimizzare il video "{ $file }": { $reason } core_failed_to_crop_video = Impossibile ritagliare il video "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Impossibile ottenere i metadati del file ottimizzato "{ $file }": { $reason } core_cannot_create_config_folder = Impossibile creare la cartella di configurazione "{ $folder }", motivo { $reason } core_cannot_create_cache_folder = Impossibile creare la cartella di cache "{ $folder }", motivo { $reason } core_cannot_create_or_open_cache_file = Impossibile creare o aprire il file di cache "{ $file }", motivo { $reason } core_cannot_set_config_cache_path = Impossibile impostare il percorso config/cache - config e cache non verranno utilizzati. core_invalid_extension_contains_space = { $extension } non è un'estensione valida perché contiene spazi vuoti all'interno core_invalid_extension_contains_dot = { $extension } non è un'estensione valida perché contiene un punto all'interno czkawka_core-11.0.1/i18n/ja/czkawka_core.ftl000064400000000000000000000270371046102023000167030ustar 00000000000000# Core core_similarity_original = 新規に作成 core_similarity_very_high = 非常に高い core_similarity_high = 高い core_similarity_medium = ミディアム core_similarity_small = 小 core_similarity_very_small = 非常に小さい core_similarity_minimal = 最小 core_cannot_open_dir = ディレクトリを開くことができません { $dir }、理由 { $reason } core_cannot_read_entry_dir = Dir { $dir } でエントリを読み込めません、理由 { $reason } core_cannot_read_metadata_dir = Dir { $dir } でメタデータを読み込めません、理由 { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = ファイル { $name } は Unix Epoch より前に変更されているようです core_folder_modified_before_epoch = フォルダ { $name } は、Unix Epoch の前に変更されているようです core_file_no_modification_date = ファイル { $name } から変更日を取得できません、理由 { $reason } core_folder_no_modification_date = フォルダ { $name } から変更日を取得できません、理由 { $reason } core_cannot_start_scan_no_included_paths = スキャンを開始できません、含まれているパスがないため。 core_skip_exist_check_all_included_paths_nonexistent = スキャンを開始できません。指定されたすべてのパスが存在しません。 core_missing_no_chosen_included_path = 有効な含まれるパスが選択されませんでした(除外されたパスがすべての含まれるパスを除外した可能性があります) core_reference_included_paths_same = スキャンを開始できません。すべての有効な含まれるパスが参照パスでもある場合、検証を試みてください、または参照パスを無効化してください。 core_path_must_exists = 提供されたパスが存在しなければなりません、{ $path } を無視して core_must_be_directory_or_file = 提供されたパスは、有効なディレクトリまたはファイルに指し示す必要があり、{ $path } を無視します。 core_excluded_paths_pointless_slash = 除外 / は無意味で、ファイルがスキャンされないことを意味するからです core_paths_unable_to_get_device_id = フォルダ { $path } からデバイスIDを取得できません core_needs_allowed_extensions_limited_by_tool = スキャンを開始できません。このツール ({ $extensions }) に存在するすべての拡張機能を除外しても。 core_needs_allowed_extensions = スキャンを開始できません。すべての拡張機能をスキャンから除外したとき core_needs_to_set_at_least_one_broken_option = スキャンを開始できません。破損オプションが設定されていない場合に発生します。 core_needs_to_set_at_least_one_bad_name_option = スキャンを開始できません。悪い名前オプションが設定されていない場合にのみスキャンします。 core_ffmpeg_not_found = FFmpegまたはFFprobeの適切なインストールを見つけられません。これらは外部プログラムであり、手動でインストールする必要があります。. core_ffmpeg_not_found_windows = ffmpeg.exeとffprobe.exeがPATHで使用できるか、アプリ実行ファイルと同じフォルダに直接配置されていることを確認してください core_invalid_symlink_infinite_recursion = 無限再帰性 core_invalid_symlink_non_existent_destination = 保存先ファイルが存在しません core_messages_limit_reached_characters = メッセージ数が設定された制限({ $current }/{ $limit } 文字)を超えたため、出力は切り捨てられました。 フル出力を読み込むには、設定で制限オプションを無効にします。. core_messages_limit_reached_lines = メッセージ数が設定された制限({ $current }/{ $limit } 行)を超えたため、出力は切り捨てられました。 フル出力を読み込むには、設定で制限オプションを無効にします。. core_error_moving_to_trash = "{ $file }" をゴミ箱に移動中にエラーが発生しました: { $error } core_error_removing = エラーを削除中に "{ $file }" で発生しました: { $error } core_no_similarity_method_selected = 類似の音楽ファイルを見つけることができません。選択された類似性方法がない場合 core_failed_to_spawn_command = コマンドの生成に失敗しました:{ $reason } core_failed_to_check_process_status = プロセス状態の確認に失敗しました:{ $reason } core_failed_to_wait_for_process = プロセスを待機できませんでした:{ $reason } core_failed_to_read_video_properties = ビデオプロパティの読み込みに失敗しました: { $reason } core_failed_to_execute_ffmpeg = ffmpegの実行に失敗しました:{ $reason } core_ffmpeg_failed_with_status = ffmpeg はステータス { $status } で失敗しました: { $stderr } (コマンド: { $command }) core_failed_to_load_image_frame = 画像フレームの読み込みに失敗しました:{ $reason } core_failed_to_extract_frame = { $time }秒でフレームを抽出できませんでした。「{ $file }」から:{ $reason } core_failed_to_save_thumbnail = サムネイルを "{ $file }" のために保存できませんでした:{ $reason } core_failed_get_frame_at_timestamp = タイムスタンプ { $timestamp } から "{ $file }" のフレームを取得できませんでした:{ $reason } core_failed_get_frame_from_file = "{ $file }" からフレームを取得できませんでした。タイムスタンプ { $timestamp }、理由 { $reason }。 core_invalid_crop_rectangle = 無効な作物矩形:左={ $left }、上={ $top }、右={ $right }、下={ $bottom } core_failed_to_crop_video_file = ビデオファイル "{ $file }" のトリミングに失敗しました:{ $reason } core_cropped_video_not_created = 切り抜かれた動画ファイルが作成されませんでした:{ $temp } core_unable_check_hash_of_file = ファイル "{ $file }" のハッシュを確認できません。理由 { $reason } core_error_checking_hash_of_file = ファイル "{ $file }" のハッシュチェック時にエラーが発生しました、理由 { $reason } core_image_zero_dimensions = 画像はゼロの幅または高さ "{ $path }" core_image_open_failed = 画像ファイル "{ $path }" を開けません:{ $reason } core_not_directory_remove = フォルダ "{ $path }" を削除しようとしています。これはディレクトリではありません。 core_cannot_read_directory = "{ $path }" を読み取れません core_cannot_read_entry_from_directory = ディレクトリ "{ $path }" からエントリを読み取ることができません。 core_folder_contains_file_inside = フォルダ内にファイル "{ $entry }" が "{ $folder }" 内に存在します。 core_unknown_directory_entry = ディレクトリエントリ "{ $entry }" のファイルタイプを "{ $path }" 内で判別できません。 core_video_width_exceeds_limit = 動画の幅 { $width } は { $limit } の制限を超えています core_video_height_exceeds_limit = 動画の高さ { $height } は { $limit } の制限を超えています core_failed_to_process_video = ビデオファイル { $file } の処理に失敗しました: { $reason } core_optimized_file_larger = 最適化ファイル { $optimized } (サイズ: { $new_size }) は、元の { $original } (サイズ: { $original_size }) よりも小さくありません。 core_unknown_codec = 不明コーデック:{ $codec } core_invalid_video_optimizer_mode = 無効なビデオ最適化モード:'{ $mode }'。許可される値:transcode, crop core_folder_does_not_exist = フォルダが存在しません: { $folder } core_path_not_directory = パスはディレクトリではありません:{ $folder } core_test_error_for_folder = フォルダのテストエラー:{ $folder } core_unknown_exif_tag_group = 不明EXIFタググループ:{ $tag } core_error_comparing_fingerprints = 指紋の比較中にエラー:{ $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = "{ $file }" のサムネイルを生成できませんでした:抽出されたフレームの寸法が異なります core_failed_to_generate_thumbnail = "{ $file }" のサムネイルの生成に失敗しました:{ $reason } core_failed_to_extract_frame_at_seek_time = { $time }秒でフレームを抽出できませんでした。「{ $file }」から:{ $reason } core_video_file_does_not_exist = ビデオファイルが存在しません(スキャン/後続ステップ間で削除しても構いません):"{ $path }" core_image_too_large = 画像が大きすぎです ({ $width }x{ $height }) - { $max }ピクセルを超えています core_failed_to_get_video_metadata = ファイル "{ $file }" のビデオメタデータを取得できませんでした:{ $reason } core_failed_to_get_video_codec = ファイル "{ $file }" のビデオコーデックを取得できませんでした。 core_failed_to_get_video_duration = ファイル "{ $file }" の動画の期間を取得できませんでした。 core_failed_to_get_video_dimensions = ファイル "{ $file }" のビデオ寸法を取得できませんでした。 core_frame_dimensions_mismatch = タイムスタンプ { $timestamp } のフレーム寸法と、最初のフレーム寸法 ({ $first_w }x{ $first_h }) が一致しません。 core_failed_to_load_data_from_cache = キャッシュファイル { $file } からデータ読み込みに失敗しました、理由 { $reason } core_failed_to_load_data_from_json_cache = JSONキャッシュファイル { $file } からデータ読み込みに失敗しました。理由 { $reason } core_failed_to_replace_with_optimized = ファイル "{ $file }" を最適化バージョンで置き換えに失敗しました: { $reason } core_failed_to_write_data_to_cache = キャッシュファイル "{ $file }" へのデータ書き込みに失敗しました、理由 { $reason } core_properly_saved_cache_entries = ファイルに正しく保存されました { $count } 件のキャッシュエントリ。. core_video_processing_stopped_by_user = ビデオ処理はユーザーによって停止されました core_thumbnail_generation_stopped_by_user = サムネイル生成はユーザーによって停止されました core_failed_to_optimize_video = ビデオの最適化に失敗しました "{ $file }": { $reason } core_failed_to_crop_video = ビデオのトリミングに失敗しました "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = 最適化されたファイル "{ $file }" のメタデータ取得に失敗しました:{ $reason } core_cannot_create_config_folder = 設定ファイル "{ $folder }" を作成できません。理由 { $reason } です。 core_cannot_create_cache_folder = キャッシュフォルダ "{ $folder }" を作成できません。理由 { $reason } core_cannot_create_or_open_cache_file = キャッシュファイル "{ $file }" を作成または開けません。理由 { $reason } core_cannot_set_config_cache_path = 設定/キャッシュのパスを設定できません - 設定とキャッシュは使用されません。. core_invalid_extension_contains_space = { $extension } は有効な拡張子ではありません。なぜなら、中に空白が含まれているからです。 core_invalid_extension_contains_dot = { $extension } は有効な拡張子ではありません。なぜなら、中にドットが含まれているからです。 czkawka_core-11.0.1/i18n/ko/czkawka_core.ftl000064400000000000000000000245451046102023000167230ustar 00000000000000# Core core_similarity_original = 원본 core_similarity_very_high = 매우 높음 core_similarity_high = 높음 core_similarity_medium = 보통 core_similarity_small = 낮음 core_similarity_very_small = 매우 낮음 core_similarity_minimal = 최소 core_cannot_open_dir = { $dir } 디렉터리를 열 수 없습니다. 이유: { $reason } core_cannot_read_entry_dir = { $dir } 디렉터리를 열 수 없습니다. 이유: { $reason } core_cannot_read_metadata_dir = { $dir } 디렉터리의 메타데이터를 열 수 없습니다. 이유: { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = File { $name } seems to have been modified before the Unix Epoch core_folder_modified_before_epoch = Folder { $name } seems to have been modified before the Unix Epoch core_file_no_modification_date = { $name } 파일의 수정된 시각을 읽을 수 없습니다. 이유: { $reason } core_folder_no_modification_date = { $name } 폴더의 수정된 시각을 읽을 수 없습니다. 이유: { $reason } core_cannot_start_scan_no_included_paths = 스캔을 시작할 수 없습니다, 포함된 경로가 없습니다 core_skip_exist_check_all_included_paths_nonexistent = 스캔을 시작할 수 없습니다, 모든 포함된 경로가 존재하지 않기 때문입니다 core_missing_no_chosen_included_path = 유효한 포함된 경로가 선택되지 않았습니다(제외된 경로가 모든 포함된 경로를 배제했을 수 있습니다) core_reference_included_paths_same = 모든 유효한 포함된 경로가 참조 경로로도 참조되는 경우 스캔을 시작할 수 없습니다. 유효성을 검사하거나 참조 경로를 비활성화하십시오 core_path_must_exists = 제공된 경로가 존재해야 하며, { $path } 무시합니다 core_must_be_directory_or_file = 제공된 경로가 유효한 디렉터리 또는 파일에 가리키도록 해야 하며, { $path } 무시합니다 core_excluded_paths_pointless_slash = 제외 / 는 무의미하며, 이는 파일이 스캔되지 않음을 의미하기 때문입니다 core_paths_unable_to_get_device_id = 폴더 { $path } 에서 장치 ID를 가져올 수 없음 core_needs_allowed_extensions_limited_by_tool = 스캔을 시작할 수 없습니다, 이 도구({ $extensions })에 있는 모든 확장 기능이 스캔에서 제외되었기 때문입니다 core_needs_allowed_extensions = 스캔 시작할 수 없습니다, 모든 확장 프로그램이 스캔에서 제외되었을 때 core_needs_to_set_at_least_one_broken_option = 스캔을 시작할 수 없습니다, 손상 옵션이 스캔하도록 설정되지 않았을 때 core_needs_to_set_at_least_one_bad_name_option = 스캔을 시작할 수 없습니다, 잘못된 이름 옵션이 스캔하도록 설정되지 않았을 때 core_ffmpeg_not_found = FFmpeg 또는 FFprobe의 적절한 설치 파일을 찾을 수 없습니다. 이러한 프로그램들은 수동으로 설치해야 합니다. core_ffmpeg_not_found_windows = ffmpeg.exe와 ffprobe.exe가 PATH에 있거나 앱 실행 파일과 같은 폴더에 직접 배치되어 있는지 확인하세요 core_invalid_symlink_infinite_recursion = 무한 재귀 core_invalid_symlink_non_existent_destination = 목표 파일이 없음 core_messages_limit_reached_characters = Number of messages exceeded the set limit ({ $current }/{ $limit } characters), so the output was truncated. To read the full output, disable the limiting option in settings. core_messages_limit_reached_lines = Number of messages exceeded the set limit ({ $current }/{ $limit } lines), so the output was truncated. To read the full output, disable the limiting option in settings. core_error_moving_to_trash = "{ $file }"를 쓰레기통으로 옮길 때 오류가 발생했습니다: { $error } core_error_removing = "{ $file }" 삭제 중 오류: { $error } core_no_similarity_method_selected = 유형을 선택하지 않았기 때문에 유사한 음악 파일을 찾을 수 없습니다 core_failed_to_spawn_command = 명령어 생성 실패: { $reason } core_failed_to_check_process_status = 프로세스 상태 확인 실패: { $reason } core_failed_to_wait_for_process = 프로세스 대기 실패: { $reason } core_failed_to_read_video_properties = 비디오 속성 읽기 실패: { $reason } core_failed_to_execute_ffmpeg = ffmpeg 실행 실패: { $reason } core_ffmpeg_failed_with_status = ffmpeg 실패했습니다 상태 { $status }: { $stderr } (명령: { $command }) core_failed_to_load_image_frame = 이미지 프레임을 로드하지 못했습니다: { $reason } core_failed_to_extract_frame = 실패했습니다: { $time } 초에서 "{ $file }"에서 프레임을 추출하지 못했습니다: { $reason } core_failed_to_save_thumbnail = 썸네일 { $file } 저장 실패: { $reason } core_failed_get_frame_at_timestamp = 실패했습니다. 타임스탬프 { $timestamp }에서 "{ $file }"에서 프레임을 가져오지 못했습니다: { $reason } core_failed_get_frame_from_file = "{ $file }"에서 프레임을 가져오지 못했습니다. 타임스탬프 { $timestamp }: { $reason } core_invalid_crop_rectangle = 유효하지 않은 래스터 영역: left={ $left }, top={ $top }, right={ $right }, bottom={ $bottom } core_failed_to_crop_video_file = 비디오 파일 "{ $file }" 자르기 실패: { $reason } core_cropped_video_not_created = 잘린 비디오 파일이 생성되지 않았습니다: { $temp } core_unable_check_hash_of_file = 파일 해시 확인 불가 { $file }, 이유 { $reason } core_image_zero_dimensions = 이미지의 너비 또는 높이는 0입니다 "{ $path }" core_image_open_failed = 불가능: "{ $path }" 이미지 파일을 열 수 없습니다. { $reason } core_not_directory_remove = 폴더 "{ $path }"을 제거하려고 하는데, 디렉터리가 아닙니다 core_cannot_read_directory = "{ $path }"를 읽을 수 없습니다 core_cannot_read_entry_from_directory = 디렉토리 "{ $path }"에서 항목을 읽을 수 없습니다 core_folder_contains_file_inside = 폴더 안에 파일 "{ $entry }" 가 "{ $folder }" 안에 있습니다 core_unknown_directory_entry = 디렉토리 항목 "{ $entry }"의 파일 유형을 "{ $path }" 내에서 확인할 수 없음 core_video_width_exceeds_limit = 비디오 너비 { $width } 가 { $limit } 의 제한을 초과합니다 core_video_height_exceeds_limit = 비디오 높이 { $height }는 { $limit }의 제한을 초과합니다 core_failed_to_process_video = 비디오 파일 { $file } 처리 실패: { $reason } core_optimized_file_larger = 최적화된 파일 { $optimized } (크기: { $new_size }) 는 원본 { $original } (크기: { $original_size }) 보다 작지 않습니다 core_unknown_codec = 알 수 없는 코덱: { $codec } core_invalid_video_optimizer_mode = 유효하지 않은 비디오 최적화 모드: '{ $mode }'. 허용 값: transcode, crop core_folder_does_not_exist = 폴더가 존재하지 않습니다: { $folder } core_path_not_directory = 경로는 디렉터리가 아닙니다: { $folder } core_test_error_for_folder = 폴더 오류 테스트: { $folder } core_unknown_exif_tag_group = 알 수 없는 EXIF 태그 그룹: { $tag } core_error_comparing_fingerprints = 지문 비교 중 오류: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = "{ $file }"에 대한 썸네일 생성 실패: 추출된 프레임의 차원이 다릅니다 core_failed_to_generate_thumbnail = "{ $file }": { $reason } 생성을 실패했습니다 core_failed_to_extract_frame_at_seek_time = 실패했습니다: { $time } 초에서 "{ $file }"에서 프레임을 추출하지 못했습니다: { $reason } core_video_file_does_not_exist = 비디오 파일이 존재하지 않습니다 (스캔/후속 단계 사이에 제거할 수 있음): "{ $path }" core_image_too_large = 이미지가 너무 큽니다 ({ $width }x{ $height }) - { $max } 픽셀 이상을 지원하지 않습니다 core_failed_to_get_video_metadata = 파일 "{ $file }"의 비디오 메타데이터를 가져오지 못했습니다: { $reason } core_failed_to_get_video_codec = 파일 "{ $file }"의 비디오 코덱을 가져오지 못했습니다 core_failed_to_get_video_duration = 파일 "{ $file }"의 비디오 길이 가져오기 실패 core_failed_to_get_video_dimensions = 파일 "{ $file }"의 비디오 치수를 가져오지 못했습니다 core_frame_dimensions_mismatch = 프레임 치수 타임스탬프 { $timestamp }와 첫 번째 프레임 치수 ({ $first_w }x{ $first_h })가 일치하지 않습니다 core_failed_to_load_data_from_cache = 캐시 파일 { $file } 로부터 데이터를 로드하지 못했습니다. 이유 { $reason } core_failed_to_load_data_from_json_cache = JSON 캐시 파일 { $file } 로부터 데이터 로드 실패, 이유 { $reason } core_failed_to_replace_with_optimized = 파일 "{ $file }"을 최적화된 버전으로 대체하지 못했습니다: { $reason } core_failed_to_write_data_to_cache = 캐시 파일 "{ $file }"에 데이터를 쓸 수 없습니다, 이유 { $reason } core_properly_saved_cache_entries = 파일에 제대로 저장됨 { $count } 캐시 항목. core_video_processing_stopped_by_user = 사용자가 비디오 처리 중단을 중단했습니다 core_thumbnail_generation_stopped_by_user = 썸네일 생성 중단됨 core_failed_to_optimize_video = 비디오 최적화 실패 "{ $file }": { $reason } core_failed_to_crop_video = 비디오 자르기 실패 "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = 최적화된 파일 "{ $file }"의 메타데이터를 가져오지 못했습니다: { $reason } core_cannot_create_config_folder = 설정 폴더 "{ $folder }"를 생성할 수 없습니다, 이유 { $reason } core_cannot_create_cache_folder = 캐시 폴더 "{ $folder }"를 생성할 수 없습니다, 이유 { $reason } core_cannot_create_or_open_cache_file = 캐시 파일 "{ $file }"를 생성하거나 열 수 없습니다, 이유 { $reason } core_cannot_set_config_cache_path = 설정/캐시 경로 설정 불가 - 설정 및 캐시는 사용되지 않습니다. core_invalid_extension_contains_space = { $extension }는 유효하지 않은 확장자입니다. 확장자 안에 빈 공간이 있기 때문입니다 core_invalid_extension_contains_dot = { $extension }는 유효하지 않은 확장자입니다. 확장자 안에 점이 포함되어 있기 때문입니다 core_error_checking_hash_of_file = 파일 "{ $file }"의 해시 값을 확인하는 과정에서 오류가 발생했습니다. 원인: { $reason }czkawka_core-11.0.1/i18n/nl/czkawka_core.ftl000064400000000000000000000233751046102023000167230ustar 00000000000000# Core core_similarity_original = Origineel core_similarity_very_high = Zeer hoog core_similarity_high = hoog core_similarity_medium = Middelgroot core_similarity_small = Klein core_similarity_very_small = Zeer Klein core_similarity_minimal = Minimaal core_cannot_open_dir = Kan dir { $dir }niet openen, reden { $reason } core_cannot_read_entry_dir = Kan invoer niet lezen in map { $dir }, reden { $reason } core_cannot_read_metadata_dir = Kan metadata niet lezen in map { $dir }, reden { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Het bestand { $name } lijkt aangepast te zijn voor Unix Epoch core_folder_modified_before_epoch = Map { $name } lijkt gewijzigd te zijn voor Unix Epoch core_file_no_modification_date = Niet in staat om de datum van bestand { $name }te krijgen, reden { $reason } core_folder_no_modification_date = Niet in staat om wijzigingsdatum van map { $name }te krijgen, reden { $reason } core_cannot_start_scan_no_included_paths = Kan de scan niet starten, omdat er geen opgenomen paden zijn core_skip_exist_check_all_included_paths_nonexistent = Kan de scan niet starten, omdat alle opgenomen paden niet bestaan core_missing_no_chosen_included_path = Geen geldige opgenomen pad werd gekozen (uitgesloten paden konden alle opgenomen paden uitsluiten) core_reference_included_paths_same = Kan geen scan starten waar alle geldige opgenomen paden ook naar verwijzingen paden zijn, probeer te valideren of verwijzingen paden uitschakelen core_excluded_paths_pointless_slash = Uitsluiten / is zinloos, omdat het betekent dat er geen bestanden zullen worden gescand core_needs_allowed_extensions_limited_by_tool = Kan de scan niet starten, wanneer alle extensies beschikbaar in dit hulpmiddel ({ $extensions }) zijn uitgesloten van de scan core_needs_allowed_extensions = Kan de scan niet starten, wanneer alle extensies zijn uitgesloten van de scan core_needs_to_set_at_least_one_broken_option = Kan geen scan starten, wanneer er geen gebroken optie is ingesteld om te scannen core_needs_to_set_at_least_one_bad_name_option = Kan de scan niet starten, wanneer er geen optie “slecht naam” is ingesteld om naar te scannen core_ffmpeg_not_found = Kan geen juiste installatie van FFmpeg of FFprobe vinden. Dit zijn externe programma's die handmatig geïnstalleerd moeten worden. core_ffmpeg_not_found_windows = Zorg ervoor dat ffmpeg.exe en ffprobe.exe beschikbaar zijn in PATH of direct in dezelfde map geplaatst zijn als de app uitvoerbaar core_invalid_symlink_infinite_recursion = Oneindige recursie core_invalid_symlink_non_existent_destination = Niet-bestaand doelbestand core_messages_limit_reached_characters = Het aantal berichten overschrijdt de ingestelde limiet ({ $current }/{ $limit } karakters), zodat de uitvoer is afgebroken. Om de volledige uitvoer te lezen, schakel de beperkende optie uit in de instellingen. core_messages_limit_reached_lines = Het aantal berichten overschrijdt de ingestelde limiet ({ $current }/{ $limit } lijnen), waardoor de uitvoer is afgebrokkeld. Om de volledige uitvoer te lezen, schakel de beperkende optie uit in de instellingen. core_error_moving_to_trash = Fout bij het verplaatsen van "{ $file }" naar de prullenbak: { $error } core_error_removing = Fout bij het verwijderen van "{ $file }": { $error } core_no_similarity_method_selected = Kan geen soortgelijke muziekbestanden vinden zonder een geselecteerde similariteitsmethode core_failed_to_spawn_command = Faillissement van het opstarten van het commando: { $reason } core_ffmpeg_failed_with_status = ffmpeg faalde met status { $status }: { $stderr } (command: { $command }) core_failed_to_extract_frame = Faalde om frame te extraheren op { $time } seconden van "{ $file }": { $reason } core_failed_to_save_thumbnail = Faillissement van miniature opslaan voor "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Faalde bij het ophalen van frame op timestamp { $timestamp } van "{ $file }": { $reason } core_failed_get_frame_from_file = Faalde bij het ophalen van frame van "{ $file }" op timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Ongeldige oogstrechthoek: links={ $left }, boven={ $top }, rechts={ $right }, onder={ $bottom } core_failed_to_crop_video_file = Faillissement van video bestand "{ $file }": { $reason } core_cropped_video_not_created = Verwijderde videobestand is niet aangemaakt: { $temp } core_unable_check_hash_of_file = Kan hash van bestand "{ $file }" niet controleren, reden { $reason } core_error_checking_hash_of_file = Fout opgetreden bij het controleren van de hash van bestand "{ $file }", reden { $reason } core_image_open_failed = Kan bestand niet openen "{ $path }": { $reason } core_not_directory_remove = Proberen om map "{ $path }" te verwijderen, wat geen directory is core_cannot_read_directory = Kan de directory "{ $path }" niet lezen core_cannot_read_entry_from_directory = Kan geen vermelding lezen uit de map "{ $path }" core_folder_contains_file_inside = Map bevat bestand "{ $entry }" in "{ $folder }" core_unknown_directory_entry = Kan het type bestand van de directory-entry "{ $entry }" niet bepalen binnen "{ $path }" core_video_width_exceeds_limit = Video breedte { $width } overschrijdt de limiet van { $limit } core_video_height_exceeds_limit = Video hoogte { $height } overschrijdt de limiet van { $limit } core_failed_to_process_video = Faalde bij het verwerken van videobestand { $file }: { $reason } core_optimized_file_larger = Geoptimaliseerd bestand { $optimized } (grootte: { $new_size }) is niet kleiner dan origineel { $original } (grootte: { $original_size }) core_unknown_codec = Onbekend codec: { $codec } core_invalid_video_optimizer_mode = Ongeldige videobesturing modus: '{ $mode }'. Toegestane waarden: transcode, crop core_folder_does_not_exist = Map niet bestaan: { $folder } core_path_not_directory = Pad is geen directory: { $folder } core_test_error_for_folder = Test fout voor map: { $folder } core_unknown_exif_tag_group = Onbekende EXIF tag groep: { $tag } core_failed_to_generate_thumbnail_frames_different_dimensions = Faalde bij het genereren van de miniaturen voor "{ $file }": de geëxtraheerde frames hebben verschillende afmetingen core_failed_to_generate_thumbnail = Faalde bij het genereren van miniaturen voor "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Faalde om frame te extraheren op { $time } seconden van "{ $file }": { $reason } core_video_file_does_not_exist = Bestand bestaat niet (kan worden verwijderd tussen scan/latere stappen): "{ $path }" core_image_too_large = Afbeelding is te groot ({ $width }x{ $height }) - meer dan ondersteund { $max } pixels core_failed_to_get_video_metadata = Faalde bij het ophalen van videometadeta voor bestand "{ $file }": { $reason } core_failed_to_get_video_codec = Faalde bij het ophalen van de videocodec voor bestand "{ $file }" core_failed_to_get_video_duration = Kon geen duur van het bestand "{ $file }" ophalen core_failed_to_get_video_dimensions = Faalde bij het ophalen van de videogrootte voor bestand "{ $file }" core_frame_dimensions_mismatch = Afmetingen van het frame voor timestamp { $timestamp } komen niet overeen met de afmetingen van het eerste frame ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Faalde bij het laden van gegevens uit cachebestand { $file }, reden { $reason } core_failed_to_load_data_from_json_cache = Faalde bij het laden van gegevens uit JSON cachebestand { $file }, reden { $reason } core_failed_to_replace_with_optimized = Faalde bij het vervangen van bestand "{ $file }" met de geoptimaliseerde versie: { $reason } core_failed_to_write_data_to_cache = Kan geen gegevens schrijven naar cachebestand "{ $file }", reden { $reason } core_properly_saved_cache_entries = Correct opgeslagen naar bestand { $count } cache-items. core_video_processing_stopped_by_user = Video verwerking is gestopt door gebruiker core_thumbnail_generation_stopped_by_user = Thumbnail generatie is gestopt door gebruiker core_failed_to_optimize_video = Faalde het bij het optimaliseren van video "{ $file }": { $reason } core_failed_to_crop_video = Failliet video snijden "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Faalde bij het ophalen van metadata van de geoptimaliseerde bestand "{ $file }": { $reason } core_cannot_create_config_folder = Kan configuratiefolder "{ $folder }" niet aanmaken, reden { $reason } core_cannot_create_cache_folder = Kan cache map "{ $folder }" niet aanmaken, reden { $reason } core_cannot_create_or_open_cache_file = Kan bestand { $file } niet aanmaken of openen, reden { $reason } core_cannot_set_config_cache_path = Kan de config/cache pad niet instellen - config en cache zullen niet worden gebruikt. core_invalid_extension_contains_space = { $extension } is geen geldige extensie omdat het lege ruimte bevat binnenin core_invalid_extension_contains_dot = { $extension } is geen geldige extensie omdat het punt erin zit core_path_must_exists = Het opgegeven pad moet bestaan, waarbij { $path } genegeerd wordt core_must_be_directory_or_file = Het opgegeven pad moet verwijzen naar een geldige map of bestand, waarbij { $path } genegeerd wordt core_paths_unable_to_get_device_id = Kan het apparaat-ID niet ophalen vanuit de map { $path } core_failed_to_check_process_status = Het controleren van de processtatus is mislukt: { $reason } core_failed_to_wait_for_process = Het wachten op het proces is mislukt: { $reason } core_failed_to_read_video_properties = Het lukte niet om de videoproperties te lezen: { $reason } core_failed_to_execute_ffmpeg = Het uitvoeren van ffmpeg is mislukt: { $reason } core_failed_to_load_image_frame = Het laden van het afbeeldingsframe is mislukt: { $reason } core_image_zero_dimensions = De afbeelding heeft een breedte of hoogte van nul: "{ $path }" core_error_comparing_fingerprints = Fout tijdens het vergelijken van vingerafdrukken: { $reason }czkawka_core-11.0.1/i18n/no/czkawka_core.ftl000064400000000000000000000224131046102023000167160ustar 00000000000000# Core core_similarity_original = Opprinnelig core_similarity_very_high = Veldig høy core_similarity_high = Høy core_similarity_medium = Middels core_similarity_small = Liten core_similarity_very_small = Veldig liten core_similarity_minimal = Minimalt core_cannot_open_dir = Kan ikke åpne dir { $dir }, årsak { $reason } core_cannot_read_entry_dir = Kan ikke lese oppføringen i dir { $dir }, årsak { $reason } core_cannot_read_metadata_dir = Kan ikke lese metadata i dir { $dir }, årsak { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Filen { $name } ser ut til å ha blitt endret før Unix Epoch core_folder_modified_before_epoch = Mappen { $name } ser ut til å ha blitt endret før Unix Epoch core_file_no_modification_date = Klarte ikke å hente endringsdato fra filen { $name }. Årsak { $reason } core_folder_no_modification_date = Klarte ikke å hente endringsdato fra mappen { $name }. Årsak { $reason } core_cannot_start_scan_no_included_paths = Kan ikke starte skanning, fordi det ikke er inkludert stier core_skip_exist_check_all_included_paths_nonexistent = Kan ikke starte skanning, fordi alle inkluderte stiene ikke eksisterer core_missing_no_chosen_included_path = Ingen gyldig inkludert sti ble valgt (utelatelser kunne ha utelukket alle inkluderte stier) core_reference_included_paths_same = Kan ikke starte skanning der alle gyldige inkluderte stier også er refererte stier, prøv å validere eller deaktivere refererte stier core_must_be_directory_or_file = Angitt sti må peke til en gyldig mappe eller fil, og ignorere { $path } core_excluded_paths_pointless_slash = Unntatt / er meningsløst, fordi det betyr at ingen filer vil bli scannet core_paths_unable_to_get_device_id = Kan ikke hente enhets-ID fra mappen { $path } core_needs_allowed_extensions_limited_by_tool = Kan ikke starte skanning, når alle utvidelser tilgjengelige i dette verktøyet ({ $extensions }) ble ekskludert fra skanning core_needs_allowed_extensions = Kan ikke starte skanning, når alle utvidelser ble ekskludert fra skanning core_needs_to_set_at_least_one_broken_option = Kan ikke starte skanning, når det ikke er satt en ødelagt alternativ for å skanne etter core_needs_to_set_at_least_one_bad_name_option = Kan ikke starte skanning, når det ikke er satt en dårlig navn-alternativ for å skanne etter core_ffmpeg_not_found = Kan ikke finne en passende installasjon av FFmpeg eller FFprobe. Disse er eksterne programmer som må installeres manuelt. core_ffmpeg_not_found_windows = Pass på at ffmpeg.exe og ffprobe.exe er tilgjengelig i PATH eller plasseres direkte i samme mappe som appen kan utføres core_invalid_symlink_infinite_recursion = Uendelig rekursjon core_invalid_symlink_non_existent_destination = Ikke-eksisterende målfil core_messages_limit_reached_characters = Antall meldinger overskred den angitte grensen ({ $current }/{ $limit } tegn), så resultatet ble avkortet. For å lese hele utdataen, deaktiver begrensningsalternativet i innstillinger. core_messages_limit_reached_lines = Antall meldinger overskred den angitte grensen ({ $current }/{ $limit } linjer), så utgangen ble avkortet. For å lese hele utdataen, deaktiver begrensningsalternativet i innstillinger. core_error_moving_to_trash = Feil ved flytting av "{ $file }" til papirkurven: { $error } core_error_removing = Feil ved sletting av "{ $file }": { $error } core_no_similarity_method_selected = Kan ikke finne lignende musikkfiler uten en valgt likhet metode core_failed_to_spawn_command = Krevde ikke å starte kommando: { $reason } core_failed_to_check_process_status = Feilet med å sjekke prosessstatus: { $reason } core_failed_to_wait_for_process = Krevde ikke å vente på prosessen: { $reason } core_failed_to_read_video_properties = Kunne ikke lese videoegenskaper: { $reason } core_failed_to_execute_ffmpeg = Krevde ikke utførelse av ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg feilet med status { $status }: { $stderr } (kommando: { $command }) core_failed_to_load_image_frame = Klarte ikke å laste inn bildefragment: { $reason } core_failed_to_extract_frame = Klarte ikke å hente ut ramme ved { $time } sekunder fra "{ $file }": { $reason } core_failed_to_save_thumbnail = Feilet ved å lagre miniatyrbilde for "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Feilet å hente ramme ved timestamp { $timestamp } fra "{ $file }": { $reason } core_failed_get_frame_from_file = Kunne ikke hente ramme fra "{ $file }" ved timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Ugyldig avlingsrektangel: venstre={ $left }, øvre={ $top }, høyre={ $right }, nedre={ $bottom } core_failed_to_crop_video_file = Feilet med å beskjære videofilen "{ $file }": { $reason } core_cropped_video_not_created = Klippet videofil ble ikke opprettet: { $temp } core_unable_check_hash_of_file = Uklart å sjekke hasj for fil "{ $file }", årsak { $reason } core_error_checking_hash_of_file = Feil oppstod ved sjekk av hasj for fil "{ $file }", årsak { $reason } core_image_zero_dimensions = Bildet har null bredde eller høyde "{ $path }" core_image_open_failed = Kan ikke åpne bildefil "{ $path }": { $reason } core_not_directory_remove = Prøver å fjerne mappen "{ $path }" som ikke er en mappe core_cannot_read_directory = Kan ikke lese katalog "{ $path }" core_cannot_read_entry_from_directory = Kan ikke lese oppføring fra katalog "{ $path }" core_folder_contains_file_inside = Mappen inneholder filen "{ $entry }" inne i "{ $folder }" core_unknown_directory_entry = Kan ikke fastslå filtype for directory entry "{ $entry }" inne i "{ $path }" core_video_width_exceeds_limit = Video bredde { $width } overstiger grensen for { $limit } core_video_height_exceeds_limit = Video høyde { $height } overstiger grensen på { $limit } core_failed_to_process_video = Klarte ikke å behandle videofilen { $file }: { $reason } core_unknown_codec = Ukjent kodek: { $codec } core_invalid_video_optimizer_mode = Ugyldig videobestøkningsmodus: '{ $mode }'. Tillatte verdier: transkode, kutt core_path_not_directory = Stien er ikke en mappe: { $folder } core_test_error_for_folder = Test feil for mappe: { $folder } core_unknown_exif_tag_group = Ukjent EXIF-tag gruppe: { $tag } core_error_comparing_fingerprints = Feil ved sammenligning av fingeravtrykk: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Feilet med å generere miniatyrbilde for "{ $file }": uthentede rammer har forskjellige dimensjoner core_failed_to_generate_thumbnail = Feilet med å generere miniatyrbilde for "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Klarte ikke å hente ut ramme ved { $time } sekunder fra "{ $file }": { $reason } core_video_file_does_not_exist = Video fil finnes ikke (kan fjernes mellom skanning/senere steg): "{ $path }" core_image_too_large = Bildet er for stort ({ $width }x{ $height }) - mer enn støttet { $max } piksler core_failed_to_get_video_metadata = Klarte ikke å hente videometadata for fil "{ $file }": { $reason } core_failed_to_get_video_codec = Kunne ikke hente videocodec for fil "{ $file }" core_failed_to_get_video_duration = Kunne ikke hente videolengde for fil "{ $file }" core_failed_to_get_video_dimensions = Kunne ikke hente vide dimensjoner for fil "{ $file }" core_frame_dimensions_mismatch = Ramme dimensjoner for tidsstempel { $timestamp } stemmer ikke overens med de første ramme dimensjonene ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Kunne ikke laste data fra cache-fil { $file }, årsak { $reason } core_failed_to_load_data_from_json_cache = Kunne ikke laste data fra json-cachen { $file}, årsak { $reason } core_failed_to_replace_with_optimized = Klarte ikke å erstatte filen "{ $file }" med den optimaliserte versjonen: { $reason } core_failed_to_write_data_to_cache = Kan ikke skrive data til cache-fil "{ $file }", årsak { $reason } core_properly_saved_cache_entries = Riktig lagret til fil { $count } cache-poster. core_video_processing_stopped_by_user = Videobehandling ble stoppet av bruker core_thumbnail_generation_stopped_by_user = Miniatyrgenerering ble stoppet av bruker core_failed_to_optimize_video = Kjempetilfelte video "{ $file }": { $reason } core_failed_to_crop_video = Feilet å beskjære video "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Klarte ikke å hente metadata for den optimaliserte filen "{ $file }": { $reason } core_cannot_create_config_folder = Kan ikke opprette konfigurasjonsmappe "{ $folder }", årsak { $reason } core_cannot_create_cache_folder = Kan ikke opprette cache-mappe "{ $folder }", årsak { $reason } core_cannot_create_or_open_cache_file = Kan ikke opprette eller åpne cachefil "{ $file }", årsak { $reason } core_cannot_set_config_cache_path = Kan ikke sette config/cache-sti - config og cache vil ikke bli brukt. core_invalid_extension_contains_space = { $extension } er ikke en gyldig filtype fordi den inneholder mellomrom core_path_must_exists = Den angitte stien må eksistere, ignorerer { $path } core_optimized_file_larger = Den optimaliserte filen { $optimized } (størrelse: { $new_size }) er ikke mindre enn den originale filen { $original } (størrelse: { $original_size }) core_folder_does_not_exist = Mappen finnes ikke: { $folder } core_invalid_extension_contains_dot = { $extension } er ikke en gyldig filtype fordi den inneholder et punkt (.) inni segczkawka_core-11.0.1/i18n/pl/czkawka_core.ftl000064400000000000000000000240601046102023000167150ustar 00000000000000# Core core_similarity_original = Oryginalny core_similarity_very_high = Bardzo Duże core_similarity_high = Duże core_similarity_medium = Średnie core_similarity_small = Małe core_similarity_very_small = Bardzo Małe core_similarity_minimal = Minimalne core_cannot_open_dir = Nie można otworzyć folderu { $dir }, powód { $reason } core_cannot_read_entry_dir = Nie można odczytać danych z folderu { $dir }, powód { $reason } core_cannot_read_metadata_dir = Nie można odczytać metadanych folderu "{ $dir }": { $reason } core_cannot_read_metadata_file = Nie można odczytać metadanych pliku "{ $file }": { $reason } core_file_modified_before_epoch = Plik "{ $name }" wygląda na zmodyfikowany przed epoką Unix core_folder_modified_before_epoch = Folder "{ $name }" wygląda na zmodyfikowany przed epoką Unix core_file_no_modification_date = Nie udało się pobrać daty modyfikacji z pliku { $name }, powód { $reason } core_folder_no_modification_date = Nie udało się pobrać daty modyfikacji z folderu { $name }, powód { $reason } core_cannot_start_scan_no_included_paths = Nie można uruchomić skanowania, ponieważ nie wybrano żadnych folderów wejściowych core_skip_exist_check_all_included_paths_nonexistent = Nie można uruchomić skanowania, ponieważ wszystkie ścieżki do wyszukiwania nie istnieją core_missing_no_chosen_included_path = Nie wybrano prawidłowej ścieżki do wyszukiwania (wykluczone ścieżki mogły wykluczyć wszystkie ścieżki wejściowe) core_reference_included_paths_same = Nie można uruchomić skanu, gdzie wszystkie poprawne ścieżki uwzględnione są również ścieżkami odniesionymi, spróbuj zweryfikować lub wyłączyć ścieżki odniesione core_path_must_exists = Podana ścieżka musi istnieć, ignorowanie { $path } core_must_be_directory_or_file = Podany ścieżka musi wskazywać na ważny katalog lub plik, pomijając { $path } core_excluded_paths_pointless_slash = Wykluczenie / jest bezcelowe, ponieważ oznacza to, że żadne pliki nie zostaną przeskanowane core_paths_unable_to_get_device_id = Nie można uzyskać identyfikatora urządzenia z folderu { $path } core_needs_allowed_extensions_limited_by_tool = Nie można uruchomić skanu, gdy wszystkie rozszerzenia dostępne w tym narzędziu ({ $extensions }) zostały wykluczone z skanu core_needs_allowed_extensions = Nie można uruchomić skanu, gdy wszystkie rozszerzenia zostały wykluczone z skanu core_needs_to_set_at_least_one_broken_option = Nie można uruchomić skanu, gdy nie ustawiono opcji "uszkodzony" do skanowania core_needs_to_set_at_least_one_bad_name_option = Nie można uruchomić skanu, jeśli nie ustawiono opcji "złe nazwy" do skanowania core_ffmpeg_not_found = Nie można znaleźć prawidłowej instalacji FFmpeg lub FFprobe. Są to programy zewnętrzne, które muszą być zainstalowane ręcznie. core_ffmpeg_not_found_windows = Upewnij się, że ffmpeg.exe i ffprobe.exe są dostępne w PATH lub są umieszczone bezpośrednio w tym samym folderze co plik wykonywalny aplikacji core_invalid_symlink_infinite_recursion = Nieskończona rekurencja core_invalid_symlink_non_existent_destination = Nieistniejący docelowy plik core_messages_limit_reached_characters = Liczba wiadomości przekroczyła ustalony limit ({ $current }/{ $limit } znaków), więc wynik został obcięty. Aby odczytać pełne wyjście, wyłącz opcję ograniczenia liczby znaków w ustawieniach. core_messages_limit_reached_lines = Liczba wiadomości przekroczyła ustalony limit ({ $current }/{ $limit } linii), więc wynik został obcięty. Aby odczytać pełne wyjście, wyłącz opcję ograniczenia liczby linii w ustawieniach. core_error_moving_to_trash = Błąd podczas przenoszenia "{ $file }" do kosza: { $error } core_error_removing = Błąd podczas usuwania "{ $file }": { $error } core_no_similarity_method_selected = Nie można znaleźć podobnych plików muzycznych bez wybranego sposobu podobieństwa core_failed_to_spawn_command = Nie udało się wygenerować polecenia: { $reason } core_failed_to_check_process_status = Błąd podczas sprawdzania statusu procesu: { $reason } core_failed_to_wait_for_process = Nie udało się oczekiwać na proces: { $reason } core_failed_to_read_video_properties = Nie udało się odczytać właściwości wideo: { $reason } core_failed_to_execute_ffmpeg = Nie udało się wykonać ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg nie powiodło się z kodem { $status }: { $stderr } (polecenie: { $command }) core_failed_to_load_image_frame = Błąd ładowania ramy obrazu: { $reason } core_failed_to_extract_frame = Nie udało się wyodrębnić klatki o { $time } sekundach z "{ $file }": { $reason } core_failed_to_save_thumbnail = Nie udało się zapisać miniaturki dla "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Błąd podczas pobierania klatki o znaczniku { $timestamp } z "{ $file }": { $reason } core_failed_get_frame_from_file = Nie udało się uzyskać ramy z "{ $file }" o znaczniku czasu { $timestamp }: { $reason } core_invalid_crop_rectangle = Nieprawidłowy prostokąt uprawnień: lewy={ $left }, górny={ $top }, prawy={ $right }, dolny={ $bottom } core_failed_to_crop_video_file = Nie udało się przyciąć pliku wideo "{ $file }": { $reason } core_cropped_video_not_created = Przekrojony plik wideo nie został utworzony: { $temp } core_unable_check_hash_of_file = Nie można sprawdzić sumy kontrolnej pliku "{ $file }", powód { $reason } core_error_checking_hash_of_file = Błąd wystąpił podczas sprawdzania sumy kontrolnej pliku "{ $file }", powód { $reason } core_image_zero_dimensions = Obraz ma zerową szerokość lub wysokość "{ $path }" core_image_open_failed = Nie można otworzyć pliku obrazu "{ $path }": { $reason } core_not_directory_remove = Próba usunięcia folderu "{ $path }" który nie jest katalogiem core_cannot_read_directory = Nie można odczytać katalogu "{ $path }" core_cannot_read_entry_from_directory = Nie można odczytać wpisu z katalogu "{ $path }" core_folder_contains_file_inside = Folder zawiera plik "{ $entry }" wewnątrz "{ $folder }" core_unknown_directory_entry = Nie można określić typu pliku wpisu katalogowego "{ $entry }" wewnątrz "{ $path }" core_video_width_exceeds_limit = Szerokość wideo { $width } przekracza limit { $limit } core_video_height_exceeds_limit = Wysokość wideo { $height } przekracza limit { $limit } core_failed_to_process_video = Nie udało się przetworzyć pliku wideo { $file }: { $reason } core_optimized_file_larger = Zoptymalizowany plik { $optimized } (rozmiar: { $new_size }) nie jest mniejszy niż oryginalny { $original } (rozmiar: { $original_size }) core_unknown_codec = Nieznany kodek: { $codec } core_invalid_video_optimizer_mode = Nieprawidłowy tryb optymalizacji wideo: '{ $mode }'. Dopuszczalne wartości: transkoduj, przycinaj core_folder_does_not_exist = Folder nie istnieje: { $folder } core_path_not_directory = Ścieżka nie jest katalogiem: { $folder } core_test_error_for_folder = Test błąd dla folderu: { $folder } core_unknown_exif_tag_group = Nieznana grupa tagów EXIF: { $tag } core_error_comparing_fingerprints = Błąd podczas porównywania odcisków palców: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Nie udało się wygenerować miniaturki dla "{ $file }": wyekstrahowane klatki mają różne wymiary core_failed_to_generate_thumbnail = Nie udało się wygenerować miniaturki dla "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Nie udało się wyodrębnić klatki o { $time } sekundach z "{ $file }": { $reason } core_video_file_does_not_exist = Plik wideo nie istnieje (można usunąć między skanowaniem/późniejszymi krokami): "{ $path }" core_image_too_large = Obraz jest zbyt duży ({ $width }x{ $height }) - więcej niż obsługiwane { $max } pikseli core_failed_to_get_video_metadata = Nie udało się uzyskać metadanych wideo dla pliku "{ $file }": { $reason } core_failed_to_get_video_codec = Nie udało się uzyskać kodeka wideo dla pliku "{ $file }" core_failed_to_get_video_duration = Nie udało się uzyskać długości wideo dla pliku "{ $file }" core_failed_to_get_video_dimensions = Nie udało się uzyskać wymiarów wideo dla pliku "{ $file }" core_frame_dimensions_mismatch = Wymiary klatki dla znacznika czasu { $timestamp } nie pasują do wymiarów pierwszej klatki ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Nie udało się załadować danych z pliku pamięci podręcznej "{ $file }": { $reason } core_failed_to_load_data_from_json_cache = Nie udało się załadować danych z pliku pamięci podręcznej JSON "{ $file }": { $reason } core_failed_to_replace_with_optimized = Nie udało się zastąpić pliku "{ $file }" zoptymalizowaną wersją: { $reason } core_failed_to_write_data_to_cache = Nie można zapisać danych do pliku pamięci podręcznej "{ $file }": { $reason } core_properly_saved_cache_entries = Prawidłowo zapisano w pamięci podręcznej { $count } wpisów do pliku. core_video_processing_stopped_by_user = Przetwarzanie wideo zostało przerwane przez użytkownika core_thumbnail_generation_stopped_by_user = Generowanie miniatur zostało przerwane przez użytkownika core_failed_to_optimize_video = Pominięto optymalizację wideo "{ $file }": { $reason } core_failed_to_crop_video = Nie udało się przyciąć wideo "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Nie udało się pobrać metadanych z zoptymalizowanego pliku "{ $file }": { $reason } core_cannot_create_config_folder = Nie można utworzyć folderu konfiguracyjnego "{ $folder }": { $reason } core_cannot_create_cache_folder = Nie można utworzyć folderu pamięci podręcznej "{ $folder }": { $reason } core_cannot_create_or_open_cache_file = Nie można utworzyć ani otworzyć pliku pamięci podręcznej "{ $file }": { $reason } core_cannot_set_config_cache_path = Nie można ustawić ścieżki do konfiguracji/pamięci podręcznej — konfiguracja i pamięć podręczna nie zostaną użyte. core_invalid_extension_contains_space = "{ $extension }" nie jest prawidłowym rozszerzeniem, ponieważ zawiera spację core_invalid_extension_contains_dot = "{ $extension }" nie jest prawidłowym rozszerzeniem, ponieważ zawiera kropkę czkawka_core-11.0.1/i18n/pt-BR/czkawka_core.ftl000064400000000000000000000251601046102023000172300ustar 00000000000000# Core core_similarity_original = Please provide the text to translate core_similarity_very_high = Muito grande core_similarity_high = Grande core_similarity_medium = Médio core_similarity_small = Pequeno core_similarity_very_small = Muito pequeno core_similarity_minimal = Mínimo core_cannot_open_dir = Não foi possível abrir o diretório ‘{ $dir }’, por causa de ‘{ $reason }’ core_cannot_read_entry_dir = Não foi possível ler os dados do diretório ‘{ $dir }’, por causa de ‘{ $reason }’ core_cannot_read_metadata_dir = Não foi possível ler os metadados do diretório ‘{ $dir }’, por causa de ‘{ $reason }’ core_cannot_read_metadata_file = Não foi possível ler os metadados no arquivo ‘{ $file }’, por causa de ‘{ $reason }’ core_file_modified_before_epoch = O arquivo { $name } parece ser modificado antes do Epoch Unix core_folder_modified_before_epoch = A pasta ‘{ $name }’ parece ter sido modificada antes do ‘Epoch’ do Unix core_file_no_modification_date = Não foi possível obter a data da modificação do arquivo ‘{ $name }’, por causa de ‘{ $reason }’ core_folder_no_modification_date = Não foi possível obter a data da modificação da pasta ‘{ $name }’, por causa de ‘{ $reason }’ core_cannot_start_scan_no_included_paths = Não é possível iniciar a varredura, porque não há caminhos incluídos core_skip_exist_check_all_included_paths_nonexistent = Não é possível iniciar a varredura, porque todos os caminhos incluídos não existem core_missing_no_chosen_included_path = Nenhum caminho válido foi selecionado (os caminhos que foram excluídos poderiam ter excluído todos os caminhos que haviam sido incluídos) core_reference_included_paths_same = Não foi possível iniciar a verificação porque todos os caminhos válidos que foram incluídos também são caminhos referenciados. Tente ativar ou desativar os caminhos referenciados core_path_must_exists = O caminho que foi fornecido tem que apontar para um diretório, por tanto, o caminho ‘{ $path }’ será ignorado core_must_be_directory_or_file = O caminho que foi fornecido tem que apontar para um diretório ou para um arquivo válido, por tanto, o caminho ‘{ $path }’ será ignorado core_excluded_paths_pointless_slash = Se você excluir a barra ‘ / ’, significa que nenhum arquivo será verificado core_paths_unable_to_get_device_id = Não foi possível obter o ID (identificador) do dispositivo da pasta ‘{ $path }’ core_needs_allowed_extensions_limited_by_tool = Não foi possível iniciar a verificação porque todas as extensões ‘{ $extensions }’ que estão disponíveis nesta ferramenta foram excluídas da verificação core_needs_allowed_extensions = Não foi possível iniciar a verificação porque todas as extensões foram excluídas da verificação core_needs_to_set_at_least_one_broken_option = Não foi possível iniciar a verificação porque nenhuma opção do nome corrompido foi definida para ser verificada core_needs_to_set_at_least_one_bad_name_option = Não foi possível iniciar a verificação porque nenhuma opção do nome incorreto foi definida para ser verificada core_ffmpeg_not_found = Não foi possível encontrar a instalação dos programas ‘FFmpeg’ ou ‘FFprobe’. Estes programas são externos e você tem que ser instalá-los manualmente. core_ffmpeg_not_found_windows = Certifique-se de que o ‘ffmpeg.exe’ e ‘ffprobe.exe’ estejam disponíveis no caminho ou sejam colocados diretamente na mesma pasta onde está o executável do programa core_invalid_symlink_infinite_recursion = Ocorreu um erro de execução na recursão infinita core_invalid_symlink_non_existent_destination = O arquivo de destino não existe core_messages_limit_reached_characters = A quantidade de ‘{ $current }’ caracteres na mensagem excedeu o limite de ‘{ $limit }’ que foi definido, portanto, a saída foi truncada. Para ler a saída completa, desative a opção do limite de caracteres nas configurações. core_messages_limit_reached_lines = A quantidade de ‘{ $current }’ linhas na mensagem excedeu o limite de ‘{ $limit }’ que foi definido, portanto, a saída foi truncada. Para ler a saída completa, desative a opção do limite de linhas nas configurações. core_error_moving_to_trash = Ocorreu o erro ‘{ $error }’ ao tentar mover o arquivo ‘{ $file }’ para a lixeira core_error_removing = Ocorreu o erro ‘{ $error }’ ao tentar remover o arquivo ‘{ $file }’ core_no_similarity_method_selected = Não foi possível encontrar os arquivos de música equivalentes porque um método de equivalência não foi definido core_failed_to_spawn_command = Falha ao gerar comando: { $reason } core_ffmpeg_failed_with_status = ffmpeg falhou com o status { $status }: { $stderr } (comando: { $command }) core_failed_to_extract_frame = Falha ao extrair quadro em { $time } segundos de "{ $file }": { $reason } core_failed_to_save_thumbnail = Falha ao salvar miniatura para "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Falha ao obter frame no timestamp { $timestamp } de "{ $file }": { $reason } core_failed_get_frame_from_file = Falhou ao obter o quadro de "{ $file }" no timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Retângulo de cultivo inválido: esquerda={ $left }, superior={ $top }, direita={ $right }, inferior={ $bottom } core_failed_to_crop_video_file = Falha ao recortar o arquivo de vídeo "{ $file }": { $reason } core_cropped_video_not_created = Arquivo de vídeo cortado não foi criado: { $temp } core_unable_check_hash_of_file = Não foi possível verificar o hash do arquivo "{ $file }", motivo { $reason } core_error_checking_hash_of_file = Erro ocorrido ao verificar o hash do arquivo "{ $file }", motivo { $reason } core_image_zero_dimensions = A imagem tem largura ou altura zero "{ $path }" core_image_open_failed = Não é possível abrir o arquivo de imagem "{ $path }": { $reason } core_not_directory_remove = Tentando remover a pasta "{ $path }" que não é um diretório core_cannot_read_directory = Não é possível ler o diretório "{ $path }" core_cannot_read_entry_from_directory = Não é possível ler entrada do diretório "{ $path }" core_folder_contains_file_inside = A pasta contém o arquivo "{ $entry }" dentro de "{ $folder }" core_unknown_directory_entry = Não foi possível determinar o tipo de arquivo da entrada de diretório "{ $entry }" dentro de "{ $path }" core_video_height_exceeds_limit = Vídeo altura { $height } excede o limite de { $limit } core_failed_to_process_video = Falha ao processar o arquivo de vídeo { $file }: { $reason } core_unknown_codec = Codec desconhecido: { $codec } core_invalid_video_optimizer_mode = Modo otimizador de vídeo inválido: '{ $mode }'. Valores permitidos: transcodar, cortar core_folder_does_not_exist = A pasta não existe: { $folder } core_path_not_directory = O caminho não é um diretório: { $folder } core_test_error_for_folder = Erro de teste para a pasta: { $folder } core_unknown_exif_tag_group = Grupo EXIF desconhecido: { $tag } core_failed_to_generate_thumbnail_frames_different_dimensions = Falha ao gerar miniatura para "{ $file }": os quadros extraídos têm dimensões diferentes core_failed_to_generate_thumbnail = Falha ao gerar miniatura para "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Falha ao extrair quadro em { $time } segundos de "{ $file }": { $reason } core_video_file_does_not_exist = Arquivo de vídeo não existe (pode ser removido entre as etapas de digitalização/mais tarde): "{ $path }" core_failed_to_get_video_metadata = Falha ao obter metadados de vídeo para o arquivo "{ $file }": { $reason } core_failed_to_get_video_codec = Falha ao obter codec de vídeo para o arquivo "{ $file }" core_failed_to_get_video_duration = Falhou ao obter a duração do vídeo para o arquivo "{ $file }" core_failed_to_get_video_dimensions = Falha ao obter as dimensões do vídeo para o arquivo "{ $file }" core_frame_dimensions_mismatch = Dimensões do quadro para timestamp { $timestamp } não correspondem às dimensões do primeiro quadro ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Falha ao carregar dados do arquivo de cache { $file }, motivo { $reason } core_failed_to_load_data_from_json_cache = Falha ao carregar dados do arquivo de cache JSON { $file }, motivo { $reason } core_failed_to_replace_with_optimized = Falha ao substituir o arquivo "{ $file }" pela versão otimizada: { $reason } core_failed_to_write_data_to_cache = Não é possível escrever dados para o arquivo de cache "{ $file }", motivo { $reason } core_properly_saved_cache_entries = Salvado corretamente para o arquivo { $count } entradas de cache. core_video_processing_stopped_by_user = O processamento de vídeo foi interrompido pelo usuário core_thumbnail_generation_stopped_by_user = Geração de miniaturas foi interrompida pelo usuário core_failed_to_optimize_video = Falha ao otimizar o vídeo "{ $file }": { $reason } core_failed_to_crop_video = Falha ao recortar o vídeo "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Falha ao obter metadados do arquivo otimizado "{ $file }": { $reason } core_cannot_create_config_folder = Não é possível criar a pasta de configuração "{ $folder }", a razão é { $reason } core_cannot_create_cache_folder = Não é possível criar a pasta de cache "{ $folder }", motivo { $reason } core_cannot_create_or_open_cache_file = Não é possível criar ou abrir o arquivo de cache "{ $file }", a razão { $reason } core_cannot_set_config_cache_path = Não é possível definir o caminho de configuração/cache - a configuração e o cache não serão utilizados. core_invalid_extension_contains_space = { $extension } não é uma extensão válida porque contém espaço em branco dentro core_invalid_extension_contains_dot = { $extension } não é uma extensão válida porque contém ponto dentro core_failed_to_check_process_status = Falha ao verificar o status do processo: { $reason } core_failed_to_wait_for_process = Falha ao aguardar o processo: { $reason } core_failed_to_read_video_properties = Falha ao ler as propriedades do vídeo: { $reason } core_failed_to_execute_ffmpeg = Falha ao executar o ffmpeg: { $reason } core_failed_to_load_image_frame = Falha ao carregar o quadro da imagem: { $reason } core_video_width_exceeds_limit = Largura do vídeo { $width } excede o limite de { $limit } core_optimized_file_larger = O arquivo otimizado { $optimized } (tamanho: { $new_size }) não é menor que o arquivo original { $original } (tamanho: { $original_size }) core_error_comparing_fingerprints = Erro ao comparar impressões digitais: { $reason } core_image_too_large = A imagem é muito grande ({$width}x{$height}) - excede o limite de { $max } pixels suportadosczkawka_core-11.0.1/i18n/pt-PT/czkawka_core.ftl000064400000000000000000000236071046102023000172540ustar 00000000000000# Core core_similarity_original = Original core_similarity_very_high = Muito alto core_similarity_high = Alto core_similarity_medium = Média core_similarity_small = Pequeno core_similarity_very_small = Muito Pequeno core_similarity_minimal = Mínimo core_cannot_open_dir = Não é possível abrir o diretório { $dir }, razão { $reason } core_cannot_read_entry_dir = Não é possível ler a entrada no diretório { $dir }, razão { $reason } core_cannot_read_metadata_dir = Não é possível ler os metadados no diretório { $dir }, razão { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = O arquivo { $name } parece ter sido modificado antes do Epoch Unix core_folder_modified_before_epoch = A pasta { $name } parece ter sido modificada antes do Epoch Unix core_file_no_modification_date = Não foi possível obter a data de modificação do arquivo { $name }, motivo { $reason } core_folder_no_modification_date = Não foi possível obter a data de modificação da pasta { $name }, motivo { $reason } core_cannot_start_scan_no_included_paths = Não é possível iniciar a varredura, porque não há caminhos incluídos core_skip_exist_check_all_included_paths_nonexistent = Não é possível iniciar a varredura, porque todos os caminhos incluídos não existem core_missing_no_chosen_included_path = O caminho incluído não válido foi escolhido (os caminhos excluídos poderiam ter excluído todos os caminhos incluídos) core_reference_included_paths_same = Não é possível iniciar a varredura onde todos os caminhos incluídos válidos também são caminhos referenciados, tente validar ou desabilitar os caminhos referenciados core_path_must_exists = O caminho fornecido deve existir, ignorando { $path } core_must_be_directory_or_file = O caminho fornecido deve apontar para um diretório ou arquivo válido, ignorando { $path } core_excluded_paths_pointless_slash = Excluindo / é inútil, porque significa que nenhum arquivo será escaneado core_paths_unable_to_get_device_id = Impossível obter o ID do dispositivo da pasta { $path } core_needs_allowed_extensions_limited_by_tool = Não é possível iniciar a varredura, quando todas as extensões disponíveis nesta ferramenta ({ $extensions }) foram excluídas da varredura core_needs_allowed_extensions = Não é possível iniciar a varredura, quando todas as extensões foram excluídas da varredura core_needs_to_set_at_least_one_broken_option = Não é possível iniciar a varredura, quando não há opção de quebrado definida para varrer core_needs_to_set_at_least_one_bad_name_option = Não é possível iniciar a varredura, quando não há opção de nome inválido definida para varrer core_ffmpeg_not_found = Não é possível encontrar uma instalação adequada de FFmpeg ou FFprobe. Estes são programas externos que devem ser instalados manualmente. core_ffmpeg_not_found_windows = Certifique-se de que o ffmpeg.exe e ffprobe.exe estejam disponíveis no PATH ou estejam diretamente na mesma pasta que o app executável core_invalid_symlink_infinite_recursion = Recursão infinita core_invalid_symlink_non_existent_destination = Arquivo de destino não existe core_messages_limit_reached_characters = Número de mensagens excedido o limite definido ({ $current }/{ $limit } caracteres), então a saída foi truncada. Para ler a saída completa, desative a opção de limitação nas configurações. core_messages_limit_reached_lines = Número de mensagens excedido o limite definido ({ $current }/{ $limit } linhas), então a saída foi truncada. Para ler a saída completa, desative a opção de limitação nas configurações. core_error_moving_to_trash = Erro ao mover "{ $file }" para a lixeira: { $error } core_error_removing = Erro ao remover "{ $file }": { $error } core_no_similarity_method_selected = Não é possível encontrar arquivos de música semelhantes sem um método de similaridade selecionado core_failed_to_spawn_command = Falhou em spawn comando: { $reason } core_ffmpeg_failed_with_status = ffmpeg falhou com o status { $status }: { $stderr } (comando: { $command }) core_failed_to_load_image_frame = Falha ao carregar o quadro de imagem: { $reason } core_failed_to_extract_frame = Falhou em extrair o quadro em { $time } segundos de "{ $file }": { $reason } core_failed_to_save_thumbnail = Falhou ao salvar a miniatura para "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Falhou em obter o quadro no timestamp { $timestamp } de "{ $file }": { $reason } core_failed_get_frame_from_file = Falhou ao obter o quadro de "{ $file }" no timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Retângulo de cultivo inválido: esquerda={ $left }, superior={ $top }, direita={ $right }, inferior={ $bottom } core_failed_to_crop_video_file = Falha ao cortar o arquivo de vídeo "{ $file }": { $reason } core_cropped_video_not_created = Arquivo de vídeo cortado não foi criado: { $temp } core_unable_check_hash_of_file = Não foi possível verificar o hash do arquivo "{ $file }", motivo { $reason } core_error_checking_hash_of_file = Erro ocorreu ao verificar o hash do arquivo "{ $file }", motivo { $reason } core_image_zero_dimensions = A imagem tem largura ou altura zero "{ $path }" core_image_open_failed = Não é possível abrir o arquivo de imagem "{ $path }": { $reason } core_not_directory_remove = Tentando remover a pasta "{ $path }" que não é um diretório core_cannot_read_directory = Não é possível ler o diretório "{ $path }" core_cannot_read_entry_from_directory = Não é possível ler entrada do diretório "{ $path }" core_folder_contains_file_inside = A pasta contém o arquivo "{ $entry }" dentro de "{ $folder }" core_unknown_directory_entry = Não foi possível determinar o tipo de arquivo da entrada de diretório "{ $entry }" dentro de "{ $path }" core_video_height_exceeds_limit = Vídeo altura { $height } excede o limite de { $limit } core_failed_to_process_video = Falha ao processar o arquivo de vídeo { $file }: { $reason } core_unknown_codec = Codec desconhecido: { $codec } core_invalid_video_optimizer_mode = Modo otimizador de vídeo inválido: '{ $mode }'. Valores permitidos: transcodar, cortar core_path_not_directory = O caminho não é um diretório: { $folder } core_test_error_for_folder = Erro de teste para a pasta: { $folder } core_unknown_exif_tag_group = Grupo EXIF desconhecido: { $tag } core_failed_to_generate_thumbnail_frames_different_dimensions = Falhou ao gerar miniatura para "{ $file }": os quadros extraídos têm dimensões diferentes core_failed_to_generate_thumbnail = Falhou ao gerar miniatura para "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Falhou em extrair o quadro em { $time } segundos de "{ $file }": { $reason } core_video_file_does_not_exist = O arquivo de vídeo não existe (pode ser removido entre as etapas de digitalização/mais tarde): "{ $path }" core_failed_to_get_video_metadata = Falhou ao obter os metadados de vídeo para o arquivo "{ $file }": { $reason } core_failed_to_get_video_codec = Falhou ao obter o codec de vídeo para o arquivo "{ $file }" core_failed_to_get_video_duration = Falhou ao obter a duração do vídeo para o arquivo "{ $file }" core_failed_to_get_video_dimensions = Falhou ao obter as dimensões do vídeo para o arquivo "{ $file }" core_frame_dimensions_mismatch = Dimensões do quadro para timestamp { $timestamp } não correspondem às dimensões do primeiro quadro ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Falha ao carregar dados do ficheiro de cache { $file }, motivo { $reason } core_failed_to_load_data_from_json_cache = Falha ao carregar dados do ficheiro de cache json { $file }, motivo { $reason } core_failed_to_replace_with_optimized = Falhou ao substituir o arquivo "{ $file }" pela versão otimizada: { $reason } core_failed_to_write_data_to_cache = Não é possível escrever dados para o ficheiro de cache "{ $file }", motivo { $reason } core_properly_saved_cache_entries = Salvado corretamente para o arquivo { $count } entradas de cache. core_video_processing_stopped_by_user = O processamento de vídeo foi interrompido pelo utilizador core_thumbnail_generation_stopped_by_user = Geração de miniaturas foi interrompida pelo usuário core_failed_to_optimize_video = Falha ao otimizar o vídeo "{ $file }": { $reason } core_failed_to_crop_video = Falha ao cortar o vídeo "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Falhou ao obter metadados do arquivo otimizado "{ $file }": { $reason } core_cannot_create_config_folder = Não é possível criar a pasta de configuração "{ $folder }", a razão { $reason } core_cannot_create_cache_folder = Não é possível criar a pasta de cache "{ $folder }", a razão { $reason } core_cannot_create_or_open_cache_file = Não é possível criar ou abrir o arquivo de cache "{ $file }", motivo { $reason } core_cannot_set_config_cache_path = Não é possível definir o caminho de configuração/cache - a configuração e o cache não serão utilizados. core_invalid_extension_contains_space = { $extension } não é uma extensão válida porque contém espaço em branco no interior core_invalid_extension_contains_dot = { $extension } não é uma extensão válida porque contém ponto dentro core_failed_to_check_process_status = Falha ao verificar o status do processo: { $reason } core_failed_to_wait_for_process = Falha ao aguardar a conclusão do processo: { $reason } core_failed_to_read_video_properties = Falha ao ler as propriedades do vídeo: { $reason } core_failed_to_execute_ffmpeg = Falha ao executar o ffmpeg: { $reason } core_video_width_exceeds_limit = Largura do vídeo { $width } excede o limite de { $limit } core_optimized_file_larger = O arquivo otimizado { $optimized } (tamanho: { $new_size }) não é menor que o arquivo original { $original } (tamanho: { $original_size }) core_folder_does_not_exist = Pasta não encontrada: { $folder } core_error_comparing_fingerprints = Erro ao comparar impressões digitais: { $reason }czkawka_core-11.0.1/i18n/ro/czkawka_core.ftl000064400000000000000000000234151046102023000167250ustar 00000000000000# Core core_similarity_original = Originală core_similarity_very_high = Foarte Mare core_similarity_high = Ridicat core_similarity_medium = Medie core_similarity_small = Mică core_similarity_very_small = Foarte mic core_similarity_minimal = Minimă core_cannot_open_dir = Nu se poate deschide dir { $dir }, motiv { $reason } core_cannot_read_entry_dir = Nu se poate citi intrarea în dir { $dir }, motivul { $reason } core_cannot_read_metadata_dir = Metadatele nu pot fi citite în dir { $dir }, motivul { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Fișierul { $name } pare să fi fost modificat înainte de Epocul Unix core_folder_modified_before_epoch = Dosarul { $name } pare să fi fost modificat înainte de Epocul Unix core_file_no_modification_date = Imposibil de obținut data modificării din fișierul { $name }, motivul { $reason } core_folder_no_modification_date = Imposibil de obținut data modificării din dosarul { $name }, motivul { $reason } core_cannot_start_scan_no_included_paths = Nu se poate începe scanarea, deoarece nu există căi incluse core_skip_exist_check_all_included_paths_nonexistent = Nu se poate începe scanarea, deoarece toate căile incluse nu există core_missing_no_chosen_included_path = Nu a fost selectat niciun drum inclus valid (drumurile excluse ar fi putut exclude toate drumurile incluse) core_reference_included_paths_same = Nu se poate începe scanarea unde toate căile incluse valide sunt și căi referite, încercați să validați sau să dezactivați căile referite core_path_must_exists = Calea furnizată trebuie să existe, ignorând { $path } core_must_be_directory_or_file = Fiul indicat trebuie să indice un director sau fișier valid, ignorând { $path } core_excluded_paths_pointless_slash = Excluderea / este inutilă, deoarece înseamnă că niciun fișier nu va fi scanat core_paths_unable_to_get_device_id = Nu se poate obține ID-ul dispozitivului din folder { $path } core_needs_allowed_extensions_limited_by_tool = Nu se poate începe scanarea, când toate extensiile disponibile în acest instrument ({ $extensions }) au fost excluse din scan core_needs_allowed_extensions = Nu se poate începe scanarea, când toate extensiile au fost excluse din scan core_needs_to_set_at_least_one_broken_option = Nu se poate începe scanarea, când nu este setată opțiunea de a scana pentru elemente rupte core_needs_to_set_at_least_one_bad_name_option = Nu se poate începe scanarea, când nu este setată opțiunea de nume invalid pentru scanare core_ffmpeg_not_found = Nu se poate găsi o instalare adecvată a FFmpeg sau FFprobe. Acestea sunt programe externe care trebuie instalate manual. core_ffmpeg_not_found_windows = Asigurați-vă că ffmpeg.exe și ffprobe.exe sunt disponibile în PATH sau sunt plasate direct în același folder cu aplicația executabilă core_invalid_symlink_infinite_recursion = Recepţie infinită core_invalid_symlink_non_existent_destination = Fișier destinație inexistent core_messages_limit_reached_characters = Numărul de mesaje a depășit limita setată ({ $current }/{ $limit } caractere), deci rezultatul a fost trunchiat. Pentru a citi ieșirea completă, dezactivați opțiunea de limitare din setări. core_messages_limit_reached_lines = Numărul de mesaje a depășit limita setată ({ $current }/{ $limit } linii), astfel rezultatul a fost trunchiat. Pentru a citi ieșirea completă, dezactivați opțiunea de limitare din setări. core_error_moving_to_trash = Eroare la mutarea "{ $file }" în coșul de gunoi: { $error } core_error_removing = Eroare la eliminarea "{ $file }": { $error } core_no_similarity_method_selected = Nu pot găsi fișiere muzicale similare fără o metodă de similaritate selectată core_failed_to_spawn_command = Eșuare la generarea comenzii: { $reason } core_failed_to_check_process_status = Eșec la verificarea stării procesului: { $reason } core_failed_to_wait_for_process = Eșec la așteptarea procesului: { $reason } core_failed_to_read_video_properties = Eșec la citirea proprietăților videoclipului: { $reason } core_failed_to_execute_ffmpeg = Eșec la execuția ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg a eșuat cu status { $status }: { $stderr } (comanda: { $command }) core_failed_to_load_image_frame = Nu s-a putut încărca cadrul imaginii: { $reason } core_failed_to_extract_frame = Eșec la extragerea cadrului la { $time } secunde din "{ $file }": { $reason } core_failed_to_save_thumbnail = Nu s-a putut salva miniatură pentru "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Eșec la obținerea cadrului la timestamp { $timestamp } din "{ $file }": { $reason } core_failed_get_frame_from_file = Nu s-a putut obține cadrul din "{ $file }" la timestamp { $timestamp }: { $reason } core_invalid_crop_rectangle = Dreptunghiul de selecție este invalid: stânga={ $left }, sus={ $top }, dreapta={ $right }, jos={ $bottom } core_failed_to_crop_video_file = Eșec la tăierea fișierului video "{ $file }": { $reason } core_cropped_video_not_created = Fișierul video tăiat nu a fost creat: { $temp } core_unable_check_hash_of_file = Nu se poate verifica hash-ul fișierului "{ $file }", motivul { $reason } core_error_checking_hash_of_file = Eroare a apărut la verificarea hash-ului fișierului "{ $file }", motivul { $reason } core_image_zero_dimensions = Imaginea are o lățime sau înălțime zero "{ $path }" core_image_open_failed = Nu se poate deschide fișierul de imagine "{ $path }": { $reason } core_not_directory_remove = Încercarea de a elimina folderul "{ $path }" care nu este un director core_cannot_read_directory = Nu pot citi directorul "{ $path }" core_cannot_read_entry_from_directory = Nu pot citi intrarea din directorul "{ $path }" core_folder_contains_file_inside = Folder conține fișierul "{ $entry }" în interiorul "{ $folder }" core_unknown_directory_entry = Nu se poate determina tipul fișierului intrării din director "{ $entry }" în "{ $path }" core_video_width_exceeds_limit = Video lățime { $width } depășește limita de { $limit } core_video_height_exceeds_limit = Video înălțime { $height } depășește limita de { $limit } core_failed_to_process_video = Fișierul video { $file } nu a putut fi procesat: { $reason } core_optimized_file_larger = Fișier optimizat { $optimized } (dimensiune: { $new_size }) nu este mai mic decât originalul { $original } (dimensiune: { $original_size }) core_unknown_codec = Codc necunoscut: { $codec } core_invalid_video_optimizer_mode = Mod de optimizare video invalid: '{ $mode }'. Valorile permise: transcode, crop core_folder_does_not_exist = Dosar nu există: { $folder } core_path_not_directory = Calele nu este un director: { $folder } core_test_error_for_folder = Eroare test pentru folder: { $folder } core_unknown_exif_tag_group = Grupul EXIF necunoscut: { $tag } core_error_comparing_fingerprints = Eroare la compararea amprentelor: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Eșec la generarea miniaturii pentru "{ $file }": cadrele extrase au dimensiuni diferite core_failed_to_generate_thumbnail = Eșec la generarea miniaturii pentru "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Eșec la extragerea cadrului la { $time } secunde din "{ $file }": { $reason } core_video_file_does_not_exist = Fișier video inexistent (poate fi eliminat între scanare/pașii ulteriori): "{ $path }" core_image_too_large = Imaginea este prea mare ({ $width }x{ $height }) - mai mult decât suportat { $max } pixeli core_failed_to_get_video_metadata = Eșec la obținerea metadatelor video pentru fișierul "{ $file }": { $reason } core_failed_to_get_video_codec = Eșec la obținerea codec-ului video pentru fișierul "{ $file }" core_failed_to_get_video_duration = Nu s-a putut obține durata video pentru fișierul "{ $file }" core_failed_to_get_video_dimensions = Eșec la obținerea dimensiunilor video pentru fișierul "{ $file }" core_frame_dimensions_mismatch = Dimensiunile cadrului pentru marcajul de timp { $timestamp } nu corespund cu dimensiunile primului cadru ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Nu s-a putut încărca date din fișierul cache { $file }, motivul { $reason } core_failed_to_load_data_from_json_cache = Nu s-a putut încărca date din fișierul cache JSON { $file }, motiv { $reason } core_failed_to_replace_with_optimized = Eșec la înlocuirea fișierului "{ $file }" cu versiunea optimizată: { $reason } core_failed_to_write_data_to_cache = Nu se poate scrie date în fișierul cache "{ $file }", motiv { $reason } core_properly_saved_cache_entries = Salvat corect în fișier { $count } intrări în cache. core_video_processing_stopped_by_user = Procesarea video a fost oprită de utilizator core_thumbnail_generation_stopped_by_user = Generarea miniaturilor a fost oprită de utilizator core_failed_to_optimize_video = Eșec la optimizarea videoclipului "{ $file }": { $reason } core_failed_to_crop_video = Eșec la tăierea videoclipului "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Eșec la obținerea metadatelor fișierului optimizat "{ $file }": { $reason } core_cannot_create_config_folder = Nu se poate crea folderul de configurare "{ $folder }", motivul { $reason } core_cannot_create_cache_folder = Nu se poate crea folderul de cache "{ $folder }", motiv { $reason } core_cannot_create_or_open_cache_file = Nu se poate crea sau deschide fișierul de cache "{ $file }", motiv { $reason } core_cannot_set_config_cache_path = Nu se poate seta calea de configurare/cache - configurarea și cache-ul nu vor fi utilizate. core_invalid_extension_contains_space = { $extension } nu este o extensie validă deoarece conține spații goale în interior core_invalid_extension_contains_dot = { $extension } nu este o extensie validă deoarece conține punct în interior czkawka_core-11.0.1/i18n/ru/czkawka_core.ftl000064400000000000000000000326021046102023000167310ustar 00000000000000# Core core_similarity_original = Оригинальное core_similarity_very_high = Очень высокое core_similarity_high = Высокое core_similarity_medium = Среднее core_similarity_small = Низкое core_similarity_very_small = Очень низкое core_similarity_minimal = Минимальное core_cannot_open_dir = Невозможно открыть каталог { $dir }, причина: { $reason } core_cannot_read_entry_dir = Невозможно прочитать запись в директории { $dir }, причина: { $reason } core_cannot_read_metadata_dir = Невозможно прочитать метаданные в директории { $dir }, причина: { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Файл { $name } был изменен до эпохи Unix core_folder_modified_before_epoch = Папка { $name } была изменена до начала Unix Epoch core_file_no_modification_date = Не удаётся получить дату изменения из файла { $name }, причина: { $reason } core_folder_no_modification_date = Не удаётся получить дату изменения из папки { $name }, причина: { $reason } core_cannot_start_scan_no_included_paths = Невозможно начать сканирование, так как не указаны пути core_skip_exist_check_all_included_paths_nonexistent = Невозможно начать сканирование, потому что все включенные пути не существуют core_missing_no_chosen_included_path = Не был выбран действительный путь, который был включен (исключающие пути могли исключить все включенные пути) core_reference_included_paths_same = Невозможно начать сканирование, где все допустимые включенные пути также являются ссылочными путями, попробуйте проверить или отключить ссылочные пути core_path_must_exists = Предоставленный путь должен существовать, игнорируя { $path } core_must_be_directory_or_file = Предоставленный путь должен указывать на действительную директорию или файл, игнорируя { $path } core_excluded_paths_pointless_slash = Исключать / бессмысленно, потому что это означает, что файлы не будут сканированы core_paths_unable_to_get_device_id = Невозможно получить идентификатор устройства из папки { $path } core_needs_allowed_extensions_limited_by_tool = Невозможно начать сканирование, когда все расширения, доступные в этом инструменте ({ $extensions }), были исключены из сканирования core_needs_allowed_extensions = Невозможно начать сканирование, когда все расширения исключены из сканирования core_needs_to_set_at_least_one_broken_option = Невозможно начать сканирование, когда не указана опция «поврежденный» для сканирования core_needs_to_set_at_least_one_bad_name_option = Невозможно начать сканирование, когда опция «плохое имя» не установлена для сканирования core_ffmpeg_not_found = Не удается найти надлежащую установку FFmpeg или FFprobe. Это внешние программы, которые должны быть установлены вручную. core_ffmpeg_not_found_windows = Убедитесь, что ffmpeg.exe и ffprobe.exe доступны в PATH или находятся непосредственно в той же папке, что и приложение core_invalid_symlink_infinite_recursion = Бесконечная рекурсия core_invalid_symlink_non_existent_destination = Не найден конечный файл core_messages_limit_reached_characters = Количество сообщений превысило установленный лимит ({ $current }/{ $limit } символов), поэтому вывод был усечен. Чтобы прочитать весь вывод, отключите опцию ограничения в настройках. core_messages_limit_reached_lines = Количество сообщений превысило установленный лимит ({ $current }/{ $limit } строк), поэтому вывод был усечен. Чтобы прочитать весь вывод, отключите опцию ограничения в настройках. core_error_moving_to_trash = Ошибка при перемещении "{ $file }" в корзину: { $error } core_error_removing = Ошибка при удалении "{ $file }": { $error } core_no_similarity_method_selected = Не удается найти похожие музыкальные файлы без выбранного метода сходства core_failed_to_spawn_command = Не удалось запустить команду: { $reason } core_failed_to_check_process_status = Не удалось проверить статус процесса: { $reason } core_failed_to_wait_for_process = Не удалось дождаться процесса: { $reason } core_failed_to_read_video_properties = Не удалось прочитать свойства видео: { $reason } core_failed_to_execute_ffmpeg = Не удалось выполнить ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg завершился с кодом ошибки { $status }: { $stderr } (команда: { $command }) core_failed_to_load_image_frame = Не удалось загрузить кадр изображения: { $reason } core_failed_to_extract_frame = Не удалось извлечь кадр в { $time } секундах из "{ $file }": { $reason } core_failed_to_save_thumbnail = Не удалось сохранить миниатюру для "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Не удалось получить кадр в метке времени { $timestamp } из "{ $file }": { $reason } core_failed_get_frame_from_file = Не удалось получить кадр из "{ $file }" в метке времени { $timestamp }: { $reason } core_invalid_crop_rectangle = Неверный прямоугольник обрезки: лево={ $left }, сверху={ $top }, право={ $right }, снизу={ $bottom } core_failed_to_crop_video_file = Не удалось обрезать видеофайл "{ $file }": { $reason } core_cropped_video_not_created = Обрезённый видеофайл не был создан: { $temp } core_unable_check_hash_of_file = Невозможно проверить хэш файла "{ $file }", причина { $reason } core_error_checking_hash_of_file = Ошибка при проверке хэша файла "{ $file }", причина { $reason } core_image_zero_dimensions = Изображение имеет нулевую ширину или высоту "{ $path }" core_image_open_failed = Невозможно открыть файл изображения "{ $path }": { $reason } core_not_directory_remove = Попытка удалить папку "{ $path }" которая не является директорией core_cannot_read_directory = Невозможно прочитать каталог "{ $path }" core_cannot_read_entry_from_directory = Невозможно прочитать запись из каталога "{ $path }" core_folder_contains_file_inside = Папка содержит файл "{ $entry }" внутри "{ $folder }" core_unknown_directory_entry = Невозможно определить тип файла для записи каталога "{ $entry }" внутри "{ $path }" core_video_width_exceeds_limit = Видео ширина { $width } превышает лимит { $limit } core_video_height_exceeds_limit = Видео высота { $height } превышает лимит { $limit } core_failed_to_process_video = Не удалось обработать видеофайл { $file }: { $reason } core_optimized_file_larger = Оптимизированный файл { $optimized } (размер: { $new_size }) не меньше, чем исходный { $original } (размер: { $original_size }) core_unknown_codec = Неизвестный кодек: { $codec } core_invalid_video_optimizer_mode = Недопустимый режим оптимизации видео: '{ $mode }'. Разрешенные значения: transcode, crop core_folder_does_not_exist = Папка не существует: { $folder } core_path_not_directory = Путь не является каталогом: { $folder } core_test_error_for_folder = Ошибка теста для папки: { $folder } core_unknown_exif_tag_group = Неизвестная группа EXIF тегов: { $tag } core_error_comparing_fingerprints = Ошибка при сравнении отпечатков пальцев: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Не удалось сгенерировать миниатюру для "{ $file }": извлеченные кадры имеют разные размеры core_failed_to_generate_thumbnail = Не удалось сгенерировать миниатюру для "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Не удалось извлечь кадр в { $time } секундах из "{ $file }": { $reason } core_video_file_does_not_exist = Файл видео не существует (может быть удален между сканированием/поздними шагами): "{ $path }" core_image_too_large = Изображение слишком большое ({ $width }x{ $height }) - больше, чем поддерживаемые { $max } пикселей core_failed_to_get_video_metadata = Не удалось получить метаданные видео для файла "{ $file }": { $reason } core_failed_to_get_video_codec = Не удалось получить видеокодек для файла "{ $file }" core_failed_to_get_video_duration = Не удалось получить продолжительность видео для файла "{ $file }" core_failed_to_get_video_dimensions = Не удалось получить размеры видео для файла "{ $file }" core_frame_dimensions_mismatch = Размеры кадра для метки времени { $timestamp } не совпадают с размерами первого кадра ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Не удалось загрузить данные из кэш-файла { $file }, причина { $reason } core_failed_to_load_data_from_json_cache = Не удалось загрузить данные из файла кэша json { $file }, причина { $reason } core_failed_to_replace_with_optimized = Не удалось заменить файл "{ $file }" на оптимизированную версию: { $reason } core_failed_to_write_data_to_cache = Невозможно записать данные в кэш-файл "{ $file }", причина { $reason } core_properly_saved_cache_entries = Правильно сохранено в файл { $count } кэш-записей. core_video_processing_stopped_by_user = Обработка видео была остановлена пользователем core_thumbnail_generation_stopped_by_user = Генерация превью была остановлена пользователем core_failed_to_optimize_video = Не удалось оптимизировать видео "{ $file }": { $reason } core_failed_to_crop_video = Не удалось обрезать видео "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Не удалось получить метаданные оптимизированного файла "{ $file }": { $reason } core_cannot_create_config_folder = Невозможно создать папку конфигурации "{ $folder }", причина { $reason } core_cannot_create_cache_folder = Невозможно создать папку кэша "{ $folder }", причина { $reason } core_cannot_create_or_open_cache_file = Невозможно создать или открыть кэш-файл "{ $file }", причина { $reason } core_cannot_set_config_cache_path = Невозможно установить путь к config/cache - config и cache не будут использоваться. core_invalid_extension_contains_space = { $extension } не является допустимым расширением, поскольку оно содержит пробел внутри core_invalid_extension_contains_dot = { $extension } не является допустимым расширением, так как оно содержит точку внутри czkawka_core-11.0.1/i18n/sv-SE/czkawka_core.ftl000064400000000000000000000232221046102023000172360ustar 00000000000000# Core core_similarity_original = Ursprunglig core_similarity_very_high = Mycket Hög core_similarity_high = Hög core_similarity_medium = Mellan core_similarity_small = Litet core_similarity_very_small = Väldigt Liten core_similarity_minimal = Minimalt core_cannot_open_dir = Kan inte öppna dir { $dir }anledning { $reason } core_cannot_read_entry_dir = Kan inte läsa post i dir { $dir }, anledning { $reason } core_cannot_read_metadata_dir = Kan inte läsa metadata i dir { $dir }, anledning { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Filen { $name } verkar ha ändrats innan Unix Epoch core_folder_modified_before_epoch = Folder { $name } verkar ha ändrats innan Unix Epoch core_file_no_modification_date = Det går inte att hämta ändringsdatum från filen { $name }, anledning { $reason } core_folder_no_modification_date = Det går inte att hämta ändringsdatum från mappen { $name }, anledning { $reason } core_cannot_start_scan_no_included_paths = Kan inte starta skanning, eftersom det inte finns några inkluderade sökvägar core_skip_exist_check_all_included_paths_nonexistent = Kan inte starta skanningen, eftersom alla inkluderade sökvägar inte finns core_missing_no_chosen_included_path = Ingen giltande inkluderad sökväg valdes (uteslutna sökvägar kunde ha uteslutit alla inkluderade sökvägar) core_reference_included_paths_same = Kan inte starta skanning där alla giltiga inkluderade sökvägar också är refererade sökvägar, försök validera eller inaktivera refererade sökvägar core_path_must_exists = Angiven sökväg måste existera, ignorera { $path } core_must_be_directory_or_file = Angiven sökväg måste peka på en giltig katalog eller fil, och ignorera { $path } core_excluded_paths_pointless_slash = Exkludera / är meningslöst, eftersom det innebär att inga filer kommer att skannas core_paths_unable_to_get_device_id = Kan inte hämta enhets-ID från mappen { $path } core_needs_allowed_extensions_limited_by_tool = Kan inte starta skanning, när alla tillgängliga tillägg i detta verktyg ({ $extensions }) var exkluderade från skanning core_needs_allowed_extensions = Kan inte starta skanning, när alla tillägg har tagits ur skanning core_needs_to_set_at_least_one_broken_option = Kan inte starta skanning, när inget alternativ för trasiga är inställt för att skanna för core_needs_to_set_at_least_one_bad_name_option = Kan inte starta skanning, när ingen alternativ för dåliga namn är inställt för att skanna för core_ffmpeg_not_found = Kan inte hitta en korrekt installation av FFmpeg eller FFprobe. Dessa är externa program som måste installeras manuellt. core_ffmpeg_not_found_windows = Se till att ffmpeg.exe och ffprobe.exe finns tillgängliga i PATH eller placeras direkt i samma mapp som appen körbar core_invalid_symlink_infinite_recursion = Oändlig recursion core_invalid_symlink_non_existent_destination = Icke-existerande målfil core_messages_limit_reached_characters = Antalet meddelanden överskred den inställda gränsen ({ $current }/{ $limit } tecken), så utmatningen trunkterades. För att läsa hela utmatningen, inaktivera begränsande alternativ i inställningarna. core_messages_limit_reached_lines = Antalet meddelanden överskred den inställda gränsen ({ $current }/{ $limit } rader), så utmatningen blev trunkerad. För att läsa hela utmatningen, inaktivera begränsande alternativ i inställningarna. core_error_moving_to_trash = Fel vid flyttning av "{ $file }" till papperskorgen: { $error } core_error_removing = Fel vid borttagning av "{ $file }": { $error } core_no_similarity_method_selected = Kan inte hitta liknande musikfiler utan en vald likhetsmetod core_failed_to_spawn_command = Misslyckades med att generera kommando: { $reason } core_failed_to_check_process_status = Misslyckades med att kontrollera processstatus: { $reason } core_failed_to_wait_for_process = Misslyckades med att vänta på processen: { $reason } core_failed_to_read_video_properties = Kunde inte läsa videobegränsningar: { $reason } core_failed_to_execute_ffmpeg = Kunde inte köra ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg misslyckades med status { $status }: { $stderr } (kommando: { $command }) core_failed_to_load_image_frame = Misslyckades med att ladda bildram: { $reason } core_failed_to_extract_frame = Misslyckades med att extrahera bildruta vid { $time } sekunder från "{ $file }": { $reason } core_failed_to_save_thumbnail = Misslyckades med att spara miniatyrbild för "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Misslyckades med att hämta bildrutor vid tidstämpling { $timestamp } från "{ $file }": { $reason } core_failed_get_frame_from_file = Misslyckades med att hämta bildrutor från "{ $file }" vid tidstämplen { $timestamp }: { $reason } core_invalid_crop_rectangle = Ogiltig odlingsrektangel: left={ $left }, top={ $top }, right={ $right }, bottom={ $bottom } core_failed_to_crop_video_file = Misslyckades med att beskära videofilen "{ $file }": { $reason } core_cropped_video_not_created = Skuren videofil var inte skapad: { $temp } core_unable_check_hash_of_file = Kunde inte kontrollera hash för fil "{ $file }", anledning { $reason } core_error_checking_hash_of_file = Fel inträffade vid kontroll av hash för filen "{ $file }", anledning { $reason } core_image_zero_dimensions = Bilden har noll bredd eller höjd "{ $path }" core_image_open_failed = Kan inte öppna bildfilen "{ $path }": { $reason } core_not_directory_remove = Försöker ta bort mappen "{ $path }" vilket inte är en katalog core_cannot_read_directory = Kan inte läsa katalogen "{ $path }" core_cannot_read_entry_from_directory = Kan inte läsa inlägg från katalogen "{ $path }" core_folder_contains_file_inside = Mappen innehåller filen "{ $entry }" inuti "{ $folder }" core_unknown_directory_entry = Kan inte fastställa filtyp för katalogposten "{ $entry }" inuti "{ $path }" core_video_width_exceeds_limit = Video bredd { $width } överskrider gränsen för { $limit } core_video_height_exceeds_limit = Video höjd { $height } överskrider gränsen för { $limit } core_optimized_file_larger = Optimerad fil { $optimized } (storlek: { $new_size }) är inte mindre än original { $original } (storlek: { $original_size }) core_unknown_codec = Okänt codec: { $codec } core_folder_does_not_exist = Mappexisterar inte: { $folder } core_path_not_directory = Sökvägen är inte en katalog: { $folder } core_test_error_for_folder = Feltest för mapp: { $folder } core_unknown_exif_tag_group = Okänt EXIF-tag grupp: { $tag } core_error_comparing_fingerprints = Fel vid jämförelse av fingeravtryck: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Misslyckades med att generera miniatyrbild för "{ $file }": extraherade ramar har olika dimensioner core_failed_to_generate_thumbnail = Misslyckades med att generera miniatyrbild för "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Misslyckades med att extrahera bildruta vid { $time } sekunder från "{ $file }": { $reason } core_video_file_does_not_exist = Videofilen finns inte (kan tas bort mellan skanning/senare steg): "{ $path }" core_image_too_large = Bilden är för stor ({ $width }x{ $height }) - mer än stödda { $max } pixlar core_failed_to_get_video_metadata = Misslyckades med att hämta videodata för fil "{ $file }": { $reason } core_failed_to_get_video_codec = Misslyckades med att hämta videocodec för filen "{ $file }" core_failed_to_get_video_duration = Kunde inte få videolängd för fil "{ $file }" core_failed_to_get_video_dimensions = Misslyckades med att få videons dimensioner för filen "{ $file }" core_frame_dimensions_mismatch = Bildens mått för tidstämplar { $timestamp } stämmer inte överens med bildens mått ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Misslyckades med att ladda data från cachefil { $file }, anledning { $reason } core_failed_to_load_data_from_json_cache = Kunde inte ladda data från json-cache-fil { $file}, anledning { $reason } core_failed_to_replace_with_optimized = Misslyckades med att ersätta filen "{ $file }" med den optimerade versionen: { $reason } core_failed_to_write_data_to_cache = Kan inte skriva data till cachefil "{ $file }", anledning { $reason } core_properly_saved_cache_entries = Spara korrekt till fil { $count } cacheposter. core_video_processing_stopped_by_user = Videobearbetningen stoppades av användaren core_thumbnail_generation_stopped_by_user = Miniatyrgenerering stoppades av användare core_failed_to_optimize_video = Misslyckades med att optimera video "{ $file }": { $reason } core_failed_to_crop_video = Misslyckades med att beskära videon "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Misslyckades med att hämta metadata för den optimerade filen "{ $file }": { $reason } core_cannot_create_config_folder = Kan inte skapa konfigurationsmappen "{ $folder }", anledningen är { $reason } core_cannot_create_cache_folder = Kan inte skapa cachemappen "{ $folder }", anledningen { $reason } core_cannot_create_or_open_cache_file = Kan inte skapa eller öppna cachefil "{ $file }", anledning { $reason } core_cannot_set_config_cache_path = Kan inte ställa in config/cache-sökväg - config och cache kommer inte att användas. core_invalid_extension_contains_space = { $extension } är inte en giltig filändelse eftersom den innehåller tomma utrymmen däri core_invalid_extension_contains_dot = { $extension } är inte en giltig filändelse eftersom den innehåller en punkt inuti core_failed_to_process_video = Kunde inte bearbeta videofil { $file }: { $reason } core_invalid_video_optimizer_mode = Ogiltigt optimeringsläge för video: '{ $mode }'. Tillåtna värden: transcode, cropczkawka_core-11.0.1/i18n/tr/czkawka_core.ftl000064400000000000000000000232371046102023000167340ustar 00000000000000# Core core_similarity_original = Asıl core_similarity_very_high = Çok Yüksek core_similarity_high = Yüksek core_similarity_medium = Orta core_similarity_small = Düşük core_similarity_very_small = Çok Düşük core_similarity_minimal = Aşırı Düşük core_cannot_open_dir = { $dir } dizini açılamıyor, nedeni: { $reason } core_cannot_read_entry_dir = { $dir } dizinindeki girdi okunamıyor, nedeni: { $reason } core_cannot_read_metadata_dir = { $dir } dizinindeki metaveri okunamıyor, nedei: { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = File { $name } seems to have been modified before the Unix Epoch core_folder_modified_before_epoch = Folder { $name } seems to have been modified before the Unix Epoch core_file_no_modification_date = { $name } dosyasının değişiklik tarihine erişilemiyor, nedeni: { $reason } core_folder_no_modification_date = { $name } klasörünün değişiklik tarihine erişilemiyor, nedeni: { $reason } core_cannot_start_scan_no_included_paths = Tarama başlatılamıyor, çünkü hiçbir dahil yol yok core_skip_exist_check_all_included_paths_nonexistent = Tarama başlatılamıyor, çünkü tüm dahil yollar mevcut değil core_missing_no_chosen_included_path = Geçerli bir dahil yol seçilemedi (hariç tutulan yollar tüm dahil yolları hariç bırakmış olabilir) core_reference_included_paths_same = Geçerli dahil yolların tamamının başvurulan yollar olarak da referanslandırıldığı bir tarama başlatılamaz, lütfen doğrulamayı deneyin veya başvurulan yolları devre dışı bırakın core_must_be_directory_or_file = Verilen yol geçer bir dizine veya dosyaya işaret etmelidir, { $path }'i göz ardı ederek core_excluded_paths_pointless_slash = Hariç tutmak / anlamsızdır, çünkü bu, hiçbir dosyanın taranmayacağı anlamına gelir core_paths_unable_to_get_device_id = Cihaz kimliğini { $path } klasöründen elde edilemiyor core_needs_allowed_extensions_limited_by_tool = Tarama başlatılamıyor, tüm uzantılar bu araçta ({ $extensions }) mevcutken taramadan hariç tutulduğunda core_needs_allowed_extensions = Tarama başlatılamıyor, tüm uzantılar taramadan hariç tutulduğunda core_needs_to_set_at_least_one_broken_option = Tarama başlatılamıyor, kırık seçenek aranırken ayarlanmamışsa core_needs_to_set_at_least_one_bad_name_option = Tarama başlatılamıyor, kötü bir isim seçeneği ayarlanmamışken tarama için core_ffmpeg_not_found = FFmpeg veya FFprobe için uygun bir kurulum bulunamıyor. Bunlar, manuel olarak kurulması gereken harici programlardır. core_ffmpeg_not_found_windows = Emin olun ki, ffmpeg.exe ve ffprobe.exe yoluna eklenmiş veya uygulama yürütülebilir dosyasının aynı klasöründe yer almıştır core_invalid_symlink_infinite_recursion = Sonsuz özyineleme core_invalid_symlink_non_existent_destination = Var olmayan hedef dosya core_messages_limit_reached_characters = Number of messages exceeded the set limit ({ $current }/{ $limit } characters), so the output was truncated. To read the full output, disable the limiting option in settings. core_messages_limit_reached_lines = Number of messages exceeded the set limit ({ $current }/{ $limit } lines), so the output was truncated. To read the full output, disable the limiting option in settings. core_error_moving_to_trash = { $file }'yi atlaşına taşıma sırasında hata: { $error } core_error_removing = "{ $file }" kaldırılırken hata: { $error } core_no_similarity_method_selected = Seçilen benzerlik metoduna olmadan benzer müzik dosyası bulamıyor core_failed_to_spawn_command = Komutun oluşturulması başarısız oldu: { $reason } core_failed_to_check_process_status = İşlem durumunu kontrol edemedi: { $reason } core_failed_to_wait_for_process = İşlem beklemede başarısız oldu: { $reason } core_failed_to_read_video_properties = Video özelliklerini okuyamıyor: { $reason } core_failed_to_execute_ffmpeg = ffmpeg'i çalıştırmada başarısız: { $reason } core_ffmpeg_failed_with_status = ffmpeg başarısız oldu durum { $status }: { $stderr } (komut: { $command }) core_failed_to_load_image_frame = Resim çerçevesi yüklenemedi: { $reason } core_failed_to_extract_frame = { $time } saniyede kareyi "{ $file }" dosyasından çıkarılamadı: { $reason } core_failed_to_save_thumbnail = "{ $file }" için önizlemeyi kaydedemedi: { $reason } core_failed_get_frame_at_timestamp = { $timestamp } zaman damgası üzerinde çerçeve alınamadı "{ $file }": { $reason } core_failed_get_frame_from_file = "{ $file }" adlı dosyadaki çerçeve alınamadı zaman damgası { $timestamp }'te: { $reason } core_invalid_crop_rectangle = Geçersiz tarım dikdörtgeni: sol={ $left }, üst={ $top }, sağ={ $right }, alt={ $bottom } core_cropped_video_not_created = Kesilmiş video dosyası oluşturulmadı: { $temp } core_unable_check_hash_of_file = Dosyanın hash'ini "{ $file }" kontrol edilemedi, nedeni { $reason } core_error_checking_hash_of_file = Dosya "{ $file }" için hash kontrolünde hata oluştu, nedeni { $reason } core_image_zero_dimensions = Görüntü sıfır genişliğe veya yüksekliğe sahiptir "{ $path }" core_image_open_failed = "{ $path }" adlı görüntü dosyasını açamıyoruz: { $reason } core_not_directory_remove = "{ $path }$" adlı klasörü silmeye çalışıyor, bu bir dizin değil core_cannot_read_directory = "{ $path }" dizinini okuyamıyor core_cannot_read_entry_from_directory = "{ $path }" dizininden girişi okuyamaz core_folder_contains_file_inside = Klasör içinde "{ $entry }" dosyası "{ $folder }" içinde bulunmaktadır core_unknown_directory_entry = Dosya türünü "{ $entry }" girişine ait "{ $path }" içinde belirleyemiyorum core_video_width_exceeds_limit = Video genişliği { $width } limiti { $limit } değerini aşmaktadır core_video_height_exceeds_limit = Video yüksekliği { $height } limiti { $limit }'i aşmaktadır core_optimized_file_larger = Optimize edilmiş dosya { $optimized } (boyut: { $new_size }) orijinal { $original } (boyut: { $original_size })'den daha küçük değil core_unknown_codec = Bilinmeyen codec: { $codec } core_invalid_video_optimizer_mode = Geçersiz video optimizasyon modu: '{ $mode }'. İzin verilen değerler: transcode, crop core_folder_does_not_exist = Klasör bulunamadı: { $folder } core_path_not_directory = Yol bir dizin değildir: { $folder } core_test_error_for_folder = Dosya hatası için klasör: { $folder } core_unknown_exif_tag_group = Bilinmeyen EXIF etiket grubu: { $tag } core_error_comparing_fingerprints = Parmak izlerini karşılaştırmada hata: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = "{ $file }" için önizlemeyi oluşturulamadı: Çıkarılan kareler farklı boyutlarda core_failed_to_generate_thumbnail = "{ $file }" için önizlemeyi oluşturulamadı: { $reason } core_failed_to_extract_frame_at_seek_time = { $time } saniyede kareyi "{ $file }" dosyasından çıkarılamadı: { $reason } core_video_file_does_not_exist = Video dosyası mevcut değil (tarama/daha sonra adımları arasında kaldırılabilir): "{ $path }" core_image_too_large = Görüntü çok büyü ({$width}x{$height}) - desteklenmeyen { $max } pikselden fazla core_failed_to_get_video_metadata = Video meta verilerini dosyayı "{ $file }" için elde edilemedi: { $reason } core_failed_to_get_video_codec = Dosya "{ $file }" için video codec'i alınamadı core_failed_to_get_video_duration = Video süresini "{ $file }" dosyası için elde edilemedi core_failed_to_get_video_dimensions = Video boyutlarını dosya için "{ $file }" alınamadı core_frame_dimensions_mismatch = Çerçeve boyutları { $timestamp } zaman damgası için ilk çerçeve boyutlarıyla (%{$first_w}x%{$first_h}) uyuşmuyor core_failed_to_load_data_from_cache = Verilen dosyadaki {$file} verisi yüklenemedi, nedeni { $reason } core_failed_to_replace_with_optimized = Dosya "{ $file }" optimize edilmiş versiyon ile değiştirilemedi: { $reason } core_failed_to_write_data_to_cache = Katalog dosyasına "{ $file }" yazamıyor, nedeni { $reason } core_properly_saved_cache_entries = Doğru şekilde dosyaya { $count } önbellek girişi kaydedildi. core_video_processing_stopped_by_user = Video işleme kullanıcının tarafından durduruldu core_thumbnail_generation_stopped_by_user = Miniature oluşturma durduruldu kullanıcı tarafından core_failed_to_optimize_video = Video "{ $file }" optimizasyonu başarısız: { $reason } core_failed_to_crop_video = Video kırpma başarısız: "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Optimizasyonlu dosya "{ $file }": { $reason } meta verisi alınamadı core_cannot_create_config_folder = Konfigürasyon klasörü "{ $folder }" oluşturulamazdı, nedeni { $reason } core_cannot_create_cache_folder = "{ $folder }" önbellek klasörü oluşturulamaz, nedeni { $reason } core_cannot_set_config_cache_path = Config/cache yolu yapılamadı - config ve cache kullanılmayacak. core_invalid_extension_contains_space = { $extension } geçerli bir uzantı değildir çünkü içinde boşluk içermektedir core_invalid_extension_contains_dot = { $extension } geçerli bir uzantı değildir çünkü içinde nokta içeriyor core_path_must_exists = Verilen yolun mevcut olması gerekmektedir, ancak {$path} kısmını dikkate almayınız core_failed_to_crop_video_file = "{ $file }" video dosyasını kırpmakta bir sorun oluştu: { $reason } core_failed_to_process_video = Video dosyasını işleme sırasında bir hata oluştu: { $file } - Neden: { $reason } core_failed_to_load_data_from_json_cache = JSON önbellek dosyasından veri yüklenemedi: { $file }, nedeni: { $reason } core_cannot_create_or_open_cache_file = "{ $file }" adlı geçici dosyayı oluşturamadı veya açamadı, nedeni: { $reason }czkawka_core-11.0.1/i18n/uk/czkawka_core.ftl000064400000000000000000000324531046102023000167260ustar 00000000000000# Core core_similarity_original = Оригінал core_similarity_very_high = Дуже висока core_similarity_high = Висока core_similarity_medium = Середня core_similarity_small = Низька core_similarity_very_small = Дуже низька core_similarity_minimal = Мінімальна core_cannot_open_dir = Не вдалося відкрити каталог { $dir }, причина: { $reason } core_cannot_read_entry_dir = Не вдалося прочитати запис в каталозі { $dir }, причина: { $reason } core_cannot_read_metadata_dir = Не вдалося прочитати метадані в каталозі { $dir }, причина: { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = Файл { $name } здається змінено до Unix Epoch core_folder_modified_before_epoch = Папка { $name } здається була змінена до Unix Epoch core_file_no_modification_date = Не вдалося отримати дату модифікації з файлу { $name }, причина: { $reason } core_folder_no_modification_date = Не вдалося отримати дату модифікації з каталогу { $name }, причина: { $reason } core_cannot_start_scan_no_included_paths = Не вдається запустити сканування, оскільки відсутні включені шляхи core_skip_exist_check_all_included_paths_nonexistent = Не вдається запустити сканування, оскільки всі включені шляхи не існують core_missing_no_chosen_included_path = Не було вибрано жодного валідного включеного шляху (виключені шляхи могли виключити всі включені шляхи) core_reference_included_paths_same = Не вдається запустити сканування, де всі валідні включені шляхи також є шляхами, на які посилаються, спробуйте перевірити їх або вимкнути шляхи, на які посилаються core_path_must_exists = Наданий шлях повинен існувати, ігноруючи { $path } core_must_be_directory_or_file = Наданий шлях повинен вказувати на дійсну директорію або файл, ігноруючи { $path } core_excluded_paths_pointless_slash = Виключення / марне, оскільки це означає, що жодні файли не будуть скановані core_paths_unable_to_get_device_id = Не вдається отримати ідентифікатор пристрою з папки { $path } core_needs_allowed_extensions_limited_by_tool = Не вдається запустити сканування, коли всі розширення, доступні в цьому інструменті ({ $extensions }), були виключені зі скану core_needs_allowed_extensions = Не вдається запустити сканування, коли всі розширення були виключені зі скану core_needs_to_set_at_least_one_broken_option = Не вдається запустити сканування, коли відсутній встановлений опція для сканування пошкоджених core_needs_to_set_at_least_one_bad_name_option = Не вдається запустити сканування, коли опція "поганого імені" не встановлена для сканування core_ffmpeg_not_found = Не вдається знайти налесну установку FFmpeg або FFprobe. Це зовнішні програми, які необхідно встановити вручну. core_ffmpeg_not_found_windows = Переконайтеся, що ffmpeg.exe і ffprobe.exe доступні в PATH або розташовані безпосередньо в тій же папці, що і виконуваний додаток core_invalid_symlink_infinite_recursion = Нескінченна рекурсія core_invalid_symlink_non_existent_destination = Неіснуючий файл призначення core_messages_limit_reached_characters = Кількість повідомлень перевищило встановлене обмеження ({ $current }/{ $limit } символів), тому результат обрізано. Щоб прочитати весь вихід, вимкніть обмежувальну опцію в налаштуваннях. core_messages_limit_reached_lines = Кількість повідомлень перевищило встановлене обмеження ({ $current }/{ $limit } рядка), тому вихід було скорочено. Щоб прочитати весь вихід, вимкніть обмежувальну опцію в налаштуваннях. core_error_moving_to_trash = Помилка при переміщенні "{ $file }" у кошик: { $error } core_error_removing = Помилка при видаленні "{ $file }": { $error } core_no_similarity_method_selected = Не вдається знайти подібні музичні файли без обраного методу подібності core_failed_to_spawn_command = Не вдалося запустити команду: { $reason } core_failed_to_check_process_status = Не вдалося перевірити статус процесу: { $reason } core_failed_to_wait_for_process = Не вдалося дочекатися процесу: { $reason } core_failed_to_read_video_properties = Не вдалося прочитати властивості відео: { $reason } core_failed_to_execute_ffmpeg = Не вдалося виконати ffmpeg: { $reason } core_ffmpeg_failed_with_status = ffmpeg не вдалося виконатися зі статусом { $status }: { $stderr } (команда: { $command }) core_failed_to_load_image_frame = Не вдалося завантажити кадр зображення: { $reason } core_failed_to_extract_frame = Не вдалося витягти кадр на { $time } секундах з "{ $file }": { $reason } core_failed_to_save_thumbnail = Не вдалося зберегти мініатюру для "{ $file }": { $reason } core_failed_get_frame_at_timestamp = Не вдалося отримати кадр о мітки часу { $timestamp } з "{ $file }": { $reason } core_failed_get_frame_from_file = Не вдалося отримати кадр з "{ $file }" у відбитку часу { $timestamp }: { $reason } core_invalid_crop_rectangle = Недійсний прямокутник обрізки: left={ $left }, top={ $top }, right={ $right }, bottom={ $bottom } core_failed_to_crop_video_file = Не вдалося обрізати відеофайл "{ $file }": { $reason } core_cropped_video_not_created = Обрізаний відеофайл не був створений: { $temp } core_unable_check_hash_of_file = Не вдалося перевірити хеш файлу "{ $file }", причина { $reason } core_error_checking_hash_of_file = Помилка сталася під час перевірки хешу файлу "{ $file }", причина { $reason } core_image_zero_dimensions = Зображення має нульову ширину або висоту "{ $path }" core_image_open_failed = Не вдається відкрити файл зображення "{ $path }": { $reason } core_not_directory_remove = Намагаюся видалити папку "{ $path }" яка не є директорією core_cannot_read_directory = Не вдається прочитати каталог "{ $path }" core_cannot_read_entry_from_directory = Не вдається прочитати запис із каталогу "{ $path }" core_folder_contains_file_inside = Папка містить файл "{ $entry }" всередині "{ $folder }" core_unknown_directory_entry = Не вдається визначити тип файлу запису каталогу "{ $entry }" всередині "{ $path }" core_video_width_exceeds_limit = Відео ширина { $width } перевищує ліміт { $limit } core_video_height_exceeds_limit = Відео висота { $height } перевищує ліміт { $limit } core_failed_to_process_video = Не вдалося обробити файл відео { $file }: { $reason } core_optimized_file_larger = Оптимізований файл { $optimized } (розмір: { $new_size }) не менший за оригінальний { $original } (розмір: { $original_size }) core_unknown_codec = Невідомий кодек: { $codec } core_invalid_video_optimizer_mode = Недійсний режим оптимізатора відео: '{ $mode }'. Допустимі значення: transcode, crop core_folder_does_not_exist = Папка не існує: { $folder } core_path_not_directory = Шлях не є директорією: { $folder } core_test_error_for_folder = Тест помилка для папки: { $folder } core_unknown_exif_tag_group = Невідомий EXIF тег група: { $tag } core_error_comparing_fingerprints = Помилка при порівнянні відбитків пальців: { $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = Не вдалося згенерувати мініатюру для "{ $file }": витягнуті кадри мають різні розміри core_failed_to_generate_thumbnail = Не вдалося згенерувати мініатюру для "{ $file }": { $reason } core_failed_to_extract_frame_at_seek_time = Не вдалося витягти кадр на { $time } секундах з "{ $file }": { $reason } core_video_file_does_not_exist = Файл відео не існує (може бути видалено між скануванням/пізнішими кроками): "{ $path }" core_image_too_large = Зображення занадто велике ({ $width }x{ $height }) - більше ніж підтримується { $max } пікселів core_failed_to_get_video_metadata = Не вдалося отримати метадані відео для файлу "{ $file }": { $reason } core_failed_to_get_video_codec = Не вдалося отримати відеокодек для файлу "{ $file }" core_failed_to_get_video_duration = Не вдалося отримати тривалість відео для файлу "{ $file }" core_failed_to_get_video_dimensions = Не вдалося отримати розміри відео для файлу "{ $file }" core_frame_dimensions_mismatch = Розміри кадру для відмітку часу { $timestamp } не відповідають розмірам першого кадру ({ $first_w }x{ $first_h }) core_failed_to_load_data_from_cache = Не вдалося завантажити дані з файлу кешу { $file }, причина { $reason } core_failed_to_load_data_from_json_cache = Не вдалося завантажити дані з JSON файлу кешу { $file}, причина { $reason } core_failed_to_replace_with_optimized = Не вдалося замінити файл "{ $file }" на оптимізовану версію: { $reason } core_failed_to_write_data_to_cache = Не вдається записати дані до кешованого файлу "{ $file }", причина { $reason } core_properly_saved_cache_entries = Правильно збережено у файл { $count } записів кешу. core_video_processing_stopped_by_user = Обробка відео була зупинена користувачем core_thumbnail_generation_stopped_by_user = Генерація превью була зупинена користувачем core_failed_to_optimize_video = Не вдалося оптимізувати відео "{ $file }": { $reason } core_failed_to_crop_video = Не вдалося обрізати відео "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = Не вдалося отримати метадані оптимізованого файлу "{ $file }": { $reason } core_cannot_create_config_folder = Не вдається створити папку конфігурації "{ $folder }", причина { $reason } core_cannot_create_cache_folder = Не вдається створити кешовий каталог "{ $folder }", причина { $reason } core_cannot_create_or_open_cache_file = Не вдається створити або відкрити файл кешу "{ $file }", причина { $reason } core_cannot_set_config_cache_path = Не вдається встановити шлях до конфігурації/кешу - конфігурація та кеш не будуть використані. core_invalid_extension_contains_space = { $extension } не є допустимим розширенням, оскільки воно містить порожній простір всередині core_invalid_extension_contains_dot = { $extension } не є допустимим розширенням, оскільки воно містить крапку всередині czkawka_core-11.0.1/i18n/zh-CN/czkawka_core.ftl000064400000000000000000000210631046102023000172210ustar 00000000000000# Core core_similarity_original = 原版 core_similarity_very_high = 非常高 core_similarity_high = 高 core_similarity_medium = 中 core_similarity_small = 小的 core_similarity_very_small = 非常小 core_similarity_minimal = 最小化 core_cannot_open_dir = 无法打开目录 { $dir },因为 { $reason } core_cannot_read_entry_dir = 无法在目录 { $dir } 中读取条目,因为 { $reason } core_cannot_read_metadata_dir = 无法读取目录 { $dir } 中的元数据,因为 { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = 文件 { $name } 似乎在Unix Epoch前被修改 core_folder_modified_before_epoch = 文件夹 { $name } 似乎已在Unix Epoch前被修改 core_file_no_modification_date = 无法从文件 { $name } 获取修改日期,因为 { $reason } core_folder_no_modification_date = 无法从文件夹 { $name } 获取修改日期,因为 { $reason } core_cannot_start_scan_no_included_paths = 无法启动扫描,因为没有包含的路径 core_skip_exist_check_all_included_paths_nonexistent = 无法启动扫描,因为所有包含的路径都不存在 core_missing_no_chosen_included_path = 未选择有效的包含路径(排除路径可能排除了所有包含路径) core_reference_included_paths_same = 无法在所有有效包含路径也同时是引用路径的位置开始扫描,请尝试验证或禁用引用路径 core_path_must_exists = 提供的路径必须存在,忽略 { $path } core_must_be_directory_or_file = 提供的路径必须指向一个有效的目录或文件,忽略 { $path } core_excluded_paths_pointless_slash = 排除/毫无意义,因为这意味着不会扫描任何文件 core_paths_unable_to_get_device_id = 无法从文件夹 { $path } 获取设备ID core_needs_allowed_extensions_limited_by_tool = 无法开始扫描,当此工具中所有可用扩展程序 ({ $extensions }) 都已从扫描中排除时 core_needs_allowed_extensions = 无法开始扫描,当所有扩展都已从扫描中排除时 core_needs_to_set_at_least_one_broken_option = 无法开始扫描,当未设置任何损坏选项以进行扫描 core_needs_to_set_at_least_one_bad_name_option = 无法启动扫描,当未设置“坏名称”选项进行扫描 core_ffmpeg_not_found = 无法找到合适的 FFmpeg 或 FFprobe 安装。这些是必须手动安装的外部程序。. core_ffmpeg_not_found_windows = 请确保ffmpeg.exe 和 ffprobe.exe 在 PATH 中可用或直接放入与应用可执行文件相同的文件夹 core_invalid_symlink_infinite_recursion = 无限递归性 core_invalid_symlink_non_existent_destination = 目标文件不存在 core_messages_limit_reached_characters = 消息数量超过了设置的限制 ({ $current }/{ $limit } 字符),所以输出被截断。 要读取全部输出,在设置中禁用限制选项。. core_messages_limit_reached_lines = 消息数量超过了设置的限制 ({ $current }/{ $limit } 行),所以输出被截断。 要读取全部输出,在设置中禁用限制选项。. core_error_moving_to_trash = 移动 "{ $file }" 到回收站时出错:{ $error } core_error_removing = 删除 "{ $file }" 时出错:{ $error } core_no_similarity_method_selected = 无法找到没有选择相似方法相似的音乐文件 core_failed_to_spawn_command = 未能生成命令:{ $reason } core_failed_to_check_process_status = 未能检查进程状态:{ $reason } core_failed_to_wait_for_process = 未能等待进程:{ $reason } core_failed_to_read_video_properties = 读取视频属性失败:{ $reason } core_failed_to_execute_ffmpeg = 未能执行 ffmpeg:{ $reason } core_ffmpeg_failed_with_status = ffmpeg 失败,状态 { $status }:{ $stderr } (命令:{ $command }) core_failed_to_load_image_frame = 加载图像帧失败:{ $reason } core_failed_to_extract_frame = 未能从 { $time } 秒的“{ $file }”中提取帧:{ $reason } core_failed_to_save_thumbnail = 缩略图保存失败“{ $file }”: { $reason } core_failed_get_frame_at_timestamp = 未能获取帧于时间戳 { $timestamp } 来自 "{ $file }": { $reason } core_failed_get_frame_from_file = 未能从 "{ $file }" 获取帧于 { $timestamp }:{ $reason } core_invalid_crop_rectangle = 无效作物矩形:左={ $left },上={ $top },右={ $right },下={ $bottom } core_failed_to_crop_video_file = 视频文件 "{ $file }" 裁剪失败:{ $reason } core_cropped_video_not_created = 裁剪视频文件未创建:{ $temp } core_unable_check_hash_of_file = 无法检查文件 "{ $file }" 的哈希值,原因 { $reason } core_error_checking_hash_of_file = 检查文件“{ $file }”的哈希时发生错误,原因 { $reason } core_image_zero_dimensions = 图片宽度或高度为零 "{ $path }" core_image_open_failed = 无法打开图像文件 "{ $path }": { $reason } core_not_directory_remove = 尝试删除文件夹 "{ $path }",它不是一个目录 core_cannot_read_directory = 无法读取目录 "{ $path }" core_cannot_read_entry_from_directory = 无法从目录 "{ $path }" 读取条目 core_folder_contains_file_inside = 文件夹包含文件 "{ $entry }" 在内 "{ $folder }" core_unknown_directory_entry = 无法确定目录条目 "{ $entry }" 在 "{ $path }" 内部的文件类型 core_video_width_exceeds_limit = 视频宽度 { $width } 超出 { $limit } 的限制 core_video_height_exceeds_limit = 视频高度 { $height } 超出 { $limit } 的限制 core_failed_to_process_video = 未能处理视频文件 { $file }: { $reason } core_optimized_file_larger = 优化文件 { $optimized } (大小:{ $new_size }) 未小于原始 { $original } (大小:{ $original_size }) core_unknown_codec = 未知编解码器:{ $codec } core_invalid_video_optimizer_mode = 无效视频优化模式:'{ $mode }'。允许的值:transcode, crop core_folder_does_not_exist = 文件夹不存在:{ $folder } core_path_not_directory = 路径不是一个目录:{ $folder } core_test_error_for_folder = 文件夹测试错误:{ $folder } core_unknown_exif_tag_group = 未知EXIF标签组:{ $tag } core_error_comparing_fingerprints = 比较指纹时出错:{ $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = 未能生成缩略图“{ $file }”: 提取的帧具有不同的尺寸 core_failed_to_generate_thumbnail = 未能生成缩略图“{ $file }”: { $reason } core_failed_to_extract_frame_at_seek_time = 未能从 { $time } 秒的“{ $file }”中提取帧:{ $reason } core_video_file_does_not_exist = 视频文件不存在(可删除在扫描/后续步骤之间):"{ $path }" core_image_too_large = 图片太大 ({ $width }x{ $height }) - 超过支持的 { $max } 像素 core_failed_to_get_video_metadata = 获取文件 "{ $file }" 的视频元数据失败:{ $reason } core_failed_to_get_video_codec = 无法获取文件“{ $file }”的视频编解码器 core_failed_to_get_video_duration = 无法获取文件 "{ $file }" 的视频时长 core_failed_to_get_video_dimensions = 无法获取文件 "{ $file }" 的视频尺寸 core_frame_dimensions_mismatch = 帧尺寸对于时间戳 { $timestamp } 不与第一帧尺寸 ({ $first_w }x{ $first_h }) 匹配 core_failed_to_load_data_from_cache = 无法从缓存文件 { $file } 加载数据,原因 { $reason } core_failed_to_load_data_from_json_cache = 未能从 json 缓存文件 { $file } 加载数据,原因 { $reason } core_failed_to_replace_with_optimized = 未能将文件“{ $file }”替换为优化版本:{ $reason } core_failed_to_write_data_to_cache = 无法将数据写入缓存文件 "{ $file }", 原因 { $reason } core_properly_saved_cache_entries = 正确保存到文件 { $count } 个缓存条目。. core_video_processing_stopped_by_user = 用户已停止视频处理 core_thumbnail_generation_stopped_by_user = 缩略图生成已由用户停止 core_failed_to_optimize_video = 视频优化失败 "{ $file }": { $reason } core_failed_to_crop_video = 视频裁剪失败 "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = 获取优化文件 "{ $file }" 的元数据失败:{ $reason } core_cannot_create_config_folder = 无法创建配置文件夹 "{ $folder }",原因 { $reason } core_cannot_create_cache_folder = 无法创建缓存文件夹 "{ $folder }", 原因 { $reason } core_cannot_create_or_open_cache_file = 无法创建或打开缓存文件 "{ $file }", 原因 { $reason } core_cannot_set_config_cache_path = 无法设置配置/缓存路径 - 配置和缓存将不会被使用。. core_invalid_extension_contains_space = { $extension } 不是一个有效的扩展名,因为它包含内部的空格 core_invalid_extension_contains_dot = { $extension } 不是一个有效的扩展名,因为它包含在点内 czkawka_core-11.0.1/i18n/zh-TW/czkawka_core.ftl000064400000000000000000000212761046102023000172610ustar 00000000000000# Core core_similarity_original = 原始 core_similarity_very_high = 極高 core_similarity_high = 高 core_similarity_medium = 中等 core_similarity_small = 小 core_similarity_very_small = 非常小 core_similarity_minimal = 最小 core_cannot_open_dir = 無法開啟目錄 { $dir },原因是 { $reason } core_cannot_read_entry_dir = 無法讀取目錄 { $dir } 中的項目,原因是 { $reason } core_cannot_read_metadata_dir = 無法讀取目錄 { $dir } 的中繼資料,原因是 { $reason } core_cannot_read_metadata_file = Cannot read metadata of file { $file }, reason { $reason } core_file_modified_before_epoch = File { $name } seems to have been modified before the Unix Epoch core_folder_modified_before_epoch = Folder { $name } seems to have been modified before the Unix Epoch core_file_no_modification_date = 無法取得檔案 { $name } 的修改日期,原因是 { $reason } core_folder_no_modification_date = 無法取得資料夾 { $name } 的修改日期,原因是 { $reason } core_cannot_start_scan_no_included_paths = 無法開始掃描,因為沒有包含的路徑 core_skip_exist_check_all_included_paths_nonexistent = 無法開始掃描,因為所有包含的路徑都不存在 core_missing_no_chosen_included_path = 無有效包含路徑被選擇 (排除路徑可能排除所有包含路徑) core_reference_included_paths_same = 無法在所有有效包含路徑同時也是參照路徑處開始掃描,請嘗試驗證或停用參照路徑 core_path_must_exists = 提供的路徑必須存在,忽略 { $path } core_must_be_directory_or_file = 提供的路徑必須指向一個有效的目錄或檔案,忽略 { $path } core_excluded_paths_pointless_slash = 排除 / 是沒有用的,因為它意味著不會掃描任何檔案 core_paths_unable_to_get_device_id = 無法從資料夾 { $path } 取得裝置 ID core_needs_allowed_extensions_limited_by_tool = 無法開始掃描,當此工具 ({ $extensions }) 中所有可用的擴充功能都已從掃描中排除時 core_needs_allowed_extensions = 無法開始掃描,當所有擴展功能已從掃描中排除 core_needs_to_set_at_least_one_broken_option = 無法開始掃描,當沒有將「損壞選項」設定為掃描時 core_needs_to_set_at_least_one_bad_name_option = 無法開始掃描,當沒有將「不良名稱」選項設定為掃描時 core_ffmpeg_not_found = 無法找到正確的 FFmpeg 或 FFprobe 安裝。這些是必須手動安裝的外部程式。. core_ffmpeg_not_found_windows = 請確保ffmpeg.exe和ffprobe.exe可用於PATH,或直接放置在與應用程式執行檔同一資料夾中。 core_invalid_symlink_infinite_recursion = 無限遞迴 core_invalid_symlink_non_existent_destination = 目標檔案不存在 core_messages_limit_reached_characters = Number of messages exceeded the set limit ({ $current }/{ $limit } characters), so the output was truncated. To read the full output, disable the limiting option in settings. core_messages_limit_reached_lines = Number of messages exceeded the set limit ({ $current }/{ $limit } lines), so the output was truncated. To read the full output, disable the limiting option in settings. core_error_moving_to_trash = 移動"{ $file }"到垃圾桶時出現錯誤:{ $error } core_error_removing = 移除"{ $file }"時發生錯誤:{ $error } core_no_similarity_method_selected = 無法找到沒有選擇相似度方法的類似音樂檔案 core_failed_to_spawn_command = 未能生成命令:{ $reason } core_failed_to_check_process_status = 無法檢查進程狀態:{ $reason } core_failed_to_wait_for_process = 未能等待處理:{ $reason } core_failed_to_read_video_properties = 無法讀取影片屬性:{ $reason } core_failed_to_execute_ffmpeg = 無法執行 ffmpeg:{ $reason } core_ffmpeg_failed_with_status = ffmpeg 失敗,狀態為 { $status }:{ $stderr } (命令:{ $command }) core_failed_to_load_image_frame = 無法載入圖片畫面:{ $reason } core_failed_to_extract_frame = 在 { $time } 秒處未能提取幀來自 "{ $file }": { $reason } core_failed_to_save_thumbnail = 無法儲存 "{ $file }" 的縮圖:{ $reason } core_failed_get_frame_at_timestamp = 未能於時間戳 { $timestamp } 從 "{ $file }" 取得畫面:{ $reason } core_failed_get_frame_from_file = 從 "{ $file }" 在 { $timestamp } 標記時間取得畫面失敗:{ $reason } core_invalid_crop_rectangle = 無效的作物矩形:左={ $left },上={ $top },右={ $right },下={ $bottom } core_failed_to_crop_video_file = 無法裁剪影片檔案 "{ $file }": { $reason } core_cropped_video_not_created = 裁剪後的影片檔案未建立:{ $temp } core_unable_check_hash_of_file = 無法檢查檔案 "{ $file }" 的雜項,原因 { $reason } core_error_checking_hash_of_file = 檢查檔案 "{ $file }" 的雜項時發生錯誤,原因 { $reason } core_image_zero_dimensions = 圖片具有零寬度或高度 "{ $path }" core_image_open_failed = 無法開啟圖片檔案 "{ $path }": { $reason } core_not_directory_remove = 嘗試移除檔案夾 "{ $path }",它不是一個目錄 core_cannot_read_directory = 無法讀取目錄 "{ $path }" core_cannot_read_entry_from_directory = 無法從目錄 "{ $path }" 讀取資料 core_folder_contains_file_inside = 資料夾內包含檔案 "{ $entry }" 位於 "{ $folder }" core_unknown_directory_entry = 無法判斷目錄入口 "{ $entry }" 內部的檔案類型 inside "{ $path }" core_video_width_exceeds_limit = 影片寬度 { $width } 超過 { $limit } 的限制 core_video_height_exceeds_limit = 影片高度 { $height } 超過 { $limit } 的限制 core_failed_to_process_video = 無法處理影片檔案 { $file }: { $reason } core_optimized_file_larger = 優化檔案 { $optimized } (大小:{ $new_size }) 未超過原始檔案 { $original } (大小:{ $original_size }) core_unknown_codec = 未知編碼格式:{ $codec } core_invalid_video_optimizer_mode = 無效的影片優化模式:'{ $mode }'。允許的值:transcode, crop core_folder_does_not_exist = 資料夾不存在:{ $folder } core_path_not_directory = 路徑不是一個目錄:{ $folder } core_test_error_for_folder = 測試資料夾錯誤:{ $folder } core_unknown_exif_tag_group = 未知的 EXIF 標籤群組:{ $tag } core_error_comparing_fingerprints = 比較指紋時發生錯誤:{ $reason } core_failed_to_generate_thumbnail_frames_different_dimensions = 未能為 "{ $file }" 產生縮圖:提取的幀有不同尺寸 core_failed_to_generate_thumbnail = 未能為 "{ $file }" 產生縮圖:{ $reason } core_failed_to_extract_frame_at_seek_time = 在 { $time } 秒處未能提取幀來自 "{ $file }": { $reason } core_video_file_does_not_exist = 影片檔案不存在 (可移除於掃描/後續步驟中):"{ $path }" core_image_too_large = 圖片太大 ({ $width }x{ $height }) - 超過支援 { $max } 像素 core_failed_to_get_video_metadata = 無法取得檔案 "{ $file }" 的影片元資料:{ $reason } core_failed_to_get_video_codec = 無法取得檔案 "{ $file }" 的影片碼通 core_failed_to_get_video_duration = 無法取得檔案 "{ $file }" 的影片長度 core_failed_to_get_video_dimensions = 無法取得檔案 "{ $file }" 的影片尺寸 core_frame_dimensions_mismatch = 時間戳 { $timestamp } 的畫框尺寸與第一個畫框尺寸 ({ $first_w }x{ $first_h }) 不符 core_failed_to_load_data_from_cache = 無法從快取檔案 { $file } 加載資料,原因 { $reason } core_failed_to_load_data_from_json_cache = 未能從 JSON 緩存檔案 { $file } 加載資料,原因 { $reason } core_failed_to_replace_with_optimized = 未能將檔案 "{ $file }" 替換為優化版本:{ $reason } core_failed_to_write_data_to_cache = 無法將資料寫入快取檔案 "{ $file }", 原因 { $reason } core_properly_saved_cache_entries = 正確儲存至檔案 { $count } 個緩存條目。. core_video_processing_stopped_by_user = 使用者已停止影片處理 core_thumbnail_generation_stopped_by_user = 使用者已停止生成縮圖 core_failed_to_optimize_video = 無法優化影片 "{ $file }": { $reason } core_failed_to_crop_video = 視頻裁剪失敗 "{ $file }": { $reason } core_failed_to_get_metadata_of_optimized_file = 無法取得優化檔案 "{ $file }" 的元資料:{ $reason } core_cannot_create_config_folder = 無法建立配置資料夾 "{ $folder }",原因 { $reason } core_cannot_create_cache_folder = 無法建立快取資料夾 "{ $folder }",原因 { $reason } core_cannot_create_or_open_cache_file = 無法建立或開啟快取檔案 "{ $file }",原因 { $reason } core_cannot_set_config_cache_path = 無法設定 config/cache 路径 - config 和 cache 将不会被使用。. core_invalid_extension_contains_space = { $extension } 不是一個有效的擴充類型,因為它包含內部的空白 core_invalid_extension_contains_dot = { $extension } 不是一個有效的擴充類型,因為它包含內部的點。 czkawka_core-11.0.1/i18n.toml000064400000000000000000000007111046102023000140220ustar 00000000000000# (Required) The language identifier of the language used in the # source code for gettext system, and the primary fallback language # (for which all strings must be present) when using the fluent # system. fallback_language = "en" # Use the fluent localization system. [fluent] # (Required) The path to the assets directory. # The paths inside the assets directory should be structured like so: # `assets_dir/{language}/{domain}.ftl` assets_dir = "i18n" czkawka_core-11.0.1/src/common/basic_gui_cli.rs000064400000000000000000000213571046102023000175600ustar 00000000000000use std::process; use log::{error, warn}; use crate::common::config_cache_path::get_config_cache_path; use crate::{CZKAWKA_VERSION, flc}; #[derive(Clone, Debug)] pub struct CliResult { pub included_items: Vec, pub excluded_items: Vec, pub referenced_items: Vec, } enum ExpectedArgs { Include, Exclude, Referenced, } // Manual processing of CLI arguments, because Clap would be too heavy for this simple task #[expect(clippy::print_stdout)] #[expect(clippy::print_stderr)] pub fn process_cli_args(app_display: &str, app_exec: &str, args: Vec) -> Option { if ["--help", "-h"].iter().any(|&arg| args.contains(&arg.to_string())) { println!("{app_display}"); println!("{app_display} allows you to specify folders to search for files via the CLI, and also to exclude or reference folders."); println!("If used, it will automatically apply the last preset and load its options."); println!("Running the app without arguments will launch the {app_display} with default or saved options."); println!("Usage: {app_exec} [OPTIONS] [FOLDERS...]"); println!("Options:"); println!(" FOLDER Include a folder in the search"); println!(" -e FOLDER, --exclude FOLDER Exclude a folder from the search"); println!(" -r FOLDER, --referenced FOLDER Include a folder and set it as referenced"); println!(" --cache, -c Opens the cache folder"); println!(" --config, -C Opens the config folder"); println!(" --help, -h Show this help message"); println!(" --version, -v Show version information"); println!("Examples:"); println!(" {app_exec} /path/absolute/to/folder -e relative_path/2 -r /path/to/referenced"); println!(" {app_exec} . folder2 folder3"); println!("If no folders are specified, the program will exit without doing anything."); process::exit(0); } if ["--version", "-v"].iter().any(|&arg| args.contains(&arg.to_string())) { let git_commit = env!("CZKAWKA_GIT_COMMIT_SHORT"); let official_build = if env!("CZKAWKA_OFFICIAL_BUILD") == "1" { "O" // Official build } else { "U" // Unofficial build }; let git_date = env!("CZKAWKA_GIT_COMMIT_DATE"); println!("{app_display} version {CZKAWKA_VERSION}({git_commit} {official_build} {git_date})"); process::exit(0); } let mut expected_arg = ExpectedArgs::Include; let mut cli_result = CliResult { included_items: Vec::new(), excluded_items: Vec::new(), referenced_items: Vec::new(), }; let mut errors = Vec::new(); for arg in args { if arg.starts_with("-") { match arg.as_str() { "-e" | "--exclude" => expected_arg = ExpectedArgs::Exclude, "-r" | "--referenced" => expected_arg = ExpectedArgs::Referenced, "-c" | "--cache" => { if let Some(cfg) = get_config_cache_path() { if let Err(e) = open::that(&cfg.cache_folder) { error!("Failed to open cache folder \"{}\": {e}", cfg.cache_folder.to_string_lossy()); process::exit(1); } process::exit(0); } else { error!("Failed to get cache folder path"); process::exit(1); } } "-C" | "--config" => { if let Some(cfg) = get_config_cache_path() { if let Err(e) = open::that(&cfg.config_folder) { error!("Failed to open config folder \"{}\": {e}", cfg.config_folder.to_string_lossy()); process::exit(1); } process::exit(0); } else { error!("Failed to get config folder path"); process::exit(1); } } _ => { eprintln!("Unknown option: {arg}"); process::exit(1); } } } else { match expected_arg { ExpectedArgs::Include => match check_if_folder_is_valid(&arg) { Ok(folder) => cli_result.included_items.push(folder), Err(e) => errors.push(e), }, ExpectedArgs::Exclude => match check_if_folder_is_valid(&arg) { Ok(folder) => cli_result.excluded_items.push(folder), Err(e) => errors.push(e), }, ExpectedArgs::Referenced => match check_if_folder_is_valid(&arg) { Ok(folder) => { cli_result.included_items.push(folder.clone()); cli_result.referenced_items.push(folder); } Err(e) => errors.push(e), }, } expected_arg = ExpectedArgs::Include; } } deduplicate_folders(&mut cli_result.included_items); deduplicate_folders(&mut cli_result.excluded_items); deduplicate_folders(&mut cli_result.referenced_items); if !errors.is_empty() { warn!("Errors encountered while processing CLI arguments:"); } for error in &errors { warn!("{error}"); } if cli_result.included_items.is_empty() && cli_result.excluded_items.is_empty() && cli_result.referenced_items.is_empty() { None } else { Some(cli_result) } } fn deduplicate_folders(folder_list: &mut Vec) { folder_list.sort(); folder_list.dedup(); } #[cfg(not(test))] fn check_if_folder_is_valid(folder: &str) -> Result { let path = std::path::Path::new(folder); if !path.exists() { return Err(flc!("core_folder_does_not_exist", folder = folder)); } if !path.is_dir() { return Err(flc!("core_path_not_directory", folder = folder)); } let canonical_path = dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {folder}. Error: {e}"))?; Ok(canonical_path.to_string_lossy().to_string()) } #[cfg(test)] fn check_if_folder_is_valid(folder: &str) -> Result { if folder.contains("test_error") { return Err(flc!("core_test_error_for_folder", folder = folder)); } Ok(folder.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn processes_include_folder() { let args = vec!["/valid/folder".to_string()]; let result = process_cli_args("A", "B", args).expect("TEST"); assert_eq!(result.included_items, vec!["/valid/folder".to_string()]); assert!(result.excluded_items.is_empty()); assert!(result.referenced_items.is_empty()); } #[test] fn processes_exclude_folder() { let args = vec!["-e".to_string(), "/valid/folder".to_string()]; let result = process_cli_args("A", "B", args).expect("TEST"); assert!(result.included_items.is_empty()); assert_eq!(result.excluded_items, vec!["/valid/folder".to_string()]); assert!(result.referenced_items.is_empty()); } #[test] fn processes_referenced_folder() { let args = vec!["-r".to_string(), "/valid/folder".to_string()]; let result = process_cli_args("A", "B", args).expect("TEST"); assert_eq!(result.included_items, vec!["/valid/folder".to_string()]); assert!(result.excluded_items.is_empty()); assert_eq!(result.referenced_items, vec!["/valid/folder".to_string()]); } #[test] fn processes_multiple_same_folder() { let args = [ "-r", "/valid/folder", "-r", "/valid/folder", "normal_folder", "abcd", "abcd", "-e", "/exclu", "normal_folder", ] .iter() .map(|s| s.to_string()) .collect(); let result = process_cli_args("A", "B", args).expect("TEST"); assert_eq!(result.included_items, vec!["/valid/folder".to_string(), "abcd".to_string(), "normal_folder".to_string()]); assert_eq!(result.excluded_items, vec!["/exclu".to_string()]); assert_eq!(result.referenced_items, vec!["/valid/folder".to_string()]); } #[test] fn handles_invalid_folder() { let args = vec!["/invalid/test_error".to_string()]; let result = process_cli_args("A", "B", args); assert!(result.is_none()); } #[test] fn handles_no_arguments() { let args = Vec::new(); let result = process_cli_args("A", "B", args); assert!(result.is_none()); } } czkawka_core-11.0.1/src/common/cache/cleaning.rs000064400000000000000000000704621046102023000176300ustar 00000000000000use std::fs; use std::io::{BufReader, BufWriter}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use bincode::Options; use crossbeam_channel::Sender; use fun_time::fun_time; use log::{debug, error}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::common::cache::{ CACHE_BROKEN_FILES_VERSION, CACHE_CLEANING_INTERVAL_SECONDS, CACHE_DUPLICATE_VERSION, CACHE_IMAGE_VERSION, CACHE_VERSION, CACHE_VIDEO_OPTIMIZE_VERSION, CACHE_VIDEO_VERSION, CLEANING_TIMESTAMPS_FILE, MEMORY_LIMIT, }; use crate::common::config_cache_path::get_config_cache_path; use crate::common::traits::ResultEntry; use crate::tools::broken_files::BrokenEntry; use crate::tools::duplicate::DuplicateEntry; use crate::tools::exif_remover::ExifEntry; use crate::tools::same_music::MusicEntry; use crate::tools::similar_images::ImagesEntry; use crate::tools::similar_videos::VideosEntry; use crate::tools::video_optimizer::{VideoCropEntry, VideoTranscodeEntry}; #[derive(Debug, Clone, Default)] pub struct CacheCleaningStatistics { pub total_files_found: usize, pub successfully_cleaned: usize, pub files_with_errors: usize, pub total_entries_before: usize, pub total_entries_removed: usize, pub total_entries_left: usize, pub total_size_before: u64, pub total_size_after: u64, pub errors: Vec, } #[derive(Debug, Clone, Default)] pub struct CacheProgressCleaning { pub current_cache_file: usize, pub total_cache_files: usize, pub current_file_name: String, pub checked_entries: usize, pub all_entries: usize, } #[derive(Deserialize, Serialize, Debug)] struct CleaningTimestamps { timestamps: Vec, } #[derive(Deserialize, Serialize, Debug)] struct SingleCleaningTimestamp { cache_file_name: String, last_cleaned_timestamp: u64, } fn get_timestamps_file_path() -> Option { get_config_cache_path().map(|config| config.cache_folder.join(CLEANING_TIMESTAMPS_FILE)) } pub(crate) fn should_clean_cache(cache_file_name: &str) -> bool { let Some(timestamps_file) = get_timestamps_file_path() else { return true; }; let Ok(content) = fs::read_to_string(×tamps_file) else { return true; }; let cleaning_timestamps = match serde_json::from_str::(&content) { Ok(t) => t, Err(e) => { error!( "Failed to parse cleaning timestamps file \"{}\" while processing cache file \"{cache_file_name}\" - {e:?}", timestamps_file.to_string_lossy() ); return true; } }; let current_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); if let Some(timestamp) = cleaning_timestamps.timestamps.iter().find(|t| t.cache_file_name == cache_file_name) { let time_since_last_cleaning = current_time.saturating_sub(timestamp.last_cleaned_timestamp); if time_since_last_cleaning < *CACHE_CLEANING_INTERVAL_SECONDS { debug!( "Last cleaning for {} was {} seconds ago, which is less than the configured interval of {} seconds. Skipping cleaning.", cache_file_name, time_since_last_cleaning, *CACHE_CLEANING_INTERVAL_SECONDS ); return false; } debug!( "Last cleaning for {} was {} seconds ago, which exceeds the configured interval of {} seconds. Proceeding with cleaning.", cache_file_name, time_since_last_cleaning, *CACHE_CLEANING_INTERVAL_SECONDS ); return true; } debug!("No cleaning timestamp found for {cache_file_name}, cache cleaning should run"); true } pub(crate) fn update_cleaning_timestamp(cache_file_name: &str) { let Some(timestamps_file) = get_timestamps_file_path() else { return; }; let mut cleaning_timestamps = if let Ok(content) = fs::read_to_string(×tamps_file) { serde_json::from_str::(&content).unwrap_or_else(|e| { error!("Failed to parse cleaning timestamps file \"{}\" content - {e:?}", timestamps_file.to_string_lossy()); CleaningTimestamps { timestamps: Vec::new() } }) } else { CleaningTimestamps { timestamps: Vec::new() } }; let current_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); if let Some(timestamp) = cleaning_timestamps.timestamps.iter_mut().find(|t| t.cache_file_name == cache_file_name) { timestamp.last_cleaned_timestamp = current_time; } else { cleaning_timestamps.timestamps.push(SingleCleaningTimestamp { cache_file_name: cache_file_name.to_string(), last_cleaned_timestamp: current_time, }); } if let Ok(serialized) = serde_json::to_string_pretty(&cleaning_timestamps) { if let Err(e) = fs::write(×tamps_file, serialized) { error!("Failed to write cleaning timestamps to file {}: {e}", timestamps_file.to_string_lossy()); } } else { error!("Failed to serialize cleaning timestamps"); } } #[derive(Debug)] enum CacheType { Duplicates, MusicTags, MusicFingerprints, SimilarImages, SimilarVideos, BrokenFiles, ExifRemover, VideoTranscode, VideoCrop, } impl CacheType { fn from_filename(filename: &str) -> Option { if filename.starts_with("cache_duplicates_") && filename.ends_with(&format!("_{CACHE_DUPLICATE_VERSION}.bin")) { Some(Self::Duplicates) } else if filename == format!("cache_same_music_tags_{CACHE_VERSION}.bin") { Some(Self::MusicTags) } else if filename == format!("cache_same_music_fingerprints_{CACHE_VERSION}.bin") { Some(Self::MusicFingerprints) } else if filename.starts_with("cache_similar_images_") && filename.ends_with(&format!("_{CACHE_IMAGE_VERSION}.bin")) { Some(Self::SimilarImages) } else if filename.starts_with(&format!("cache_similar_videos_{CACHE_VIDEO_VERSION}__")) && filename.ends_with(".bin") { Some(Self::SimilarVideos) } else if filename == format!("cache_broken_files_{CACHE_BROKEN_FILES_VERSION}.bin") { Some(Self::BrokenFiles) } else if filename == format!("cache_exif_remover_{CACHE_VERSION}.bin") { Some(Self::ExifRemover) } else if filename == format!("cache_video_transcode_{CACHE_VIDEO_OPTIMIZE_VERSION}.bin") { Some(Self::VideoTranscode) } else if filename.starts_with(&format!("cache_video_crop_{CACHE_VIDEO_OPTIMIZE_VERSION}_")) && filename.ends_with(".bin") { Some(Self::VideoCrop) } else { None } } } #[fun_time(message = "clean_all_cache_files", level = "debug")] pub fn clean_all_cache_files(stop_flag: &Arc, cache_progress_sender: Option<&Sender>) -> Result { let mut stats = CacheCleaningStatistics::default(); let Some(config_cache_path) = get_config_cache_path() else { return Err("Cannot get cache folder path".to_string()); }; let cache_folder = &config_cache_path.cache_folder; let entries = fs::read_dir(cache_folder).map_err(|e| format!("Cannot read cache folder \"{}\": {}", cache_folder.to_string_lossy(), e))?; let cache_files: Vec<_> = entries .flatten() .filter_map(|entry| { let path = entry.path(); if !path.is_file() { return None; } let file_name = path.file_name()?.to_str()?.to_string(); let cache_type = CacheType::from_filename(&file_name)?; Some((path, file_name, cache_type)) }) .collect(); let total_files = cache_files.len(); let current_file = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let current_file_name = Arc::new(std::sync::Mutex::new(String::new())); let checked_entries = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let all_entries = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let progress_thread = cache_progress_sender.map(|sender| { let sender = sender.clone(); let stop_flag = stop_flag.clone(); let current_file = current_file.clone(); let current_file_name = current_file_name.clone(); let checked_entries = checked_entries.clone(); let all_entries = all_entries.clone(); std::thread::spawn(move || { while !stop_flag.load(Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_millis(100)); let current = current_file.load(Ordering::Relaxed); let name = current_file_name.lock().expect("Mutex poisoned").clone(); let checked = checked_entries.load(Ordering::Relaxed); let all = all_entries.load(Ordering::Relaxed); if current > 0 { let _ = sender.send(CacheProgressCleaning { current_cache_file: current, total_cache_files: total_files, current_file_name: name, checked_entries: checked, all_entries: all, }); } } }) }); for (current_file_idx, (path, file_name, cache_type)) in cache_files.into_iter().enumerate() { if stop_flag.load(Ordering::Relaxed) { return Err("Operation stopped by user".to_string()); } stats.total_files_found += 1; debug!("Found cache file to clean: {file_name} (type: {cache_type:?})"); current_file.store(current_file_idx + 1, Ordering::Relaxed); *current_file_name.lock().expect("Lock poisoned") = file_name.clone(); checked_entries.store(0, Ordering::Relaxed); all_entries.store(0, Ordering::Relaxed); let result = match cache_type { CacheType::Duplicates => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::MusicTags | CacheType::MusicFingerprints => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::SimilarImages => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::SimilarVideos => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::BrokenFiles => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::ExifRemover => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::VideoTranscode => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), CacheType::VideoCrop => clean_cache_file_typed::(&path, stop_flag, &checked_entries, &all_entries), }; match result { Ok(Some((before, after, size_before, size_after))) => { stats.successfully_cleaned += 1; stats.total_entries_before += before; stats.total_entries_left += after; stats.total_entries_removed += before - after; stats.total_size_before += size_before; stats.total_size_after += size_after; update_cleaning_timestamp(&file_name); } Ok(None) => { debug!("Cleaning of cache file {file_name} was skipped due to stop flag"); return Err("Operation stopped by user".to_string()); } Err(e) => { stats.files_with_errors += 1; stats.errors.push(format!("{file_name}: {e}")); } } } stop_flag.store(true, Ordering::Relaxed); if let Some(handle) = progress_thread { let _ = handle.join(); } Ok(stats) } fn clean_cache_file_typed( cache_path: &Path, stop_flag: &Arc, checked_entries: &Arc, all_entries: &Arc, ) -> Result, String> where for<'a> T: Deserialize<'a> + ResultEntry + Serialize + Clone + Send, { let size_before = fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0); let file = fs::File::open(cache_path).map_err(|e| format!("Cannot open file: {e}"))?; let reader = BufReader::new(file); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); let entries: Vec = options.deserialize_from(reader).map_err(|e| format!("Cannot deserialize file: {e}"))?; let original_count = entries.len(); all_entries.store(original_count, Ordering::Relaxed); let checked_entries_clone = checked_entries.clone(); let filtered_entries: Vec = entries .into_par_iter() .map(|cached_entry| { if stop_flag.load(Ordering::Relaxed) { return None; } checked_entries_clone.fetch_add(1, Ordering::Relaxed); let Ok(metadata) = fs::metadata(cached_entry.get_path()) else { return Some(None); }; if metadata.len() != cached_entry.get_size() { return Some(None); } if let Ok(modified_time) = metadata.modified() { if let Ok(duration_since_epoch) = modified_time.duration_since(std::time::UNIX_EPOCH) { if duration_since_epoch.as_secs() != cached_entry.get_modified_date() { return Some(None); } } else { return Some(None); } } Some(Some(cached_entry)) }) .while_some() .flatten() .collect(); if stop_flag.load(Ordering::Relaxed) { return Ok(None); } let remaining_count = filtered_entries.len(); let removed_count = original_count - remaining_count; let size_after = if removed_count > 0 { let tmp_file_path = cache_path.with_extension("tmp"); let tmp_file = fs::File::create(&tmp_file_path).map_err(|e| format!("Cannot create temporary file: {e}"))?; let writer = BufWriter::new(tmp_file); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); options .serialize_into(writer, &filtered_entries) .map_err(|e| format!("Cannot serialize cleaned data to temporary file: {e}"))?; let new_size = fs::metadata(&tmp_file_path).map(|m| m.len()).unwrap_or(size_before); fs::rename(&tmp_file_path, cache_path).map_err(|e| format!("Cannot replace original cache file: {e}"))?; debug!( "Cleaned cache file \"{}\": removed {} entries, {} remaining, size reduced from {} to {} bytes", cache_path.to_string_lossy(), removed_count, filtered_entries.len(), size_before, new_size ); new_size } else { size_before }; Ok(Some((original_count, remaining_count, size_before, size_after))) } #[cfg(test)] mod tests { use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::UNIX_EPOCH; use bincode::Options; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use super::*; use crate::common::cache::tests::setup_cache_path; #[derive(Clone, Debug, Serialize, Deserialize)] struct TestCacheEntry { path: PathBuf, size: u64, modified_date: u64, data: String, } impl ResultEntry for TestCacheEntry { fn get_path(&self) -> &Path { &self.path } fn get_size(&self) -> u64 { self.size } fn get_modified_date(&self) -> u64 { self.modified_date } } fn setup_test_env() -> (PathBuf, PathBuf) { setup_cache_path(); let config_cache = get_config_cache_path().unwrap(); (config_cache.cache_folder.clone(), config_cache.config_folder) } fn create_test_file(dir: &Path, name: &str, content: &str) -> (PathBuf, u64, u64) { let path = dir.join(name); fs::write(&path, content).unwrap(); let metadata = fs::metadata(&path).unwrap(); let modified = metadata.modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs(); (path, metadata.len(), modified) } fn create_cache_file(cache_dir: &Path, name: &str, entries: &[TestCacheEntry]) -> PathBuf { let cache_path = cache_dir.join(name); let file = fs::File::create(&cache_path).unwrap(); let writer = BufWriter::new(file); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); options.serialize_into(writer, entries).unwrap(); cache_path } #[test] fn test_timestamp_operations_and_should_clean() { let (_cache_dir, _config_dir) = setup_test_env(); let cache_name = format!("test_cache_{}", std::process::id()); assert!(should_clean_cache(&cache_name)); update_cleaning_timestamp(&cache_name); assert!(!should_clean_cache(&cache_name)); update_cleaning_timestamp(&cache_name); assert!(!should_clean_cache(&cache_name)); let different_cache = format!("different_cache_{}", std::process::id()); assert!(should_clean_cache(&different_cache)); update_cleaning_timestamp(&different_cache); assert!(!should_clean_cache(&different_cache)); assert!(!should_clean_cache(&cache_name)); } #[test] fn test_clean_cache_file_typed_mixed_scenarios() { let (cache_dir, _config_dir) = setup_test_env(); let data_dir = TempDir::new().unwrap(); let (valid_path, valid_size, valid_modified) = create_test_file(data_dir.path(), "valid.txt", "valid content"); let (modified_path, _, old_modified) = create_test_file(data_dir.path(), "modified.txt", "old content"); std::thread::sleep(std::time::Duration::from_millis(100)); fs::write(&modified_path, "new content with different size").unwrap(); let (deleted_path, deleted_size, deleted_modified) = create_test_file(data_dir.path(), "deleted.txt", "to be deleted"); fs::remove_file(&deleted_path).unwrap(); let entries = vec![ TestCacheEntry { path: valid_path.clone(), size: valid_size, modified_date: valid_modified, data: "valid".to_string(), }, TestCacheEntry { path: modified_path, size: 11, modified_date: old_modified, data: "modified".to_string(), }, TestCacheEntry { path: deleted_path, size: deleted_size, modified_date: deleted_modified, data: "deleted".to_string(), }, ]; let cache_path = create_cache_file(cache_dir.as_path(), "test_cache.bin", &entries); let stop_flag = Arc::new(AtomicBool::new(false)); let checked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let all = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let result = clean_cache_file_typed::(&cache_path, &stop_flag, &checked, &all).unwrap(); assert!(result.is_some()); let (original, remaining, _, _) = result.unwrap(); assert_eq!(original, 3); assert_eq!(remaining, 1); assert_eq!(checked.load(Ordering::Relaxed), 3); assert_eq!(all.load(Ordering::Relaxed), 3); let file = fs::File::open(&cache_path).unwrap(); let reader = BufReader::new(file); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); let cleaned_entries: Vec = options.deserialize_from(reader).unwrap(); assert_eq!(cleaned_entries.len(), 1); assert_eq!(cleaned_entries[0].path, valid_path); } #[test] fn test_clean_cache_file_with_stop_flag() { let (cache_dir, _config_dir) = setup_test_env(); let data_dir = TempDir::new().unwrap(); const ENTRIES_NUMBER: usize = 100; let mut entries = Vec::new(); for i in 0..ENTRIES_NUMBER { let (path, size, modified) = create_test_file(data_dir.path(), &format!("file_{i}.txt"), &format!("content {i}")); entries.push(TestCacheEntry { path, size, modified_date: modified, data: format!("data {i}"), }); } let cache_path = create_cache_file(cache_dir.as_path(), "test_stop.bin", &entries); let stop_flag = Arc::new(AtomicBool::new(false)); let checked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let all = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let stop_flag_clone = stop_flag.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1)); stop_flag_clone.store(true, Ordering::Relaxed); }); // Well - it may fail in any place, so we just cannot check exact number of checked entries let result = clean_cache_file_typed::(&cache_path, &stop_flag, &checked, &all).unwrap(); if result.is_some() { assert!(checked.load(Ordering::Relaxed) <= ENTRIES_NUMBER); } } #[test] fn test_cache_type_from_filename_all_variants() { assert!(matches!( CacheType::from_filename(&format!("cache_duplicates_hash_{CACHE_DUPLICATE_VERSION}.bin")), Some(CacheType::Duplicates) )); assert!(matches!( CacheType::from_filename(&format!("cache_duplicates_size_{CACHE_DUPLICATE_VERSION}.bin")), Some(CacheType::Duplicates) )); assert!(matches!( CacheType::from_filename(&format!("cache_same_music_tags_{CACHE_VERSION}.bin")), Some(CacheType::MusicTags) )); assert!(matches!( CacheType::from_filename(&format!("cache_same_music_fingerprints_{CACHE_VERSION}.bin")), Some(CacheType::MusicFingerprints) )); assert!(matches!( CacheType::from_filename(&format!("cache_similar_images_8_{CACHE_IMAGE_VERSION}.bin")), Some(CacheType::SimilarImages) )); assert!(matches!( CacheType::from_filename(&format!("cache_similar_videos_{CACHE_VIDEO_VERSION}__10.bin")), Some(CacheType::SimilarVideos) )); assert!(matches!( CacheType::from_filename(&format!("cache_broken_files_{CACHE_BROKEN_FILES_VERSION}.bin")), Some(CacheType::BrokenFiles) )); assert!(matches!( CacheType::from_filename(&format!("cache_exif_remover_{CACHE_VERSION}.bin")), Some(CacheType::ExifRemover) )); assert!(matches!( CacheType::from_filename(&format!("cache_video_transcode_{CACHE_VIDEO_OPTIMIZE_VERSION}.bin")), Some(CacheType::VideoTranscode) )); assert!(matches!( CacheType::from_filename(&format!("cache_video_crop_{CACHE_VIDEO_OPTIMIZE_VERSION}_test.bin")), Some(CacheType::VideoCrop) )); assert!(CacheType::from_filename("invalid_cache.bin").is_none()); assert!(CacheType::from_filename("cache_duplicates_99.bin").is_none()); assert!(CacheType::from_filename("random_file.txt").is_none()); } #[test] fn test_clean_cache_file_no_changes_needed() { let (cache_dir, _config_dir) = setup_test_env(); let data_dir = TempDir::new().unwrap(); let mut entries = Vec::new(); for i in 0..5 { let (path, size, modified) = create_test_file(data_dir.path(), &format!("valid_{i}.txt"), &format!("valid content {i}")); entries.push(TestCacheEntry { path, size, modified_date: modified, data: format!("data {i}"), }); } let cache_path = create_cache_file(cache_dir.as_path(), "test_no_changes.bin", &entries); let size_before = fs::metadata(&cache_path).unwrap().len(); let stop_flag = Arc::new(AtomicBool::new(false)); let checked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let all = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let result = clean_cache_file_typed::(&cache_path, &stop_flag, &checked, &all).unwrap(); assert!(result.is_some()); let (original, remaining, size_before_result, size_after) = result.unwrap(); assert_eq!(original, 5); assert_eq!(remaining, 5); assert_eq!(size_before_result, size_before); assert_eq!(size_after, size_before); } #[test] fn test_clean_cache_file_all_entries_invalid() { let (cache_dir, _config_dir) = setup_test_env(); let data_dir = TempDir::new().unwrap(); let (deleted1, size1, mod1) = create_test_file(data_dir.path(), "del1.txt", "content 1"); let (deleted2, size2, mod2) = create_test_file(data_dir.path(), "del2.txt", "content 2"); let (deleted3, size3, mod3) = create_test_file(data_dir.path(), "del3.txt", "content 3"); fs::remove_file(&deleted1).unwrap(); fs::remove_file(&deleted2).unwrap(); fs::remove_file(&deleted3).unwrap(); let entries = vec![ TestCacheEntry { path: deleted1, size: size1, modified_date: mod1, data: "1".to_string(), }, TestCacheEntry { path: deleted2, size: size2, modified_date: mod2, data: "2".to_string(), }, TestCacheEntry { path: deleted3, size: size3, modified_date: mod3, data: "3".to_string(), }, ]; let cache_path = create_cache_file(cache_dir.as_path(), "test_all_invalid.bin", &entries); let stop_flag = Arc::new(AtomicBool::new(false)); let checked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let all = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let result = clean_cache_file_typed::(&cache_path, &stop_flag, &checked, &all).unwrap(); assert!(result.is_some()); let (original, remaining, _, _) = result.unwrap(); assert_eq!(original, 3); assert_eq!(remaining, 0); let file = fs::File::open(&cache_path).unwrap(); let reader = BufReader::new(file); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); let cleaned_entries: Vec = options.deserialize_from(reader).unwrap(); assert_eq!(cleaned_entries.len(), 0); } #[test] fn test_cache_progress_cleaning_struct() { let progress = CacheProgressCleaning { current_cache_file: 3, total_cache_files: 10, current_file_name: "test_cache.bin".to_string(), checked_entries: 50, all_entries: 100, }; assert_eq!(progress.current_cache_file, 3); assert_eq!(progress.total_cache_files, 10); assert_eq!(progress.current_file_name, "test_cache.bin"); assert_eq!(progress.checked_entries, 50); assert_eq!(progress.all_entries, 100); } #[test] fn test_cleaning_timestamps_serialization() { let timestamps = CleaningTimestamps { timestamps: vec![ SingleCleaningTimestamp { cache_file_name: "cache1.bin".to_string(), last_cleaned_timestamp: 1000, }, SingleCleaningTimestamp { cache_file_name: "cache2.bin".to_string(), last_cleaned_timestamp: 2000, }, ], }; let serialized = serde_json::to_string(×tamps).unwrap(); let deserialized: CleaningTimestamps = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized.timestamps.len(), 2); assert_eq!(deserialized.timestamps[0].cache_file_name, "cache1.bin"); assert_eq!(deserialized.timestamps[0].last_cleaned_timestamp, 1000); assert_eq!(deserialized.timestamps[1].cache_file_name, "cache2.bin"); assert_eq!(deserialized.timestamps[1].last_cleaned_timestamp, 2000); } } czkawka_core-11.0.1/src/common/cache.rs000064400000000000000000000666051046102023000160540ustar 00000000000000#![allow(clippy::useless_let_if_seq)] mod cleaning; use std::collections::BTreeMap; use std::io::{BufReader, BufWriter}; use std::path::Path; use std::{fs, mem}; use bincode::Options; pub use cleaning::{CacheCleaningStatistics, CacheProgressCleaning, clean_all_cache_files}; use fun_time::fun_time; use humansize::{BINARY, format_size}; use indexmap::IndexMap; use log::{debug, error}; use once_cell::sync::Lazy; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use crate::common::cache::cleaning::{should_clean_cache, update_cleaning_timestamp}; use crate::common::config_cache_path::open_cache_folder; use crate::common::tool_data::CommonData; use crate::common::traits::ResultEntry; use crate::flc; use crate::helpers::messages::Messages; pub(crate) const CACHE_VERSION: u8 = 100; pub(crate) const CACHE_DUPLICATE_VERSION: u8 = 100; pub(crate) const CACHE_IMAGE_VERSION: u8 = 100; pub(crate) const CACHE_VIDEO_VERSION: u8 = 110; pub(crate) const CACHE_BROKEN_FILES_VERSION: u8 = 110; pub(crate) const CACHE_VIDEO_OPTIMIZE_VERSION: u8 = 110; const MEMORY_LIMIT: u64 = 8 * 1024 * 1024 * 1024; const CLEANING_TIMESTAMPS_FILE: &str = "cleaning_timestamps.json"; static CACHE_CLEANING_INTERVAL_SECONDS: Lazy = Lazy::new(|| { option_env!("CZKAWKA_CACHE_CLEANING_INTERVAL_SECONDS") .and_then(|s| s.parse::().ok()) .unwrap_or(7 * 24 * 60 * 60) }); fn get_cache_size(file_name: &Path) -> String { fs::metadata(file_name).map_or_else(|_| "".to_string(), |metadata| format_size(metadata.len(), BINARY)) } #[fun_time(message = "save_cache_to_file_generalized", level = "debug")] pub fn save_cache_to_file_generalized(cache_file_name: &str, hashmap: &BTreeMap, save_also_as_json: bool, minimum_file_size: u64) -> Messages where T: Serialize + ResultEntry + Sized + Send + Sync, { let mut text_messages = Messages::new(); if let Some(((file_handler, cache_file), (file_handler_json, cache_file_json))) = open_cache_folder(cache_file_name, true, save_also_as_json, &mut text_messages.warnings) { let hashmap_to_save = hashmap.values().filter(|t| t.get_size() >= minimum_file_size).collect::>(); { let writer = BufWriter::new(file_handler.expect("Cannot fail, because for saving, this always exists")); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); if let Err(e) = options.serialize_into(writer, &hashmap_to_save) { text_messages .warnings .push(flc!("core_failed_to_write_data_to_cache", file = cache_file.to_string_lossy(), reason = e.to_string())); debug!("Failed to save cache to file \"{}\" - {e}", cache_file.to_string_lossy()); return text_messages; } debug!("Saved cache to binary file \"{}\" with size {}", cache_file.to_string_lossy(), get_cache_size(&cache_file)); } if save_also_as_json && let Some(file_handler_json) = file_handler_json { let writer = BufWriter::new(file_handler_json); if let Err(e) = serde_json::to_writer(writer, &hashmap_to_save) { text_messages .warnings .push(flc!("core_failed_to_write_data_to_cache", file = cache_file_json.to_string_lossy(), reason = e.to_string())); debug!("Failed to save cache to file \"{}\" - {e}", cache_file_json.to_string_lossy()); return text_messages; } debug!( "Saved cache to json file \"{}\" with size {}", cache_file_json.to_string_lossy(), get_cache_size(&cache_file_json) ); } text_messages.messages.push(flc!("core_properly_saved_cache_entries", count = hashmap.len())); debug!("Properly saved to file {} cache entries.", hashmap.len()); } else { debug!("Failed to save cache to file {cache_file_name} because not exists"); } text_messages } pub(crate) fn extract_loaded_cache( loaded_hash_map: &BTreeMap, files_to_check: BTreeMap, records_already_cached: &mut BTreeMap, non_cached_files_to_check: &mut BTreeMap, ) where T: Clone, { for (name, file_entry) in files_to_check { if let Some(cached_file_entry) = loaded_hash_map.get(&name) { records_already_cached.insert(name, cached_file_entry.clone()); } else { non_cached_files_to_check.insert(name, file_entry); } } } #[fun_time(message = "load_cache_from_file_generalized_by_path", level = "debug")] pub fn load_cache_from_file_generalized_by_path(cache_file_name: &str, delete_outdated_cache: bool, used_files: &BTreeMap) -> (Messages, Option>) where for<'a> T: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, { let check_file = |file_entry: &T| { let file_entry_path_str = file_entry.get_path().to_string_lossy(); let key: &str = file_entry_path_str.as_ref(); if let Some(used_file) = used_files.get(key) { if file_entry.get_size() != used_file.get_size() { return false; } if file_entry.get_modified_date() != used_file.get_modified_date() { return false; } } true }; let (text_messages, vec_loaded_cache) = load_cache_from_file_generalized(cache_file_name, delete_outdated_cache, check_file); let Some(vec_loaded_entries) = vec_loaded_cache else { return (text_messages, None); }; debug!("Converting cache Vec into BTreeMap"); let number_of_entries = vec_loaded_entries.len(); let start_time = std::time::Instant::now(); let map_loaded_entries: BTreeMap = vec_loaded_entries .into_iter() .map(|file_entry| (file_entry.get_path().to_string_lossy().into_owned(), file_entry)) .collect(); debug!("Converted cache Vec({number_of_entries} results) into BTreeMap in {:?}", start_time.elapsed()); (text_messages, Some(map_loaded_entries)) } #[fun_time(message = "load_cache_from_file_generalized_by_size", level = "debug")] pub fn load_cache_from_file_generalized_by_size( cache_file_name: &str, delete_outdated_cache: bool, cache_not_converted: &BTreeMap>, ) -> (Messages, Option>>) where for<'a> T: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, { debug!("Converting cache BtreeMap> into IndexMap"); let used_files: IndexMap = cache_not_converted .iter() .flat_map(|(size, vec)| { vec.iter() .map(move |file_entry| (file_entry.get_path().to_string_lossy().into_owned(), (*size, file_entry.get_modified_date()))) }) .collect(); debug!("Converted cache BtreeMap> into IndexMap"); let check_file = |file_entry: &T| { let file_entry_path_str = file_entry.get_path().to_string_lossy(); let key: &str = file_entry_path_str.as_ref(); if let Some((size, modification_date)) = used_files.get(key) { if file_entry.get_size() != *size { return false; } if file_entry.get_modified_date() != *modification_date { return false; } } true }; let (text_messages, vec_loaded_cache) = load_cache_from_file_generalized(cache_file_name, delete_outdated_cache, check_file); let Some(vec_loaded_entries) = vec_loaded_cache else { return (text_messages, None); }; debug!("Converting cache Vec into BTreeMap>"); let number_of_entries = vec_loaded_entries.len(); let start_time = std::time::Instant::now(); let mut map_loaded_entries: BTreeMap> = Default::default(); for file_entry in vec_loaded_entries { map_loaded_entries.entry(file_entry.get_size()).or_default().push(file_entry); } debug!( "Converted cache Vec({number_of_entries} results) into BTreeMap> in {:?}", start_time.elapsed() ); (text_messages, Some(map_loaded_entries)) } #[fun_time(message = "load_cache_from_file_generalized", level = "debug")] fn load_cache_from_file_generalized(cache_file_name: &str, delete_outdated_cache: bool, check_func: F) -> (Messages, Option>) where for<'a> T: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, F: Fn(&T) -> bool + Send + Sync, { let mut text_messages = Messages::new(); if let Some(((file_handler, cache_file), (file_handler_json, cache_file_json))) = open_cache_folder(cache_file_name, false, true, &mut text_messages.warnings) { let cache_full_name; let mut vec_loaded_entries: Vec; if let Some(file_handler) = file_handler { cache_full_name = cache_file.clone(); let reader = BufReader::new(file_handler); let options = bincode::DefaultOptions::new().with_limit(MEMORY_LIMIT); vec_loaded_entries = match options.deserialize_from(reader) { Ok(t) => t, Err(e) => { text_messages .warnings .push(flc!("core_failed_to_load_data_from_cache", file = cache_file.to_string_lossy(), reason = e.to_string())); error!("Failed to load cache from file {} - {e}", cache_file.to_string_lossy()); return (text_messages, None); } }; } else { cache_full_name = cache_file_json.clone(); let reader = BufReader::new(file_handler_json.expect("This cannot fail, because if file_handler is None, then this cannot be None")); vec_loaded_entries = match serde_json::from_reader(reader) { Ok(t) => t, Err(e) => { text_messages.warnings.push(flc!( "core_failed_to_load_data_from_json_cache", file = cache_file_json.to_string_lossy(), reason = e.to_string() )); debug!("Failed to load cache from file {} - {e}", cache_file_json.to_string_lossy()); return (text_messages, None); } }; } let should_clean = should_clean_cache(cache_file_name); debug!( "Starting removing outdated cache entries (removing non existent files from cache - {delete_outdated_cache}, should_clean - {should_clean}, entries number - {})", vec_loaded_entries.len() ); let initial_number_of_entries = vec_loaded_entries.len(); let deleting_start_time = std::time::Instant::now(); let effective_delete_outdated = delete_outdated_cache && should_clean; vec_loaded_entries = vec_loaded_entries .into_par_iter() .filter(|file_entry| { if !check_func(file_entry) { return false; } if effective_delete_outdated && !file_entry.get_path().exists() { return false; } true }) .collect(); if effective_delete_outdated { update_cleaning_timestamp(cache_file_name); } debug!( "Completed removing outdated cache entries, removed {} out of all {} entries in {:?}", initial_number_of_entries - vec_loaded_entries.len(), initial_number_of_entries, deleting_start_time.elapsed() ); text_messages.messages.push(format!("Properly loaded {} cache entries.", vec_loaded_entries.len())); debug!( "Loaded cache from file {cache_file_name} (or json alternative) - {} results - size {}", vec_loaded_entries.len(), get_cache_size(&cache_full_name) ); return (text_messages, Some(vec_loaded_entries)); } debug!("Failed to load cache from file {cache_file_name} because not exists"); (text_messages, None) } pub(crate) fn load_and_split_cache_generalized_by_path( cache_file_name: &str, mut items_to_check: BTreeMap, common_data: &mut C, ) -> (BTreeMap, BTreeMap, BTreeMap) where for<'a> K: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, { if !common_data.get_use_cache() { return (Default::default(), Default::default(), items_to_check); } let loaded_hash_map; let mut records_already_cached: BTreeMap = Default::default(); let mut non_cached_files_to_check: BTreeMap = Default::default(); let (messages, loaded_items) = load_cache_from_file_generalized_by_path::(cache_file_name, common_data.get_delete_outdated_cache(), &items_to_check); common_data.get_text_messages_mut().extend_with_another_messages(messages); loaded_hash_map = loaded_items.unwrap_or_default(); debug!("load_cache - Starting to check for differences"); extract_loaded_cache( &loaded_hash_map, mem::take(&mut items_to_check), &mut records_already_cached, &mut non_cached_files_to_check, ); debug!( "load_cache - completed diff between loaded and prechecked files, {}({}) - non cached, {}({}) - already cached", non_cached_files_to_check.len(), format_size(non_cached_files_to_check.values().map(|e| e.get_size()).sum::(), BINARY), records_already_cached.len(), format_size(records_already_cached.values().map(|e| e.get_size()).sum::(), BINARY), ); (loaded_hash_map, records_already_cached, non_cached_files_to_check) } pub(crate) fn save_and_connect_cache_generalized_by_path(cache_file_name: &str, vec_file_entry: &[K], loaded_hash_map: BTreeMap, common_data: &mut C) where K: Serialize + ResultEntry + Sized + Send + Sync + Clone, { if !common_data.get_use_cache() { return; } let mut all_results: BTreeMap = Default::default(); for file_entry in vec_file_entry.iter().cloned() { all_results.insert(file_entry.get_path().to_string_lossy().to_string(), file_entry); } for (name, file_entry) in loaded_hash_map { all_results.insert(name, file_entry); } let messages = save_cache_to_file_generalized(cache_file_name, &all_results, common_data.get_save_also_as_json(), 0); common_data.get_text_messages_mut().extend_with_another_messages(messages); } #[cfg(test)] mod tests { use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use std::sync::Once; use tempfile::TempDir; use super::*; use crate::common::config_cache_path::set_config_cache_path_test; static INIT: Once = Once::new(); pub(crate) fn setup_cache_path() { INIT.call_once(|| { let temp_cache_dir = TempDir::new().expect("Failed to create temp cache dir"); let temp_config_dir = TempDir::new().expect("Failed to create temp config dir"); let cache_path = temp_cache_dir.path().to_path_buf(); let config_path = temp_config_dir.path().to_path_buf(); set_config_cache_path_test(cache_path, config_path); // Leak the TempDir to keep directories alive for the duration of tests std::mem::forget(temp_cache_dir); std::mem::forget(temp_config_dir); }); } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct TestEntry { path: PathBuf, size: u64, modified_date: u64, value: u32, } impl ResultEntry for TestEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl TestEntry { fn new(path: &str, size: u64, modified_date: u64, value: u32) -> Self { Self { path: PathBuf::from(path), size, modified_date, value, } } } #[test] fn test_extract_loaded_cache() { let mut loaded_cache = BTreeMap::new(); loaded_cache.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); loaded_cache.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); let mut files_to_check = BTreeMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file3".to_string(), TestEntry::new("/tmp/file3", 300, 3000, 30)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); let mut records_already_cached = BTreeMap::new(); let mut non_cached_files_to_check = BTreeMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); assert_eq!(records_already_cached.len(), 2); assert_eq!(non_cached_files_to_check.len(), 1); assert!(records_already_cached.contains_key("file1")); assert!(records_already_cached.contains_key("file2")); assert!(non_cached_files_to_check.contains_key("file3")); assert_eq!(records_already_cached.get("file1").unwrap().value, 10); assert_eq!(non_cached_files_to_check.get("file3").unwrap().value, 30); } #[test] fn test_extract_loaded_cache_empty() { let loaded_cache: BTreeMap = BTreeMap::new(); let mut files_to_check = BTreeMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); let mut records_already_cached = BTreeMap::new(); let mut non_cached_files_to_check = BTreeMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); assert_eq!(records_already_cached.len(), 0, "No entries should be cached"); assert_eq!(non_cached_files_to_check.len(), 2, "All entries should be non-cached"); } #[test] fn test_extract_loaded_cache_all_cached() { let mut loaded_cache = BTreeMap::new(); loaded_cache.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); loaded_cache.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); let mut files_to_check = BTreeMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); let mut records_already_cached = BTreeMap::new(); let mut non_cached_files_to_check = BTreeMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); assert_eq!(records_already_cached.len(), 2, "All entries should be cached"); assert_eq!(non_cached_files_to_check.len(), 0, "No entries should be non-cached"); } #[test] fn test_save_and_load_cache_by_path() { setup_cache_path(); let temp_dir = TempDir::new().unwrap(); let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test content").unwrap(); let metadata = fs::metadata(&temp_file).unwrap(); let mut cache_to_save = BTreeMap::new(); cache_to_save.insert( temp_file.to_string_lossy().to_string(), TestEntry::new(temp_file.to_str().unwrap(), metadata.len(), metadata.modified().unwrap().elapsed().unwrap().as_secs(), 42), ); // Save cache let cache_name = format!("test_cache_by_path_{}", std::process::id()); let messages = save_cache_to_file_generalized(&cache_name, &cache_to_save, false, 0); assert!(messages.warnings.is_empty(), "Should not have warnings when saving"); assert!(!messages.messages.is_empty(), "Should have success messages when saving"); // Load cache let (load_messages, loaded_cache) = load_cache_from_file_generalized_by_path::(&cache_name, false, &cache_to_save); assert!(load_messages.warnings.is_empty(), "Should not have warnings when loading"); assert!(!load_messages.messages.is_empty(), "Should have success messages when loading"); assert!(loaded_cache.is_some(), "Should load cache successfully"); let loaded = loaded_cache.unwrap(); assert_eq!(loaded.len(), 1, "Should load 1 entry"); assert!(loaded.contains_key(temp_file.to_str().unwrap()), "Should contain the test file"); } #[test] fn test_save_and_load_cache_by_size() { setup_cache_path(); let temp_dir = TempDir::new().unwrap(); let temp_file1 = temp_dir.path().join("test_file1.txt"); let temp_file2 = temp_dir.path().join("test_file2.txt"); fs::write(&temp_file1, "test content 1").unwrap(); fs::write(&temp_file2, "test content 2").unwrap(); let metadata1 = fs::metadata(&temp_file1).unwrap(); let metadata2 = fs::metadata(&temp_file2).unwrap(); let mut cache_to_save: BTreeMap> = BTreeMap::new(); cache_to_save.entry(metadata1.len()).or_default().push(TestEntry::new( temp_file1.to_str().unwrap(), metadata1.len(), metadata1.modified().unwrap().elapsed().unwrap().as_secs(), 10, )); cache_to_save.entry(metadata2.len()).or_default().push(TestEntry::new( temp_file2.to_str().unwrap(), metadata2.len(), metadata2.modified().unwrap().elapsed().unwrap().as_secs(), 20, )); // Convert to flat map for saving let mut flat_cache = BTreeMap::new(); for entries in cache_to_save.values() { for entry in entries { flat_cache.insert(entry.path.to_string_lossy().to_string(), entry.clone()); } } // Save cache let cache_name = format!("test_cache_by_size_{}", std::process::id()); let messages = save_cache_to_file_generalized(&cache_name, &flat_cache, false, 0); assert!(messages.warnings.is_empty(), "Should not have warnings when saving"); // Load cache let (load_messages, loaded_cache) = load_cache_from_file_generalized_by_size::(&cache_name, false, &cache_to_save); assert!(load_messages.warnings.is_empty(), "Should not have warnings when loading"); assert!(loaded_cache.is_some(), "Should load cache successfully"); let loaded = loaded_cache.unwrap(); assert!(!loaded.is_empty(), "Should load entries"); } #[test] fn test_save_cache_with_minimum_file_size() { setup_cache_path(); let temp_dir = TempDir::new().unwrap(); let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test").unwrap(); let mut cache_to_save = BTreeMap::new(); cache_to_save.insert("small_file".to_string(), TestEntry::new("/tmp/small", 10, 1000, 1)); cache_to_save.insert("large_file".to_string(), TestEntry::new("/tmp/large", 1000, 2000, 2)); // Save cache with minimum file size of 100 bytes let cache_name = format!("test_cache_min_size_{}", std::process::id()); let messages = save_cache_to_file_generalized(&cache_name, &cache_to_save, false, 100); assert!(messages.warnings.is_empty(), "Should not have warnings"); // Load cache - should only contain large file let files_to_check = cache_to_save.clone(); let (_, loaded_cache) = load_cache_from_file_generalized_by_path::(&cache_name, false, &files_to_check); if let Some(loaded) = loaded_cache { // Only the large file should be saved (size >= 100) for (_, entry) in loaded { assert!(entry.size >= 100, "All loaded entries should be >= minimum size"); } } } #[test] fn test_load_cache_with_outdated_entries() { setup_cache_path(); let temp_dir = TempDir::new().unwrap(); let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test content").unwrap(); let metadata = fs::metadata(&temp_file).unwrap(); let mut cache_to_save = BTreeMap::new(); cache_to_save.insert( temp_file.to_string_lossy().to_string(), TestEntry::new(temp_file.to_str().unwrap(), metadata.len(), metadata.modified().unwrap().elapsed().unwrap().as_secs(), 42), ); // Save cache let cache_name = format!("test_cache_outdated_{}", std::process::id()); save_cache_to_file_generalized(&cache_name, &cache_to_save, false, 0); // Modify the file std::thread::sleep(std::time::Duration::from_millis(100)); fs::write(&temp_file, "modified content").unwrap(); // Create new files_to_check with updated metadata let new_metadata = fs::metadata(&temp_file).unwrap(); let mut files_to_check = BTreeMap::new(); files_to_check.insert( temp_file.to_string_lossy().to_string(), TestEntry::new( temp_file.to_str().unwrap(), new_metadata.len(), new_metadata.modified().unwrap().elapsed().unwrap().as_secs(), 42, ), ); // Load cache - should filter out the outdated entry let (_, loaded_cache) = load_cache_from_file_generalized_by_path::(&cache_name, false, &files_to_check); if let Some(loaded) = loaded_cache { // Should be empty because size/modified date changed assert!(loaded.is_empty() || loaded.len() < cache_to_save.len(), "Outdated entries should be filtered"); } } #[test] fn test_load_nonexistent_cache() { setup_cache_path(); let cache_name = format!("nonexistent_cache_{}", std::process::id()); let files_to_check: BTreeMap = BTreeMap::new(); let (messages, loaded_cache) = load_cache_from_file_generalized_by_path::(&cache_name, false, &files_to_check); assert!(loaded_cache.is_none(), "Should return None for nonexistent cache"); assert!(messages.warnings.is_empty(), "Should not have warnings for nonexistent cache"); } #[test] fn test_save_cache_with_json() { setup_cache_path(); let temp_dir = TempDir::new().unwrap(); let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test content").unwrap(); let mut cache_to_save = BTreeMap::new(); cache_to_save.insert("test_key".to_string(), TestEntry::new("/tmp/test", 100, 1000, 42)); // Save cache with JSON enabled let cache_name = format!("test_cache_json_{}", std::process::id()); let messages = save_cache_to_file_generalized(&cache_name, &cache_to_save, true, 0); assert!(messages.warnings.is_empty(), "Should not have warnings when saving with JSON"); } #[test] fn test_get_cache_size_nonexistent() { let nonexistent_path = Path::new("/nonexistent/path/to/cache.bin"); let size_str = get_cache_size(nonexistent_path); assert_eq!(size_str, "", "Should return unknown size for nonexistent file"); } } czkawka_core-11.0.1/src/common/config_cache_path.rs000064400000000000000000000163601046102023000204060ustar 00000000000000use std::fs::{File, OpenOptions}; use std::path::PathBuf; use std::{env, fs}; use directories_next::ProjectDirs; use log::{info, warn}; use once_cell::sync::OnceCell; use crate::flc; static CONFIG_CACHE_PATH: OnceCell> = OnceCell::new(); #[derive(Debug, Clone)] pub struct ConfigCachePath { pub config_folder: PathBuf, pub cache_folder: PathBuf, } pub fn get_config_cache_path() -> Option { CONFIG_CACHE_PATH.get().expect("Cannot fail if set_config_cache_path was called before").clone() } fn resolve_folder(env_var: &str, default_folder: Option, name: &'static str, warnings: &mut Vec) -> Option { let default_folder_str = default_folder.as_ref().map_or("".to_string(), |t| t.to_string_lossy().to_string()); if env_var.is_empty() { default_folder } else { let folder_path = PathBuf::from(env_var); let _ = fs::create_dir_all(&folder_path); if !folder_path.exists() { warnings.push(format!( "{name} folder \"{}\" does not exist, using default folder \"{}\"", folder_path.to_string_lossy(), default_folder_str )); return default_folder; } if !folder_path.is_dir() { warnings.push(format!( "{name} folder \"{}\" is not a directory, using default folder \"{}\"", folder_path.to_string_lossy(), default_folder_str )); return default_folder; } match dunce::canonicalize(folder_path) { Ok(t) => Some(t), Err(_e) => { warnings.push(format!( "Cannot canonicalize {} folder \"{}\", using default folder \"{}\"", name.to_ascii_lowercase(), env_var, default_folder_str )); default_folder } } } } #[cfg(test)] pub fn set_config_cache_path_test(cache_path: PathBuf, config_path: PathBuf) { CONFIG_CACHE_PATH .set(Some(ConfigCachePath { cache_folder: cache_path, config_folder: config_path, })) .expect("Cannot set config cache path"); } pub struct ConfigCachePathSetResult { pub infos: Vec, pub warnings: Vec, pub config_env_set: bool, pub cache_env_set: bool, pub default_cache_path_exists: bool, pub default_config_path_exists: bool, } // This function must be executed, to not crash, when gathering config/cache path pub fn set_config_cache_path(cache_name: &'static str, config_name: &'static str) -> ConfigCachePathSetResult { // By default, such folders are used: // Lin: /home/username/.config/czkawka // LinFlatpak: /home/username/.var/app/com.github.qarmin.czkawka/config/czkawka // Win: C:\Users\Username\AppData\Roaming\Qarmin\Czkawka\config // Mac: /Users/Username/Library/Application Support/pl.Qarmin.Czkawka let mut infos = Vec::new(); let mut warnings = Vec::new(); let config_folder_env = env::var("CZKAWKA_CONFIG_PATH").unwrap_or_default().trim().to_string(); let cache_folder_env = env::var("CZKAWKA_CACHE_PATH").unwrap_or_default().trim().to_string(); let default_cache_folder = ProjectDirs::from("pl", "Qarmin", cache_name).map(|proj_dirs| proj_dirs.cache_dir().to_path_buf()); let default_config_folder = ProjectDirs::from("pl", "Qarmin", config_name).map(|proj_dirs| proj_dirs.config_dir().to_path_buf()); let default_config_path_exists = default_config_folder.as_ref().is_some_and(|t| t.exists()); let default_cache_path_exists = default_cache_folder.as_ref().is_some_and(|t| t.exists()); let config_folder = resolve_folder(&config_folder_env, default_config_folder, "Config", &mut warnings); let cache_folder = resolve_folder(&cache_folder_env, default_cache_folder, "Cache", &mut warnings); let config_cache_path = if let (Some(config_folder), Some(cache_folder)) = (config_folder, cache_folder) { infos.push(format!( "Config folder set to \"{}\" and cache folder set to \"{}\"", config_folder.to_string_lossy(), cache_folder.to_string_lossy() )); if !config_folder.exists() && let Err(e) = fs::create_dir_all(&config_folder) { warnings.push(flc!("core_cannot_create_config_folder", folder = config_folder.to_string_lossy(), reason = e.to_string())); } if !cache_folder.exists() && let Err(e) = fs::create_dir_all(&cache_folder) { warnings.push(flc!("core_cannot_create_cache_folder", folder = cache_folder.to_string_lossy(), reason = e.to_string())); } Some(ConfigCachePath { config_folder, cache_folder }) } else { warnings.push(flc!("core_cannot_set_config_cache_path")); None }; CONFIG_CACHE_PATH.set(config_cache_path).expect("Cannot set config/cache path twice"); ConfigCachePathSetResult { infos, warnings, config_env_set: !config_folder_env.is_empty(), cache_env_set: !cache_folder_env.is_empty(), default_cache_path_exists, default_config_path_exists, } } pub(crate) fn open_cache_folder( cache_file_name: &str, save_to_cache: bool, use_json: bool, warnings: &mut Vec, ) -> Option<((Option, PathBuf), (Option, PathBuf))> { let cache_dir = get_config_cache_path()?.cache_folder; let cache_file = cache_dir.join(cache_file_name); let cache_file_json = cache_dir.join(cache_file_name.replace(".bin", ".json")); let mut file_handler_default = None; let mut file_handler_json = None; if save_to_cache { file_handler_default = Some(match OpenOptions::new().truncate(true).write(true).create(true).open(&cache_file) { Ok(t) => t, Err(e) => { warnings.push(flc!("core_cannot_create_or_open_cache_file", file = cache_file.to_string_lossy(), reason = e.to_string())); return None; } }); if use_json { file_handler_json = Some(match OpenOptions::new().truncate(true).write(true).create(true).open(&cache_file_json) { Ok(t) => t, Err(e) => { warnings.push(flc!( "core_cannot_create_or_open_cache_file", file = cache_file_json.to_string_lossy(), reason = e.to_string() )); return None; } }); } } else if let Ok(t) = OpenOptions::new().read(true).open(&cache_file) { file_handler_default = Some(t); } else if use_json { file_handler_json = Some(OpenOptions::new().read(true).open(&cache_file_json).ok()?); } else { return None; } Some(((file_handler_default, cache_file), (file_handler_json, cache_file_json))) } // When initializing logger or settings config/cache folders, logger is not yet initialized, // so we need to delay them until logger is initialized pub fn print_infos_and_warnings(infos: Vec, warnings: Vec) { for info in infos { info!("{info}"); } for warning in warnings { warn!("{warning}"); } } czkawka_core-11.0.1/src/common/consts.rs000064400000000000000000000060771046102023000163170ustar 00000000000000pub const DEFAULT_THREAD_SIZE: usize = 8 * 1024 * 1024; // 8 MB pub const DEFAULT_WORKER_THREAD_SIZE: usize = 4 * 1024 * 1024; // 4 MB pub const VIDEO_RESOLUTION_LIMIT: u32 = 16 * 1024; // Not processing is a problem, but overflows, when width * height overflows u64 in gui, so with such limit can i32 can be used safely pub const RAW_IMAGE_EXTENSIONS: &[&str] = &[ "ari", "cr3", "cr2", "crw", "erf", "raf", "3fr", "kdc", "dcs", "dcr", "iiq", "mos", "mef", "mrw", "nef", "nrw", "orf", "rw2", "pef", "srw", "arw", "srf", "sr2", ]; #[cfg(feature = "libavif")] pub const IMAGE_RS_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "bmp", "tiff", "tif", "tga", "ff", "jif", "jfi", "webp", "gif", "ico", "exr", "qoi", "jxl", "avif", ]; #[cfg(not(feature = "libavif"))] pub const IMAGE_RS_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "bmp", "tiff", "tif", "tga", "ff", "jif", "jfi", "webp", "gif", "ico", "exr", "qoi", "jxl", ]; #[cfg(feature = "libavif")] pub const IMAGE_RS_SIMILAR_IMAGES_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "tiff", "tif", "tga", "ff", "jif", "jfi", "bmp", "webp", "exr", "qoi", "jxl", "avif"]; #[cfg(not(feature = "libavif"))] pub const IMAGE_RS_SIMILAR_IMAGES_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "tiff", "tif", "tga", "ff", "jif", "jfi", "bmp", "webp", "exr", "qoi", "jxl"]; #[cfg(feature = "libavif")] pub const IMAGE_RS_BROKEN_FILES_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tiff", "tif", "tga", "ff", "jif", "jfi", "gif", "bmp", "ico", "jfif", "jpe", "pnz", "dib", "webp", "exr", "avif", "jxl", ]; #[cfg(not(feature = "libavif"))] pub const IMAGE_RS_BROKEN_FILES_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tiff", "tif", "tga", "ff", "jif", "jfi", "gif", "bmp", "ico", "jfif", "jpe", "pnz", "dib", "webp", "exr", "jxl", ]; pub const HEIC_EXTENSIONS: &[&str] = &["heif", "heifs", "heic", "heics", "avci", "avcs", "hif"]; pub const ZIP_FILES_EXTENSIONS: &[&str] = &["zip", "jar"]; pub const PDF_FILES_EXTENSIONS: &[&str] = &["pdf"]; pub const AUDIO_FILES_EXTENSIONS: &[&str] = &[ "mp3", "flac", "wav", "ogg", "m4a", "aac", "aiff", "pcm", "aif", "aiff", "aifc", "m3a", "mp2", "mp4a", "mp2a", "mpga", "wave", "weba", "wma", "oga", ]; pub const VIDEO_FILES_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mkv", "avi", "mov", "webm", "flv", "wmv", // Popular "mpeg", "mpg", "mp2", "mpe", "m2ts", "vob", "evo", // MPEG / broadcast, "ts" "3gp", "3g2", "f4v", "f4p", "f4a", "f4b", // Mobile / legacy "qt", "m4p", "mpv", // Apple / ISO BMFF "ogv", "rm", "rmvb", "asf", // Streaming / recording "dv", "mxf", "roq", "nsv", "yuv", // Professional "y4m", "h264", "h265", "hevc", "av1", "vp8", "vp9", // Raw / uncompressed "amv", "drc", "gifv", "smk", "bik", // Older / games ]; pub const TEXT_FILES_EXTENSIONS: &[&str] = &["txt", "md", "csv", "log", "ini", "json", "xml", "yaml", "yml", "toml", "doc", "docx", "rtf", "odt"]; // "dng" - is theoretically a tiff file, but little_exif have problem with saving metadata to it pub const EXIF_FILES_EXTENSIONS: &[&str] = &["jpg", "jpeg", "jfif", "png", "tiff", "tif", "avif", "jxl", "webp", "heic", "heif"]; czkawka_core-11.0.1/src/common/dir_traversal.rs000064400000000000000000001074501046102023000176440ustar 00000000000000use std::collections::BTreeMap; use std::fs; use std::fs::{DirEntry, FileType, Metadata}; #[cfg(target_family = "unix")] use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::UNIX_EPOCH; use crossbeam_channel::Sender; use fun_time::fun_time; use log::debug; use rayon::prelude::*; use crate::common::directories::Directories; use crate::common::extensions::Extensions; use crate::common::items::ExcludedItems; use crate::common::model::{CheckingMethod, FileEntry, ToolType}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::CommonToolData; use crate::flc; #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum Collect { InvalidSymlinks, Files, } #[derive(Eq, PartialEq, Copy, Clone, Debug)] enum EntryType { File, Dir, Symlink, Other, } pub struct DirTraversalBuilder<'b, F> { group_by: Option, root_dirs: Vec, root_files: Vec, stop_flag: Option>, progress_sender: Option<&'b Sender>, minimal_file_size: Option, maximal_file_size: Option, checking_method: CheckingMethod, collect: Collect, recursive_search: bool, directories: Option, excluded_items: Option, extensions: Option, tool_type: ToolType, } #[derive(Debug)] pub struct DirTraversal<'b, F> { group_by: F, root_dirs: Vec, root_files: Vec, stop_flag: Arc, progress_sender: Option<&'b Sender>, recursive_search: bool, directories: Directories, excluded_items: ExcludedItems, extensions: Extensions, minimal_file_size: u64, maximal_file_size: u64, checking_method: CheckingMethod, tool_type: ToolType, collect: Collect, } impl Default for DirTraversalBuilder<'_, ()> { fn default() -> Self { Self::new() } } impl DirTraversalBuilder<'_, ()> { pub fn new() -> Self { DirTraversalBuilder { group_by: None, root_dirs: Vec::new(), root_files: Vec::new(), stop_flag: None, progress_sender: None, checking_method: CheckingMethod::None, minimal_file_size: None, maximal_file_size: None, collect: Collect::Files, recursive_search: false, directories: None, extensions: None, excluded_items: None, tool_type: ToolType::None, } } } impl<'b, F> DirTraversalBuilder<'b, F> { pub(crate) fn common_data(mut self, common_tool_data: &CommonToolData) -> Self { self.root_dirs = common_tool_data.directories.included_directories.clone(); self.root_files = common_tool_data.directories.included_files.clone(); self.extensions = Some(common_tool_data.extensions.clone()); self.excluded_items = Some(common_tool_data.excluded_items.clone()); self.recursive_search = common_tool_data.recursive_search; self.minimal_file_size = Some(common_tool_data.minimal_file_size); self.maximal_file_size = Some(common_tool_data.maximal_file_size); self.tool_type = common_tool_data.tool_type; self.directories = Some(common_tool_data.directories.clone()); self } pub(crate) fn stop_flag(mut self, stop_flag: &Arc) -> Self { self.stop_flag = Some(stop_flag.clone()); self } pub(crate) fn progress_sender(mut self, progress_sender: Option<&'b Sender>) -> Self { self.progress_sender = progress_sender; self } pub(crate) fn checking_method(mut self, checking_method: CheckingMethod) -> Self { self.checking_method = checking_method; self } pub(crate) fn minimal_file_size(mut self, minimal_file_size: u64) -> Self { self.minimal_file_size = Some(minimal_file_size); self } pub(crate) fn maximal_file_size(mut self, maximal_file_size: u64) -> Self { self.maximal_file_size = Some(maximal_file_size); self } pub(crate) fn collect(mut self, collect: Collect) -> Self { self.collect = collect; self } pub(crate) fn group_by(self, group_by: G) -> DirTraversalBuilder<'b, G> where G: Fn(&FileEntry) -> T, { DirTraversalBuilder { group_by: Some(group_by), root_dirs: self.root_dirs, root_files: self.root_files, stop_flag: self.stop_flag, progress_sender: self.progress_sender, directories: self.directories, extensions: self.extensions, excluded_items: self.excluded_items, recursive_search: self.recursive_search, maximal_file_size: self.maximal_file_size, minimal_file_size: self.minimal_file_size, collect: self.collect, checking_method: self.checking_method, tool_type: self.tool_type, } } pub(crate) fn build(self) -> DirTraversal<'b, F> { DirTraversal { group_by: self.group_by.expect("could not build"), root_dirs: self.root_dirs, root_files: self.root_files, stop_flag: self.stop_flag.expect("Stop flag must be always initialized"), progress_sender: self.progress_sender, checking_method: self.checking_method, minimal_file_size: self.minimal_file_size.unwrap_or(0), maximal_file_size: self.maximal_file_size.unwrap_or(u64::MAX), collect: self.collect, directories: self.directories.expect("could not build"), excluded_items: self.excluded_items.expect("could not build"), extensions: self.extensions.unwrap_or_default(), recursive_search: self.recursive_search, tool_type: self.tool_type, } } } pub enum DirTraversalResult { SuccessFiles { warnings: Vec, grouped_file_entries: BTreeMap>, }, Stopped, } fn entry_type(file_type: FileType) -> EntryType { if file_type.is_dir() { EntryType::Dir } else if file_type.is_symlink() { EntryType::Symlink } else if file_type.is_file() { EntryType::File } else { EntryType::Other } } impl DirTraversal<'_, F> where F: Fn(&FileEntry) -> T, T: Ord + PartialOrd, { #[fun_time(message = "run(collecting files/dirs)", level = "debug")] pub(crate) fn run(self) -> DirTraversalResult { assert_ne!(self.tool_type, ToolType::None, "Tool type cannot be None"); let mut all_warnings = Vec::new(); let mut grouped_file_entries: BTreeMap> = BTreeMap::new(); // Add root folders and files for finding let mut folders_to_check: Vec = self.root_dirs.clone(); let mut files_to_check: Vec = self.root_files.clone(); let progress_handler = prepare_thread_handler_common(self.progress_sender, CurrentStage::CollectingFiles, 0, (self.tool_type, self.checking_method), 0); let DirTraversal { collect, directories, excluded_items, extensions, recursive_search, minimal_file_size, maximal_file_size, stop_flag, .. } = self; let mut file_results = Vec::new(); // File traversal while let Some(current_file) = files_to_check.pop() { let Some(metadata) = common_get_metadata_from_path(¤t_file, &mut all_warnings) else { continue; }; let file_type = metadata.file_type(); match (entry_type(file_type), collect) { (EntryType::File, Collect::Files) => { progress_handler.increase_items(1); process_file_in_file_mode_path_check( ¤t_file, &metadata, &mut all_warnings, &mut file_results, &extensions, &excluded_items, &directories, minimal_file_size, maximal_file_size, ); } (EntryType::File, Collect::InvalidSymlinks) => { progress_handler.increase_items(1); } (EntryType::Symlink, Collect::InvalidSymlinks) => { progress_handler.increase_items(1); process_symlink_in_symlink_mode_path_check(¤t_file, &metadata, &mut all_warnings, &mut file_results, &extensions, &excluded_items); } (EntryType::Symlink | EntryType::Dir | EntryType::Other, _) => { // nothing to do } } } file_results.sort_by_cached_key(|fe| fe.path.to_string_lossy().to_string()); for fe in file_results { let key = (self.group_by)(&fe); grouped_file_entries.entry(key).or_default().push(fe); } // Folder traversal while !folders_to_check.is_empty() { if check_if_stop_received(&stop_flag) { progress_handler.join_thread(); return DirTraversalResult::Stopped; } let segments: Vec<_> = folders_to_check .into_par_iter() .with_max_len(2) // Avoiding checking too many folders in batch .map(|current_folder| { let mut dir_result = Vec::new(); let mut warnings = Vec::new(); let mut fe_result = Vec::new(); let Some(read_dir) = common_read_dir(¤t_folder, &mut warnings) else { return Some((dir_result, warnings, fe_result)); }; let mut counter = 0; // Check every sub folder/file/link etc. for entry in read_dir { if check_if_stop_received(&stop_flag) { return None; } let Some(entry_data) = common_get_entry_data(&entry, &mut warnings, ¤t_folder) else { continue; }; let Ok(file_type) = entry_data.file_type() else { continue }; match (entry_type(file_type), collect) { (EntryType::Dir, Collect::Files | Collect::InvalidSymlinks) => { process_dir_in_file_symlink_mode(recursive_search, entry_data, &directories, &mut dir_result, &mut warnings, &excluded_items); } (EntryType::File, Collect::Files) => { counter += 1; process_file_in_file_mode( entry_data, &mut warnings, &mut fe_result, &extensions, &directories, &excluded_items, minimal_file_size, maximal_file_size, ); } (EntryType::File, Collect::InvalidSymlinks) => { counter += 1; } (EntryType::Symlink, Collect::InvalidSymlinks) => { counter += 1; process_symlink_in_symlink_mode(entry_data, &mut warnings, &mut fe_result, &extensions, &directories, &excluded_items); } (EntryType::Symlink, Collect::Files) | (EntryType::Other, _) => { // nothing to do } } } if counter > 0 { // Increase counter in batch, because usually it may be slow to add multiple times atomic value progress_handler.increase_items(counter); } Some((dir_result, warnings, fe_result)) }) .while_some() .collect(); let required_size = segments.iter().map(|(segment, _, _)| segment.len()).sum::(); folders_to_check = Vec::with_capacity(required_size); // Process collected data for (segment, warnings, mut fe_result) in segments { folders_to_check.extend(segment); all_warnings.extend(warnings); fe_result.sort_by_cached_key(|fe| fe.path.to_string_lossy().to_string()); for fe in fe_result { let key = (self.group_by)(&fe); grouped_file_entries.entry(key).or_default().push(fe); } } } progress_handler.join_thread(); debug!("Collected {} files", grouped_file_entries.values().map(Vec::len).sum::()); match collect { Collect::Files | Collect::InvalidSymlinks => DirTraversalResult::SuccessFiles { grouped_file_entries, warnings: all_warnings, }, } } } fn process_file_in_file_mode( entry_data: &DirEntry, warnings: &mut Vec, fe_result: &mut Vec, extensions: &Extensions, directories: &Directories, excluded_items: &ExcludedItems, minimal_file_size: u64, maximal_file_size: u64, ) { if !extensions.check_if_entry_have_valid_extension(&entry_data.file_name()) { return; } let current_file_name = entry_data.path(); if excluded_items.is_excluded(¤t_file_name) { return; } if directories.is_excluded_file(¤t_file_name) { return; } #[cfg(target_family = "unix")] if directories.exclude_other_filesystems() { match directories.is_on_other_filesystems(¤t_file_name) { Ok(true) => return, Err(e) => warnings.push(e), _ => (), } } #[cfg(windows)] let _ = directories; // Silence unused variable warning on Windows let Some(metadata) = common_get_metadata_dir(entry_data, warnings, ¤t_file_name) else { return; }; if (minimal_file_size..=maximal_file_size).contains(&metadata.len()) { // Creating new file entry let fe: FileEntry = FileEntry { size: metadata.len(), modified_date: get_modified_time(&metadata, warnings, ¤t_file_name, false), path: current_file_name, }; fe_result.push(fe); } } // Same as above, but working with Path instead of DirEntry // Sadly this cannot be merged, due to a little crazy optimizations done in this functions fn process_file_in_file_mode_path_check( path: &Path, metadata: &Metadata, warnings: &mut Vec, fe_result: &mut Vec, extensions: &Extensions, excluded_items: &ExcludedItems, directories: &Directories, minimal_file_size: u64, maximal_file_size: u64, ) { let Some(file_name) = path.file_name() else { return; }; if !extensions.check_if_entry_have_valid_extension(file_name) { return; } if directories.is_excluded_file(path) { return; } if directories.is_excluded_item_in_dir(path) { return; } if excluded_items.is_excluded(path) { return; } if (minimal_file_size..=maximal_file_size).contains(&metadata.len()) { // Creating new file entry let fe: FileEntry = FileEntry { size: metadata.len(), modified_date: get_modified_time(metadata, warnings, path, false), path: path.to_path_buf(), }; fe_result.push(fe); } } fn process_dir_in_file_symlink_mode( recursive_search: bool, entry_data: &DirEntry, directories: &Directories, dir_result: &mut Vec, warnings: &mut Vec, excluded_items: &ExcludedItems, ) { if !recursive_search { return; } let dir_path = entry_data.path(); if directories.is_excluded_dir(&dir_path) { return; } if excluded_items.is_excluded(&dir_path) { return; } #[cfg(target_family = "unix")] if directories.exclude_other_filesystems() { match directories.is_on_other_filesystems(&dir_path) { Ok(true) => return, Err(e) => warnings.push(e), _ => (), } } #[cfg(windows)] let _ = warnings; // Silence unused variable warning on Windows dir_result.push(dir_path); } fn process_symlink_in_symlink_mode( entry_data: &DirEntry, warnings: &mut Vec, fe_result: &mut Vec, extensions: &Extensions, directories: &Directories, excluded_items: &ExcludedItems, ) { if !extensions.check_if_entry_have_valid_extension(&entry_data.file_name()) { return; } let current_file_name = entry_data.path(); if excluded_items.is_excluded(¤t_file_name) { return; } #[cfg(target_family = "unix")] if directories.exclude_other_filesystems() { match directories.is_on_other_filesystems(¤t_file_name) { Ok(true) => return, Err(e) => warnings.push(e), _ => (), } } #[cfg(windows)] let _ = directories; // Silence unused variable warning on Windows let Some(metadata) = common_get_metadata_dir(entry_data, warnings, ¤t_file_name) else { return; }; // Creating new file entry let fe: FileEntry = FileEntry { size: metadata.len(), modified_date: get_modified_time(&metadata, warnings, ¤t_file_name, false), path: current_file_name, }; fe_result.push(fe); } fn process_symlink_in_symlink_mode_path_check( path: &Path, metadata: &Metadata, warnings: &mut Vec, fe_result: &mut Vec, extensions: &Extensions, excluded_items: &ExcludedItems, ) { let Some(file_name) = path.file_name() else { return; }; if !extensions.check_if_entry_have_valid_extension(file_name) { return; } if excluded_items.is_excluded(path) { return; } // Creating new file entry let fe: FileEntry = FileEntry { size: metadata.len(), modified_date: get_modified_time(metadata, warnings, path, false), path: path.to_path_buf(), }; fe_result.push(fe); } pub(crate) fn common_read_dir(current_folder: &Path, warnings: &mut Vec) -> Option>> { match fs::read_dir(current_folder) { Ok(t) => Some(t.collect()), Err(e) => { warnings.push(flc!("core_cannot_open_dir", dir = current_folder.to_string_lossy().to_string(), reason = e.to_string())); None } } } pub(crate) fn common_get_entry_data<'a>(entry: &'a Result, warnings: &mut Vec, current_folder: &Path) -> Option<&'a DirEntry> { let entry_data = match entry { Ok(t) => t, Err(e) => { warnings.push(flc!( "core_cannot_read_entry_dir", dir = current_folder.to_string_lossy().to_string(), reason = e.to_string() )); return None; } }; Some(entry_data) } pub(crate) fn common_get_metadata_dir(entry_data: &DirEntry, warnings: &mut Vec, current_folder: &Path) -> Option { let metadata: Metadata = match entry_data.metadata() { Ok(t) => t, Err(e) => { warnings.push(flc!( "core_cannot_read_metadata_dir", dir = current_folder.to_string_lossy().to_string(), reason = e.to_string() )); return None; } }; Some(metadata) } pub(crate) fn common_get_metadata_from_path(path: &Path, warnings: &mut Vec) -> Option { let metadata: Metadata = match fs::metadata(path) { Ok(t) => t, Err(e) => { warnings.push(flc!("core_cannot_read_metadata_file", file = path.to_string_lossy().to_string(), reason = e.to_string())); return None; } }; Some(metadata) } pub(crate) fn get_modified_time(metadata: &Metadata, warnings: &mut Vec, current_file_name: &Path, is_folder: bool) -> u64 { match metadata.modified() { Ok(t) => match t.duration_since(UNIX_EPOCH) { Ok(d) => d.as_secs(), Err(_inspected) => { if is_folder { warnings.push(flc!("core_folder_modified_before_epoch", name = current_file_name.to_string_lossy().to_string())); } else { warnings.push(flc!("core_file_modified_before_epoch", name = current_file_name.to_string_lossy().to_string())); } 0 } }, Err(e) => { if is_folder { warnings.push(flc!( "core_folder_no_modification_date", name = current_file_name.to_string_lossy().to_string(), reason = e.to_string() )); } else { warnings.push(flc!( "core_file_no_modification_date", name = current_file_name.to_string_lossy().to_string(), reason = e.to_string() )); } 0 } } } #[cfg(target_family = "windows")] pub(crate) fn inode(_fe: &FileEntry) -> Option { None } #[cfg(target_family = "unix")] pub(crate) fn inode(fe: &FileEntry) -> Option { if let Ok(meta) = fs::metadata(&fe.path) { Some(meta.ino()) } else { None } } pub(crate) fn take_1_per_inode((k, mut v): (Option, Vec)) -> Vec { if k.is_some() { v.drain(1..); } v } #[cfg(test)] mod tests { use std::fs::File; use std::io::prelude::*; use std::time::{Duration, SystemTime}; use std::{fs, io}; use indexmap::IndexSet; use super::*; use crate::common::tool_data::*; impl CommonData for CommonToolData { type Info = (); type Parameters = (); fn get_information(&self) -> Self::Info {} fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { self } fn get_cd_mut(&mut self) -> &mut CommonToolData { self } fn found_any_items(&self) -> bool { false } } static NOW: std::sync::LazyLock = std::sync::LazyLock::new(|| SystemTime::UNIX_EPOCH + Duration::new(100, 0)); const CONTENT: &[u8; 1] = b"a"; fn normalize_path(item: &Path) -> PathBuf { let canonicalized = if cfg!(windows) { // Only canonicalize if it's not a network path // This can be done by checking if path starts with \\?\UNC\ if let Ok(dir_can) = item.canonicalize() && let Some(dir_can_str) = dir_can.to_string_lossy().strip_prefix(r"\\?\") && dir_can_str.chars().nth(1) == Some(':') { PathBuf::from(dir_can_str) } else { item.to_path_buf() } } else { if let Ok(dir) = item.canonicalize() { dir } else { item.to_path_buf() } }; #[cfg(target_family = "windows")] return crate::common::normalize_windows_path(&canonicalized); #[cfg(not(target_family = "windows"))] return canonicalized; } fn create_files(dir: &Path) -> io::Result<(PathBuf, PathBuf, PathBuf)> { let (src, hard, other_file) = (dir.join("a"), dir.join("b"), dir.join("c")); let mut file = File::create(&src)?; file.write_all(CONTENT)?; fs::hard_link(&src, &hard)?; file.set_modified(*NOW)?; let mut file = File::create(&other_file)?; file.write_all(CONTENT)?; file.set_modified(*NOW)?; Ok((normalize_path(&src), normalize_path(&hard), normalize_path(&other_file))) } #[test] fn test_traversal() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let dir_path = normalize_path(dir.path()); let (src, hard, other_file) = create_files(&dir_path)?; let secs = NOW.duration_since(SystemTime::UNIX_EPOCH).expect("Cannot fail calculating duration since epoch").as_secs(); let mut common_data = CommonToolData::new(ToolType::SimilarImages); common_data.directories.set_included_paths([dir.path().to_owned()].to_vec()); common_data.set_minimal_file_size(0); match DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(&Arc::default()) .common_data(&common_data) .build() .run() { DirTraversalResult::SuccessFiles { warnings: _, grouped_file_entries, } => { let actual: IndexSet<_> = grouped_file_entries.into_values().flatten().collect(); assert_eq!( IndexSet::from([ FileEntry { path: normalize_path(&src), size: 1, modified_date: secs, }, FileEntry { path: normalize_path(&hard), size: 1, modified_date: secs, }, FileEntry { path: normalize_path(&other_file), size: 1, modified_date: secs, }, ]), actual ); } DirTraversalResult::Stopped => { panic!("Expect SuccessFiles."); } } Ok(()) } fn create_temp_structure(dir: &Path) -> io::Result<(PathBuf, PathBuf, PathBuf)> { let global_file = dir.join("global_file.txt"); let other_dir = dir.join("other_file"); fs::create_dir_all(&other_dir)?; let other_file = other_dir.join("other_file.txt"); let mut f = File::create(&global_file)?; f.write_all(b"global_file")?; f.set_modified(*NOW)?; let mut f2 = File::create(&other_file)?; f2.write_all(b"other_file")?; f2.set_modified(*NOW)?; let global_file = normalize_path(&global_file); let other_file = normalize_path(&other_file); let other_dir = normalize_path(&other_dir); Ok((global_file, other_file, other_dir)) } fn run_traversal(common_data: &CommonToolData) -> Vec { match DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(&Arc::default()) .common_data(common_data) .build() .run() { DirTraversalResult::SuccessFiles { grouped_file_entries, .. } => grouped_file_entries.into_values().flatten().collect(), DirTraversalResult::Stopped => panic!("Expect SuccessFiles."), } } #[test] #[expect(clippy::needless_for_each)] fn test_traversal_with_and_without_excluded_dir() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let dir_path = dir.path().to_path_buf(); let dir_path = normalize_path(&dir_path); let (global_file, other_file, other_dir) = create_temp_structure(&dir_path)?; let secs = NOW.duration_since(SystemTime::UNIX_EPOCH).expect("Cannot fail calculating duration since epoch").as_secs(); let mut common_data = CommonToolData::new(ToolType::SimilarImages); common_data.directories.set_included_paths([dir.path().to_owned()].to_vec()); common_data.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(2, actual.len()); assert!(actual.contains(&FileEntry { path: global_file.clone(), size: 11, modified_date: secs })); assert!(actual.contains(&FileEntry { path: other_file.clone(), size: 10, modified_date: secs })); let mut common_data2 = CommonToolData::new(ToolType::SimilarImages); common_data2.directories.set_included_paths([dir.path().to_owned()].to_vec()); common_data2.directories.set_excluded_paths([other_dir].to_vec()); common_data2.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data2); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(1, actual.len()); assert!(actual.contains(&FileEntry { path: global_file.clone(), size: 11, modified_date: secs })); let mut common_data3 = CommonToolData::new(ToolType::SimilarImages); common_data3.directories.set_included_paths([dir.path().to_owned()].to_vec()); common_data3.directories.set_excluded_paths([other_file.clone()].to_vec()); common_data3.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data3); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(1, actual.len()); assert!(actual.contains(&FileEntry { path: global_file.clone(), size: 11, modified_date: secs })); let mut common_data4 = CommonToolData::new(ToolType::SimilarImages); common_data4.directories.set_included_paths([global_file.clone()].to_vec()); common_data4.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data4); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(1, actual.len()); assert!(actual.contains(&FileEntry { path: global_file.clone(), size: 11, modified_date: secs })); let mut common_data5 = CommonToolData::new(ToolType::SimilarImages); common_data5.directories.set_included_paths([global_file.clone(), other_file.clone()].to_vec()); common_data5.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data5); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(2, actual.len()); assert!(actual.contains(&FileEntry { path: global_file.clone(), size: 11, modified_date: secs })); assert!(actual.contains(&FileEntry { path: other_file.clone(), size: 10, modified_date: secs })); // Other file should be excluded by optimizer, but it works even without it, so we can keep this test, but can be removed if it will start to fail let mut common_data6 = CommonToolData::new(ToolType::SimilarImages); common_data6.directories.set_included_paths([global_file.clone(), other_file.clone()].to_vec()); common_data6.directories.set_excluded_paths([other_file].to_vec()); common_data6.set_minimal_file_size(0); let mut actual: Vec<_> = run_traversal(&common_data6); actual.iter_mut().for_each(|e| e.path = normalize_path(&e.path)); assert_eq!(1, actual.len()); assert!(actual.contains(&FileEntry { path: global_file, size: 11, modified_date: secs })); // This test is invalid - other dir should be removed by optimizer // let mut common_data7 = CommonToolData::new(ToolType::SimilarImages); // common_data7.directories.set_included_paths([other_file.clone()].to_vec()); // common_data7.directories.set_excluded_paths([other_dir.clone()].to_vec()); // common_data7.set_minimal_file_size(0); // // let actual: IndexSet<_> = run_traversal(&common_data7).into_iter().collect(); // assert_eq!(0, actual.len()); Ok(()) } #[cfg(target_family = "unix")] #[test] fn test_traversal_group_by_inode() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let dir_path = normalize_path(dir.path()); let (src, _, other) = create_files(&dir_path)?; let secs = NOW.duration_since(SystemTime::UNIX_EPOCH).expect("Cannot fail calculating duration since epoch").as_secs(); let mut common_data = CommonToolData::new(ToolType::SimilarImages); common_data.directories.set_included_paths([dir.path().to_owned()].to_vec()); common_data.set_minimal_file_size(0); match DirTraversalBuilder::new() .group_by(inode) .stop_flag(&Arc::default()) .common_data(&common_data) .build() .run() { DirTraversalResult::SuccessFiles { warnings: _, grouped_file_entries, } => { let actual: IndexSet<_> = grouped_file_entries.into_iter().flat_map(take_1_per_inode).collect(); assert_eq!( IndexSet::from([ FileEntry { path: normalize_path(&src), size: 1, modified_date: secs, }, FileEntry { path: normalize_path(&other), size: 1, modified_date: secs, }, ]), actual ); } DirTraversalResult::Stopped => { panic!("Expect SuccessFiles."); } } Ok(()) } #[cfg(target_family = "windows")] #[test] fn test_traversal_group_by_inode() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let dir_path = normalize_path(&dir.path()); let (src, hard, other) = create_files(&dir_path)?; let secs = NOW.duration_since(SystemTime::UNIX_EPOCH).expect("Cannot fail duration from epoch").as_secs(); let mut common_data = CommonToolData::new(ToolType::SimilarImages); common_data.directories.set_included_paths([dir_path.to_owned()].to_vec()); common_data.set_minimal_file_size(0); match DirTraversalBuilder::new() .group_by(inode) .stop_flag(&Arc::default()) .common_data(&common_data) .build() .run() { DirTraversalResult::SuccessFiles { warnings: _, grouped_file_entries, } => { let actual: IndexSet<_> = grouped_file_entries.into_iter().flat_map(take_1_per_inode).collect(); assert_eq!( IndexSet::from([ FileEntry { path: src, size: 1, modified_date: secs, }, FileEntry { path: hard, size: 1, modified_date: secs, }, FileEntry { path: other, size: 1, modified_date: secs, }, ]), actual ); } _ => { panic!("Expect SuccessFiles."); } }; Ok(()) } } czkawka_core-11.0.1/src/common/directories.rs000064400000000000000000000436471046102023000173260ustar 00000000000000use std::path::{Path, PathBuf}; #[cfg(target_family = "unix")] use std::{fs, os::unix::fs::MetadataExt}; use crate::common::traits::ResultEntry; use crate::flc; use crate::helpers::messages::Messages; #[derive(Debug, Clone, Default)] pub struct Directories { pub(crate) included_directories: Vec, pub(crate) excluded_directories: Vec, pub(crate) reference_directories: Vec, pub(crate) included_files: Vec, pub(crate) excluded_files: Vec, pub(crate) reference_files: Vec, pub(crate) original_included_paths: Vec, pub(crate) original_excluded_paths: Vec, pub(crate) original_reference_paths: Vec, pub(crate) exclude_other_filesystems: Option, #[cfg(target_family = "unix")] pub(crate) included_dev_ids: Vec, } impl Directories { pub fn new() -> Self { Default::default() } pub(crate) fn set_reference_paths(&mut self, reference_paths: Vec) -> Messages { self.reference_files = Vec::new(); self.reference_directories = Vec::new(); self.original_reference_paths = reference_paths.clone(); self.process_paths(reference_paths, true, false) } pub(crate) fn set_included_paths(&mut self, included_paths: Vec) -> Messages { self.included_files = Vec::new(); self.included_directories = Vec::new(); self.original_included_paths = included_paths.clone(); self.process_paths(included_paths, false, false) } pub(crate) fn set_excluded_paths(&mut self, excluded_paths: Vec) -> Messages { self.excluded_files = Vec::new(); self.excluded_directories = Vec::new(); self.original_excluded_paths = excluded_paths.clone(); self.process_paths(excluded_paths, false, true) } fn process_paths(&mut self, paths: Vec, is_reference: bool, is_excluded: bool) -> Messages { let mut messages: Messages = Messages::new(); if paths.is_empty() { return messages; } for path in paths { if is_excluded && path.to_string_lossy() == "/" { messages.errors.push(flc!("core_excluded_paths_pointless_slash")); break; } let (dir, msg) = Self::canonicalize_and_clear_path(&path, is_excluded); messages.extend_with_another_messages(msg); if let Some(dir) = dir { #[cfg(target_family = "windows")] let dir = crate::common::normalize_windows_path(&dir); match (dir.is_file(), is_reference, is_excluded) { (false, true, false) => self.reference_directories.push(dir), (false, false, false) => self.included_directories.push(dir), (false, false, true) => self.excluded_directories.push(dir), (true, true, false) => self.reference_files.push(dir), (true, false, false) => self.included_files.push(dir), (true, false, true) => self.excluded_files.push(dir), _ => unreachable!("Invalid combination of parameters in process_paths"), } } } messages } fn canonicalize_and_clear_path(path: &Path, is_excluded: bool) -> (Option, Messages) { let mut messages = Messages::new(); let mut path = path.to_path_buf(); if !path.exists() { if !is_excluded { messages.warnings.push(flc!("core_path_must_exists", path = path.to_string_lossy().to_string())); } return (None, messages); } if !path.is_dir() && !path.is_file() { messages.warnings.push(flc!("core_must_be_directory_or_file", path = path.to_string_lossy().to_string())); return (None, messages); } // Try to canonicalize them if cfg!(windows) { // Only canonicalize if it's not a network path // This can be done by checking if path starts with \\?\UNC\ if let Ok(dir_can) = path.canonicalize() && let Some(dir_can_str) = dir_can.to_string_lossy().strip_prefix(r"\\?\") && dir_can_str.chars().nth(1) == Some(':') { path = PathBuf::from(dir_can_str); } } else { if let Ok(dir) = path.canonicalize() { path = dir; } } #[cfg(target_family = "windows")] let path = crate::common::normalize_windows_path(&path); (Some(path), messages) } #[cfg(target_family = "unix")] pub(crate) fn set_exclude_other_filesystems(&mut self, exclude_other_filesystems: bool) { self.exclude_other_filesystems = Some(exclude_other_filesystems); } pub(crate) fn optimize_directories(&mut self, recursive_search: bool, skip_exist_check: bool) -> Result { let mut messages: Messages = Messages::new(); if self.original_included_paths.is_empty() { messages.critical = Some(flc!("core_cannot_start_scan_no_included_paths")); return Err(messages); } if self.included_directories.is_empty() && self.included_files.is_empty() { messages.critical = Some(flc!("core_skip_exist_check_all_included_paths_nonexistent")); return Err(messages); } // Remove duplicated entries like: "/", "/" for items in &mut [ &mut self.included_directories, &mut self.excluded_directories, &mut self.reference_directories, &mut self.included_files, &mut self.excluded_files, &mut self.reference_files, ] { items.sort_unstable(); items.dedup(); } // Optimize for duplicated included directories - "/", "/home". "/home/Pulpit" to "/" // Do not use when not using recursive search if recursive_search && !self.exclude_other_filesystems.unwrap_or(false) { for kk in [&mut self.included_directories, &mut self.excluded_directories] { let cloned = kk.clone(); kk.retain(|item| !cloned.iter().any(|other_item| item != other_item && item.starts_with(other_item))); } } // Remove included directories which are inside any excluded directory // Same with included files for kk in [&mut self.included_directories, &mut self.included_files] { kk.retain(|id| !self.excluded_directories.iter().any(|ed| id.starts_with(ed))); } // Remove included files inside included directories { let kk = &mut self.included_files; kk.retain(|id| !self.included_directories.iter().any(|ed| id.starts_with(ed))); } // Also check if files are not excluded directly { let kk = &mut self.included_files; kk.retain(|id| !self.excluded_directories.iter().any(|ed| id == ed)); } // Remove non existed directories and files if !skip_exist_check { for kk in [ &mut self.excluded_files, &mut self.excluded_directories, &mut self.included_files, &mut self.included_directories, ] { kk.retain(|path| path.exists()); } } // Excluded paths must are inside included path, because otherwise they are pointless // So first, removing included files, that are inside excluded directories // So this will allow to remove excluded directories outside included directories self.included_files.retain(|ifile| !self.excluded_directories.iter().any(|ed| ifile.starts_with(ed))); self.excluded_directories.retain(|ed| self.included_directories.iter().any(|id| ed.starts_with(id))); // Selecting Reference folders { self.reference_directories.retain(|folder| self.included_directories.iter().any(|e| folder.starts_with(e))); self.reference_files .retain(|file| self.included_directories.iter().any(|e| file.starts_with(e)) || self.included_files.iter().any(|f| file == f)); } // Not needed, but better is to have sorted everything for items in &mut [ &mut self.included_directories, &mut self.excluded_directories, &mut self.reference_directories, &mut self.included_files, &mut self.excluded_files, &mut self.reference_files, ] { items.sort_unstable(); } // Get device IDs for included directories, probably ther better solution would be to get one id per directory, but this is faster, but a little less precise #[cfg(target_family = "unix")] if self.exclude_other_filesystems() { for d in &self.included_directories { match fs::metadata(d) { Ok(m) => self.included_dev_ids.push(m.dev()), Err(_) => messages.errors.push(flc!("core_paths_unable_to_get_device_id", path = d.to_string_lossy().to_string())), } } } if self.included_directories.is_empty() && self.included_files.is_empty() { messages.critical = Some(flc!("core_missing_no_chosen_included_path")); return Err(messages); } if self.reference_directories == self.included_directories && self.included_files == self.reference_files { messages.critical = Some(flc!("core_reference_included_paths_same")); return Err(messages); } Ok(messages) } pub(crate) fn is_in_referenced_directory(&self, path: &Path) -> bool { self.reference_directories.iter().any(|e| path.starts_with(e)); self.reference_files.iter().any(|e| e.as_path() == path); self.reference_directories.iter().any(|e| path.starts_with(e)) || self.reference_files.iter().any(|e| e.as_path() == path) } pub(crate) fn is_excluded_dir(&self, path: &Path) -> bool { #[cfg(target_family = "windows")] let path = crate::common::normalize_windows_path(path); // We're assuming that `excluded_directories` are already normalized self.excluded_directories.iter().any(|p| p.as_path() == path) } pub(crate) fn is_excluded_file(&self, path: &Path) -> bool { #[cfg(target_family = "windows")] let path = crate::common::normalize_windows_path(path); // We're assuming that `excluded_files` are already normalized self.excluded_files.iter().any(|p| p.as_path() == path) } // Usually it is not required, because if main directory is excluded, then we don't run check on // every single children, different situation is with excluded single file pub(crate) fn is_excluded_item_in_dir(&self, path: &Path) -> bool { #[cfg(target_family = "windows")] let path = crate::common::normalize_windows_path(path); #[cfg(target_family = "windows")] let path = &path; // We're assuming that `excluded_directories` are already normalized self.excluded_directories.iter().any(|p| path.starts_with(p)) } #[cfg(target_family = "unix")] pub(crate) fn exclude_other_filesystems(&self) -> bool { self.exclude_other_filesystems.unwrap_or(false) } #[cfg(target_family = "unix")] pub(crate) fn is_on_other_filesystems>(&self, path: P) -> Result { let path = path.as_ref(); match fs::metadata(path) { Ok(m) => { if m.file_type().is_file() && !self.included_files.is_empty() && self.included_files.contains(&path.to_path_buf()) { return Ok(false); // Exact equality for included files is always allowed } Ok(!self.included_dev_ids.iter().any(|&id| id == m.dev())) } Err(_) => Err(flc!("core_paths_unable_to_get_device_id", path = path.to_string_lossy().to_string())), } } pub(crate) fn filter_reference_folders(&self, entries_to_check: Vec>) -> Vec<(T, Vec)> where T: ResultEntry, { entries_to_check .into_iter() .filter_map(|vec_file_entry| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry.into_iter().partition(|e| self.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>() } } #[cfg(test)] mod tests { use std::path::PathBuf; use super::*; #[test] fn test_no_included_paths_errors() { let mut d = Directories::new(); let msgs = d.optimize_directories(true, true).unwrap_err(); assert!(msgs.critical.is_some()); } #[test] fn test_dedup_included_directories() { let p = PathBuf::from("/this/path/does/not/exist/dedup"); let mut d = Directories::new(); d.included_directories.push(p.clone()); d.included_directories.push(p.clone()); d.original_included_paths.push(p.clone()); d.original_included_paths.push(p.clone()); let _msgs = d.optimize_directories(true, true).unwrap(); assert_eq!(d.included_directories, vec![p]); } #[test] fn test_excluded_removes_included_inside() { let base = PathBuf::from("/this/base/does/not/exist"); let sub = base.join("sub"); let mut d = Directories::new(); d.included_directories.push(sub.clone()); d.original_included_paths.push(sub); d.excluded_directories.push(base); let _msgs = d.optimize_directories(true, true).unwrap_err(); assert_eq!(d.included_directories, Vec::::new()); } #[test] fn test_optimize_nested_included_directories_dedup() { let mut d = Directories::new(); d.included_directories.push(PathBuf::from("/")); d.original_included_paths.push(PathBuf::from("/")); d.included_directories.push(PathBuf::from("/home")); d.original_included_paths.push(PathBuf::from("/home")); d.included_directories.push(PathBuf::from("/home/Pulpit")); d.original_included_paths.push(PathBuf::from("/home/Pulpit")); // use recursive_search = true and skip_exist_check = true as requested let msgs = d.optimize_directories(true, true).unwrap(); // only root should remain after dedup assert_eq!(d.included_directories, vec![PathBuf::from("/")]); assert!(msgs.critical.is_none()); } #[test] fn test_excluded_directories_pruned_to_inside_included() { let mut d = Directories::new(); d.included_directories.push(PathBuf::from("/this/include")); d.original_included_paths.push(PathBuf::from("/this/include")); d.excluded_directories.push(PathBuf::from("/this/include/sub")); d.excluded_directories.push(PathBuf::from("/other/place")); d.original_excluded_paths.push(PathBuf::from("/this/include/sub")); d.original_excluded_paths.push(PathBuf::from("/other/place")); let _msgs = d.optimize_directories(true, true).unwrap(); assert_eq!(d.included_directories, vec![PathBuf::from("/this/include")]); assert_eq!(d.excluded_directories, vec![PathBuf::from("/this/include/sub")]); } #[test] fn test_reference_dirs_and_files_retained_correctly() { let mut d = Directories::new(); d.included_directories.push(PathBuf::from("/a")); d.original_included_paths.push(PathBuf::from("/a")); d.included_files.push(PathBuf::from("/a/included_file.txt")); d.original_included_paths.push(PathBuf::from("/a/included_file.txt")); d.reference_directories.push(PathBuf::from("/a/sub")); d.reference_directories.push(PathBuf::from("/other")); d.reference_files.push(PathBuf::from("/a/included_file.txt")); d.reference_files.push(PathBuf::from("/other/file2.txt")); let _msgs = d.optimize_directories(true, true).unwrap(); assert_eq!(d.included_directories, vec![PathBuf::from("/a")]); assert_eq!(d.excluded_directories, Vec::::new()); assert_eq!(d.included_files, Vec::::new()); assert_eq!(d.excluded_files, Vec::::new()); assert_eq!(d.reference_directories, vec![PathBuf::from("/a/sub")]); assert_eq!(d.reference_files, vec![PathBuf::from("/a/included_file.txt")]); } #[test] fn test_reference_equals_included_error() { let mut d = Directories::new(); d.included_directories.push(PathBuf::from("/same")); d.reference_directories.push(PathBuf::from("/same")); d.included_files = Vec::new(); d.reference_files = Vec::new(); let msgs = d.optimize_directories(true, true).unwrap_err(); assert!(msgs.critical.is_some()); } #[test] fn test_included_files_removed_when_equal_to_excluded_directory() { let mut d = Directories::new(); d.included_directories.push(PathBuf::from("/base")); d.original_included_paths.push(PathBuf::from("/base")); d.included_files.push(PathBuf::from("/base/file")); d.original_included_paths.push(PathBuf::from("/base/file")); // excluded directory equals included file path d.excluded_directories.push(PathBuf::from("/base/file")); d.original_excluded_paths.push(PathBuf::from("/base/file")); let _msgs = d.optimize_directories(true, true).unwrap(); // included_files should be removed because it equals an excluded directory assert!(d.included_files.is_empty()); // excluded_directories should be retained as it's inside included_directories assert_eq!(d.excluded_directories, vec![PathBuf::from("/base/file")]); assert_eq!(d.included_directories, vec![PathBuf::from("/base")]); } } czkawka_core-11.0.1/src/common/extensions.rs000064400000000000000000000234431046102023000172010ustar 00000000000000use std::ffi::OsStr; use indexmap::IndexSet; use crate::common::consts::{AUDIO_FILES_EXTENSIONS, IMAGE_RS_EXTENSIONS, TEXT_FILES_EXTENSIONS, VIDEO_FILES_EXTENSIONS}; use crate::flc; use crate::helpers::messages::Messages; #[derive(Debug, Clone, Default)] pub struct Extensions { allowed_extensions_hashset: IndexSet, excluded_extensions_hashset: IndexSet, } impl Extensions { pub fn new() -> Self { Default::default() } pub(crate) fn filter_extensions(file_extensions: Vec) -> (IndexSet, Messages) { let mut messages = Messages::new(); let extensions_hashset: IndexSet = file_extensions .into_iter() .flat_map(|e| match e.trim().trim_start_matches(".").to_lowercase().as_str() { "image" => IMAGE_RS_EXTENSIONS.iter().map(|s| s.to_string()).collect(), "video" => VIDEO_FILES_EXTENSIONS.iter().map(|s| s.to_string()).collect(), "music" => AUDIO_FILES_EXTENSIONS.iter().map(|s| s.to_string()).collect(), "text" => TEXT_FILES_EXTENSIONS.iter().map(|s| s.to_string()).collect(), _ => vec![e], }) .filter_map(|extension| { let e = extension.trim().trim_start_matches(".").to_lowercase(); if e.is_empty() { return None; } if e.contains(' ') { messages.warnings.push(flc!("core_invalid_extension_contains_space", extension = extension)); return None; } if e.contains('.') { messages.warnings.push(flc!("core_invalid_extension_contains_dot", extension = extension)); return None; } Some(e) }) .collect(); (extensions_hashset, messages) } pub(crate) fn set_allowed_extensions(&mut self, allowed_extensions: Vec) -> Messages { let (extensions, messages) = Self::filter_extensions(allowed_extensions); self.allowed_extensions_hashset = extensions; messages } pub(crate) fn set_excluded_extensions(&mut self, excluded_extensions: Vec) -> Messages { let (extensions, messages) = Self::filter_extensions(excluded_extensions); self.excluded_extensions_hashset = extensions; messages } #[expect(clippy::string_slice)] // Valid, because we address go to dot, which is known ascii character pub(crate) fn check_if_entry_have_valid_extension(&self, file_name: &OsStr) -> bool { if self.allowed_extensions_hashset.is_empty() && self.excluded_extensions_hashset.is_empty() { return true; } // Using entry_data.path().extension() is a lot of slower, even 5 times let Some(file_name_str) = file_name.to_str() else { return false }; let Some(extension_idx) = file_name_str.rfind('.') else { return false }; let extension = &file_name_str[extension_idx + 1..]; if !self.allowed_extensions_hashset.is_empty() { if extension.chars().all(|c| c.is_ascii_lowercase()) { self.allowed_extensions_hashset.contains(extension) } else { self.allowed_extensions_hashset.contains(&extension.to_lowercase()) } } else if extension.chars().all(|c| c.is_ascii_lowercase()) { !self.excluded_extensions_hashset.contains(extension) } else { !self.excluded_extensions_hashset.contains(&extension.to_lowercase()) } } // E.g. when using similar videos, user can provide extensions like "mp4,flv", but if user provide "mp4,jpg" then // it will be only "mp4" because "jpg" is not valid extension for videos fn intersection_allowed_extensions(&mut self, file_extensions: &[&str]) { self.allowed_extensions_hashset.retain(|ext| file_extensions.contains(&ext.as_str())); } // Tool extensions may be set by the tool itself, e.g. similar images may only use image extensions pub(crate) fn set_and_validate_extensions(&mut self, tool_extensions: Option<&[&str]>) -> Result<(), String> { let user_set_any_allowed_extensions = !self.allowed_extensions_hashset.is_empty(); let tool_have_any_extensions = tool_extensions.is_some(); // If user not set any extensions and tool not have any allowed extension, it is fine if !user_set_any_allowed_extensions && !tool_have_any_extensions { return Ok(()); } if let Some(tool_extensions) = tool_extensions { // If there is no selected allowed extensions, that means that are all allowed // If there are some allowed extensions, we need to do intersection with tool extensions if user_set_any_allowed_extensions { self.intersection_allowed_extensions(tool_extensions); } else { self.allowed_extensions_hashset = tool_extensions.iter().map(|ext| ext.trim_start_matches('.').to_string()).collect(); } } let both_extensions = self.allowed_extensions_hashset.intersection(&self.excluded_extensions_hashset).cloned().collect::>(); self.allowed_extensions_hashset.retain(|ext| !both_extensions.contains(ext)); self.excluded_extensions_hashset.retain(|ext| !both_extensions.contains(ext)); if self.allowed_extensions_hashset.is_empty() { if let Some(tool_extensions) = tool_extensions { Err(flc!("core_needs_allowed_extensions_limited_by_tool", extensions = tool_extensions.join(", "))) } else { Err(flc!("core_needs_allowed_extensions")) } } else { Ok(()) } } } #[cfg(test)] mod tests { use std::fs; use super::*; #[test] fn test_filter_extensions_basic_and_replacements() { // Empty string let (exts, msgs) = Extensions::filter_extensions(Vec::new()); assert!(exts.is_empty()); assert!(msgs.messages.is_empty() && msgs.warnings.is_empty() && msgs.errors.is_empty()); // Basic extensions let (exts, msgs) = Extensions::filter_extensions(vec!["jpg".to_string(), "png".to_string(), "gif".to_string()]); assert_eq!(exts.len(), 3); assert!(exts.contains("jpg") && exts.contains("png") && exts.contains("gif")); assert!(msgs.warnings.is_empty()); // With dots let (exts, _) = Extensions::filter_extensions(vec![".jpg".to_string(), ".png".to_string()]); assert_eq!(exts.len(), 2); assert!(exts.contains("jpg") && exts.contains("png")); // IMAGE replacement let (exts, _) = Extensions::filter_extensions(vec!["IMAGE".to_string()]); assert!(exts.contains("jpg") && exts.contains("png") && exts.contains("bmp")); // VIDEO replacement let (exts, _) = Extensions::filter_extensions(vec!["VIDEO".to_string()]); assert!(exts.contains("mp4") && exts.contains("mkv") && exts.contains("avi")); // Invalid extensions with dot inside let (exts, msgs) = Extensions::filter_extensions(vec!["jpg".to_string(), "test.bad".to_string(), "png".to_string()]); assert_eq!(exts.len(), 2); assert!(!exts.contains("test.bad")); assert!(msgs.warnings.iter().any(|w| w.contains("test.bad"))); // Invalid extensions with space let (exts, msgs) = Extensions::filter_extensions(vec!["jpg".to_string(), "bad ext".to_string(), "png".to_string()]); assert!(!exts.contains("bad ext")); assert!(msgs.warnings.iter().any(|w| w.contains("bad ext"))); } #[test] fn test_check_if_entry_have_valid_extension() { let temp_dir = tempfile::tempdir().unwrap(); let file_jpg = temp_dir.path().join("test.jpg"); let file_png = temp_dir.path().join("test.PNG"); let file_gif = temp_dir.path().join("test.gif"); let file_txt = temp_dir.path().join("test.txt"); let file_no_ext = temp_dir.path().join("noext"); fs::write(&file_jpg, "test").unwrap(); fs::write(&file_png, "test").unwrap(); fs::write(&file_gif, "test").unwrap(); fs::write(&file_txt, "test").unwrap(); fs::write(&file_no_ext, "test").unwrap(); // No extensions set - all should pass let ext = Extensions::new(); assert!( ext.check_if_entry_have_valid_extension( &fs::read_dir(&temp_dir) .unwrap() .find(|e| e.as_ref().unwrap().file_name() == "test.jpg") .unwrap() .unwrap() .file_name() ) ); // Allowed extensions let mut ext = Extensions::new(); ext.set_allowed_extensions(vec!["jpg".to_string(), "png".to_string()]); let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().map(|e| e.unwrap()).collect(); assert!(ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "test.jpg").unwrap().file_name())); assert!(ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "test.PNG").unwrap().file_name())); // case insensitive assert!(!ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "test.gif").unwrap().file_name())); assert!(!ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "noext").unwrap().file_name())); // Excluded extensions let mut ext = Extensions::new(); ext.set_excluded_extensions(vec!["txt".to_string()]); assert!(ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "test.jpg").unwrap().file_name())); assert!(!ext.check_if_entry_have_valid_extension(&entries.iter().find(|e| e.file_name() == "test.txt").unwrap().file_name())); } } czkawka_core-11.0.1/src/common/ffmpeg_utils.rs000064400000000000000000000014451046102023000174640ustar 00000000000000use std::process::{Command, Stdio}; use crate::common::process_utils::disable_windows_console_window; pub fn check_if_ffprobe_ffmpeg_exists() -> bool { let mut ffmpeg_command = Command::new("ffmpeg"); disable_windows_console_window(&mut ffmpeg_command); let ffmpeg_ok = ffmpeg_command .arg("-version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); let mut ffprobe_command = Command::new("ffprobe"); disable_windows_console_window(&mut ffprobe_command); let ffprobe_ok = ffprobe_command .arg("-version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); ffprobe_ok && ffmpeg_ok } czkawka_core-11.0.1/src/common/image.rs000064400000000000000000000247641046102023000160730ustar 00000000000000use std::fs::File; use std::panic; use std::path::Path; use image::{DynamicImage, ImageReader}; use log::{error, trace}; use nom_exif::{ExifIter, ExifTag, MediaParser, MediaSource}; use crate::common::consts::{HEIC_EXTENSIONS, IMAGE_RS_EXTENSIONS, RAW_IMAGE_EXTENSIONS}; use crate::common::create_crash_message; use crate::flc; const MAXIMUM_IMAGE_PIXELS: u32 = 2_000_000_000; pub fn register_image_decoding_hooks() { #[cfg(feature = "heif")] libheif_rs::integration::image::register_all_decoding_hooks(); jxl_oxide::integration::register_image_decoding_hook(); } // Using this instead of image::open because image::open only reads content of files if extension matches content // This is not really helpful when trying to show preview of files with wrong extensions pub(crate) fn decode_normal_image(path: &str) -> Result { let file = File::open(path).map_err(|e| e.to_string())?; let reader = ImageReader::new(std::io::BufReader::new(file)).with_guessed_format().map_err(|e| e.to_string())?; let img = reader.decode().map_err(|e| e.to_string())?; Ok(img) } pub fn get_dynamic_image_from_path(path: &str) -> Result { let path_lower = Path::new(path).extension().unwrap_or_default().to_string_lossy().to_lowercase(); trace!("decoding file \"{path}\""); let res = panic::catch_unwind(|| { let img = if RAW_IMAGE_EXTENSIONS.iter().any(|ext| path_lower.ends_with(ext)) { get_raw_image(path).map_err(|e| flc!("core_image_open_failed", path = path, reason = e)) } else { // Heic files must be registered in image-rs decode_normal_image(path).map_err(|e| flc!("core_image_open_failed", path = path, reason = e)) }?; if img.width() == 0 || img.height() == 0 { return Err(flc!("core_image_zero_dimensions", path = path)); } if img.width() as u64 * img.height() as u64 > MAXIMUM_IMAGE_PIXELS as u64 { return Err(flc!("core_image_too_large", width = img.width(), height = img.height(), max = MAXIMUM_IMAGE_PIXELS)); } Ok(img) }); if let Ok(res) = res { match res { Ok(t) => { if t.width() == 0 || t.height() == 0 { return Err(flc!("core_image_zero_dimensions", path = path)); } let rotation = get_rotation_from_exif(path).unwrap_or(None); match rotation { Some(ExifOrientation::Normal) | None => Ok(t), Some(ExifOrientation::MirrorHorizontal) => Ok(t.fliph()), Some(ExifOrientation::Rotate180) => Ok(t.rotate180()), Some(ExifOrientation::MirrorVertical) => Ok(t.flipv()), Some(ExifOrientation::MirrorHorizontalAndRotate270CW) => Ok(t.fliph().rotate270()), Some(ExifOrientation::Rotate90CW) => Ok(t.rotate90()), Some(ExifOrientation::MirrorHorizontalAndRotate90CW) => Ok(t.fliph().rotate90()), Some(ExifOrientation::Rotate270CW) => Ok(t.rotate270()), } } Err(e) => Err(flc!("core_image_open_failed", path = path, reason = e)), } } else { let message = create_crash_message("Image-rs or libraw-rs or jxl-oxide", path, "https://github.com/image-rs/image/issues"); error!("{message}"); Err(message) } } #[cfg(feature = "libraw")] pub(crate) fn get_raw_image>(path: P) -> Result { let buf = std::fs::read(path.as_ref()).map_err(|e| format!("Error reading image: {e}"))?; let processor = libraw::Processor::new(); let processed = processor.process_8bit(&buf).map_err(|e| format!("Error processing RAW image: {e}"))?; let width = processed.width(); let height = processed.height(); let data = processed.to_vec(); let data_len = data.len(); let buffer = image::ImageBuffer::from_raw(width, height, data).ok_or(format!( "Cannot create ImageBuffer from raw image with width: {width} and height: {height} and data length: {data_len}", ))?; Ok(DynamicImage::ImageRgb8(buffer)) } #[cfg(not(feature = "libraw"))] pub(crate) fn get_raw_image + std::fmt::Debug>(path: P) -> Result { use rawler::decoders::RawDecodeParams; use rawler::imgop::develop::RawDevelop; use rawler::rawsource::RawSource; let mut timer = crate::helpers::debug_timer::Timer::new("Rawler"); let raw_source = RawSource::new(path.as_ref()).map_err(|err| format!("Failed to create RawSource from path {}: {err}", path.as_ref().to_string_lossy()))?; timer.checkpoint("Created RawSource"); let decoder = rawler::get_decoder(&raw_source).map_err(|e| e.to_string())?; timer.checkpoint("Got decoder"); let params = RawDecodeParams::default(); // TODO - Nef currently disabled, due really bad quality of some extracted images https://github.com/dnglab/dnglab/issues/638, waiting for new release if !path.as_ref().to_string_lossy().to_ascii_lowercase().ends_with(".nef") && let Some(extracted_dynamic_image) = decoder.full_image(&raw_source, ¶ms).ok().flatten() { timer.checkpoint("Decoded full image"); trace!("{}", timer.report("Everything", false)); return Ok(extracted_dynamic_image); } let raw_image = decoder.raw_image(&raw_source, ¶ms, false).map_err(|e| e.to_string())?; timer.checkpoint("Decoded raw image"); let developer = RawDevelop::default(); let developed_image = developer.develop_intermediate(&raw_image).map_err(|e| e.to_string())?; timer.checkpoint("Developed raw image"); let dynamic_image = developed_image.to_dynamic_image().ok_or("Failed to convert image to DynamicImage".to_string())?; timer.checkpoint("Converted to DynamicImage"); trace!("{}", timer.report("Everything", false)); Ok(dynamic_image) } pub fn check_if_can_display_image(path: &str) -> bool { let Some(extension) = Path::new(path).extension() else { return false; }; let extension_str = extension.to_string_lossy().to_lowercase(); #[cfg(feature = "heif")] let allowed_extensions = &[IMAGE_RS_EXTENSIONS, RAW_IMAGE_EXTENSIONS, HEIC_EXTENSIONS].concat(); #[cfg(not(feature = "heif"))] let allowed_extensions = &[IMAGE_RS_EXTENSIONS, RAW_IMAGE_EXTENSIONS].concat(); allowed_extensions.iter().any(|ext| &extension_str == ext) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExifOrientation { Normal, MirrorHorizontal, Rotate180, MirrorVertical, MirrorHorizontalAndRotate270CW, Rotate90CW, MirrorHorizontalAndRotate90CW, Rotate270CW, } pub(crate) fn get_rotation_from_exif(path: &str) -> Result, nom_exif::Error> { if let Some(extension) = Path::new(path).extension() && HEIC_EXTENSIONS.contains(&extension.to_string_lossy().to_lowercase().as_str()) { return Ok(None); // libheif already applies orientation } let res = panic::catch_unwind(|| { let mut parser = MediaParser::new(); let ms = MediaSource::file_path(path)?; if !ms.has_exif() { return Ok(None); } let exif_iter: ExifIter = parser.parse(ms)?; for exif_entry in exif_iter { if exif_entry.tag() == Some(ExifTag::Orientation) && let Some(value) = exif_entry.get_value() { return match value.to_string().as_str() { "1" => Ok(Some(ExifOrientation::Normal)), "2" => Ok(Some(ExifOrientation::MirrorHorizontal)), "3" => Ok(Some(ExifOrientation::Rotate180)), "4" => Ok(Some(ExifOrientation::MirrorVertical)), "5" => Ok(Some(ExifOrientation::MirrorHorizontalAndRotate270CW)), "6" => Ok(Some(ExifOrientation::Rotate90CW)), "7" => Ok(Some(ExifOrientation::MirrorHorizontalAndRotate90CW)), "8" => Ok(Some(ExifOrientation::Rotate270CW)), _ => Ok(None), }; } } Ok(None) }); res.unwrap_or_else(|_| { let message = create_crash_message("nom-exif", path, "https://github.com/mindeng/nom-exif"); error!("{message}"); Err(nom_exif::Error::IOError(std::io::Error::other("Panic in get_rotation_from_exif"))) }) } #[cfg(test)] mod tests { use super::*; const TEST_NORMAL_IMAGE: &str = "test_resources/images/normal.jpg"; const TEST_ROTATED_IMAGE: &str = "test_resources/images/rotated.jpg"; #[test] fn test_image_loading_and_exif_rotation() { let normal_img = get_dynamic_image_from_path(TEST_NORMAL_IMAGE).unwrap(); let rotated_img = get_dynamic_image_from_path(TEST_ROTATED_IMAGE).unwrap(); assert!(normal_img.width() > 0 && normal_img.height() > 0); assert!(rotated_img.width() > 0 && rotated_img.height() > 0); let normal_exif = get_rotation_from_exif(TEST_NORMAL_IMAGE).ok(); let rotated_exif = get_rotation_from_exif(TEST_ROTATED_IMAGE).ok(); if let Some(normal_orientation) = normal_exif { assert!(normal_orientation == Some(ExifOrientation::Normal) || normal_orientation.is_none()); } if let Some(rotated_orientation) = rotated_exif && rotated_orientation.is_some() { let raw_rotated = decode_normal_image(TEST_ROTATED_IMAGE).unwrap(); if rotated_orientation == Some(ExifOrientation::Rotate90CW) || rotated_orientation == Some(ExifOrientation::Rotate270CW) { assert_eq!(rotated_img.width(), raw_rotated.height()); assert_eq!(rotated_img.height(), raw_rotated.width()); } } } #[test] fn test_check_if_can_display_image() { assert!(check_if_can_display_image("test.jpg")); assert!(check_if_can_display_image("test.png")); assert!(check_if_can_display_image("test.webp")); assert!(check_if_can_display_image("test.jxl")); assert!(check_if_can_display_image("test.cr2")); assert!(check_if_can_display_image("test.JPG")); assert!(!check_if_can_display_image("test.txt")); assert!(!check_if_can_display_image("test.mp4")); assert!(!check_if_can_display_image("test")); } #[test] fn test_error_handling() { get_dynamic_image_from_path("nonexistent.jpg").unwrap_err(); decode_normal_image("nonexistent.jpg").unwrap_err(); get_rotation_from_exif("nonexistent.jpg").unwrap_err(); } } czkawka_core-11.0.1/src/common/items.rs000064400000000000000000000151231046102023000161170ustar 00000000000000use std::path::Path; use crate::common::regex_check; use crate::helpers::messages::Messages; #[cfg(target_family = "unix")] pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &["/proc", "/dev", "/sys", "/snap"]; #[cfg(not(target_family = "unix"))] pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &["C:\\Windows"]; #[cfg(all(target_family = "unix", target_os = "macos"))] pub const DEFAULT_EXCLUDED_ITEMS: &str = "*/.git/*,*/node_modules/*,*/lost+found/*,*/Trash/*,*/.Trash-*/*,/Users/*/Library/Caches/*"; #[cfg(all(target_family = "unix", not(target_os = "macos")))] pub const DEFAULT_EXCLUDED_ITEMS: &str = "*/.git/*,*/node_modules/*,*/lost+found/*,*/Trash/*,*/.Trash-*/*,*/snap/*,/home/*/.cache/*,/home/*/.var/app/,/home/*/.*"; #[cfg(not(target_family = "unix"))] pub const DEFAULT_EXCLUDED_ITEMS: &str = "*\\.git\\*,*\\node_modules\\*,*\\lost+found\\*,*:\\windows\\*,*:\\$RECYCLE.BIN\\*,*:\\$SysReset\\*,*:\\System Volume Information\\*,*:\\OneDriveTemp\\*,*:\\hiberfil.sys,*:\\pagefile.sys,*:\\swapfile.sys,*:\\Users\\*\\AppData"; #[derive(Debug, Clone, Default)] pub struct ExcludedItems { expressions: Vec, connected_expressions: Vec, } #[derive(Debug, Clone, Default)] pub struct SingleExcludedItem { pub expression: String, pub expression_splits: Vec, pub unique_extensions_splits: Vec, } impl ExcludedItems { pub fn new() -> Self { Default::default() } pub fn new_from(excluded_items: Vec) -> Self { let mut s = Self::new(); s.set_excluded_items(excluded_items); s } pub(crate) fn set_excluded_items(&mut self, excluded_items: Vec) -> Messages { let mut warnings: Vec = Vec::new(); if excluded_items.is_empty() { return Messages::new(); } let expressions: Vec = excluded_items; let mut checked_expressions: Vec = Vec::new(); for expression in expressions { let expression: String = expression.trim().to_string(); if expression.is_empty() { continue; } #[cfg(target_family = "windows")] let expression = expression.replace("/", "\\"); if expression == "DEFAULT" { checked_expressions.push(DEFAULT_EXCLUDED_ITEMS.to_string()); continue; } if !expression.contains('*') { warnings.push("Excluded Items Warning: Wildcard * is required in expression, ignoring ".to_string() + expression.as_str()); continue; } checked_expressions.push(expression); } for checked_expression in &checked_expressions { let item = new_excluded_item(checked_expression); self.expressions.push(item.expression.clone()); self.connected_expressions.push(item); } Messages { critical: None, messages: Vec::new(), warnings, errors: Vec::new(), } } pub(crate) fn get_excluded_items(&self) -> &Vec { &self.expressions } pub(crate) fn is_excluded(&self, path: &Path) -> bool { if self.connected_expressions.is_empty() { return false; } #[cfg(target_family = "windows")] let path = crate::common::normalize_windows_path(path); let path_str = path.to_string_lossy(); for expression in &self.connected_expressions { if regex_check(expression, &path_str) { return true; } } false } } pub fn new_excluded_item(expression: &str) -> SingleExcludedItem { let expression = expression.trim().to_string(); let expression_splits: Vec = expression.split('*').filter_map(|e| if e.is_empty() { None } else { Some(e.to_string()) }).collect(); let mut unique_extensions_splits = expression_splits.clone(); unique_extensions_splits.sort(); unique_extensions_splits.dedup(); unique_extensions_splits.sort_by_key(|b| std::cmp::Reverse(b.len())); SingleExcludedItem { expression, expression_splits, unique_extensions_splits, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_excluded_items_new_and_basic_operations() { let items = ExcludedItems::new(); assert!(items.expressions.is_empty()); assert!(items.connected_expressions.is_empty()); let items = ExcludedItems::new_from(vec!["*/.git/*".to_string(), "*/node_modules/*".to_string()]); assert_eq!(items.expressions.len(), 2); assert_eq!(items.get_excluded_items().len(), 2); } #[test] fn test_set_excluded_items_with_default() { let mut items = ExcludedItems::new(); let msgs = items.set_excluded_items(vec!["DEFAULT".to_string()]); assert!(msgs.warnings.is_empty()); assert_eq!(items.expressions.len(), 1); assert!(items.expressions[0].contains(".git") || items.expressions[0].contains("node_modules")); } #[test] fn test_set_excluded_items_warnings() { let mut items = ExcludedItems::new(); let msgs = items.set_excluded_items(vec!["no_wildcard".to_string(), " ".to_string()]); assert_eq!(msgs.warnings.len(), 1); assert!(msgs.warnings[0].contains("Wildcard * is required")); assert!(items.expressions.is_empty()); } #[test] fn test_is_excluded() { let mut items = ExcludedItems::new(); items.set_excluded_items(vec!["*/.git/*".to_string(), "*/node_modules/*".to_string(), "/home/*/.*".to_string()]); assert!(items.is_excluded(Path::new("/home/user/.git/config"))); assert!(items.is_excluded(Path::new("/home/user/.abscd/config"))); assert!(items.is_excluded(Path::new("/project/node_modules/package.json"))); assert!(!items.is_excluded(Path::new("/home/user/file.txt"))); // Empty items - nothing excluded let items_empty = ExcludedItems::new(); assert!(!items_empty.is_excluded(Path::new("/any/path"))); } #[test] fn test_new_excluded_item() { let item = new_excluded_item(" */test/*.txt "); assert_eq!(item.expression, "*/test/*.txt"); assert_eq!(item.expression_splits, vec!["/test/", ".txt"]); assert_eq!(item.unique_extensions_splits.len(), 2); let item2 = new_excluded_item("*abc*def*abc*"); assert_eq!(item2.expression_splits, vec!["abc", "def", "abc"]); // unique_extensions_splits should be deduplicated and sorted by length assert_eq!(item2.unique_extensions_splits, vec!["abc", "def"]); } } czkawka_core-11.0.1/src/common/logger.rs000064400000000000000000000162031046102023000162550ustar 00000000000000use std::env; use file_rotate::compression::Compression; use file_rotate::suffix::{AppendTimestamp, FileLimit}; use file_rotate::{ContentLimit, FileRotate}; use handsome_logger::{ColorChoice, CombinedLogger, ConfigBuilder, FormatText, SharedLogger, TermLogger, TerminalMode, TimeFormat, WriteLogger}; use log::{LevelFilter, Record, info, warn}; use crate::CZKAWKA_VERSION; use crate::common::config_cache_path::get_config_cache_path; use crate::common::get_all_available_threads; pub fn setup_logger(disabled_terminal_printing: bool, app_name: &str, filtering_messages_func: fn(&Record) -> bool) { log_panics::init(); let terminal_log_level = if disabled_terminal_printing && ![Ok("1"), Ok("true")].contains(&env::var("ENABLE_TERMINAL_LOGS_IN_CLI").as_deref()) { LevelFilter::Off } else { LevelFilter::Info }; let file_log_level = LevelFilter::Debug; let term_config = ConfigBuilder::default() .set_level(terminal_log_level) .set_message_filtering(Some(filtering_messages_func)) .build(); let file_config = ConfigBuilder::default() .set_level(file_log_level) .set_write_once(true) .set_message_filtering(Some(filtering_messages_func)) .set_time_format(TimeFormat::DateTimeWithMicro, None) .set_format_text(FormatText::DefaultWithThreadFile.get(), None) .build(); let combined_logger = (|| { let Some(config_cache_path) = get_config_cache_path() else { // println!("No config cache path configured, using default config folder"); return None; }; let cache_logs_path = config_cache_path.cache_folder.join(format!("{app_name}.log")); let write_rotater = FileRotate::new( &cache_logs_path, AppendTimestamp::default(FileLimit::MaxFiles(3)), ContentLimit::BytesSurpassed(100 * 1024 * 1024), Compression::None, None, ); let combined_logs: Vec> = if [Ok("1"), Ok("true")].contains(&env::var("DISABLE_FILE_LOGGING").as_deref()) { vec![TermLogger::new_from_config(term_config.clone())] } else { vec![TermLogger::new_from_config(term_config.clone()), WriteLogger::new(file_config, write_rotater)] }; CombinedLogger::init(combined_logs).ok().inspect(|()| { info!("Logging to file \"{}\" and terminal", cache_logs_path.to_string_lossy()); }) })(); if combined_logger.is_none() { let _ = TermLogger::init(term_config, TerminalMode::Mixed, ColorChoice::Always); info!("Logging to terminal only, file logging is disabled"); } } pub fn filtering_messages(record: &Record) -> bool { if let Some(module_path) = record.module_path() { // Printing not supported modules // if !["krokiet", "czkawka", "log_panics", "smithay_client_toolkit", "sctk_adwaita"] // .iter() // .any(|t| module_path.starts_with(t)) // { // println!("{:?}", module_path); // return true; // } else { // return false; // } ["krokiet", "czkawka", "log_panics"].iter().any(|t| module_path.starts_with(t)) } else { true } } #[allow(clippy::allow_attributes)] #[allow(unfulfilled_lint_expectations)] // Happens only on release build #[expect(clippy::vec_init_then_push)] #[expect(unused_mut)] pub fn print_version_mode(app: &str) { let rust_version = env!("RUST_VERSION_INTERNAL"); let debug_release = if cfg!(debug_assertions) { "debug" } else { "release" }; let processors = get_all_available_threads(); let info = os_info::get(); let mut features: Vec<&str> = Vec::new(); #[cfg(feature = "heif")] features.push("heif"); #[cfg(feature = "libavif")] features.push("libavif"); #[cfg(feature = "libraw")] features.push("libraw"); let mut app_cpu_version = "Baseline"; let mut os_cpu_version = "Baseline"; if cfg!(target_feature = "sse2") { app_cpu_version = "x86-64-v1 (SSE2)"; } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("sse2") { os_cpu_version = "x86-64-v1 (SSE2)"; } if cfg!(target_feature = "popcnt") { app_cpu_version = "x86-64-v2 (SSE4.2 + POPCNT)"; } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("popcnt") { os_cpu_version = "x86-64-v2 (SSE4.2 + POPCNT)"; } if cfg!(target_feature = "avx2") { app_cpu_version = "x86-64-v3 (AVX2)"; } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("avx2") { os_cpu_version = "x86-64-v3 (AVX2)"; } if cfg!(target_feature = "avx512f") { app_cpu_version = "x86-64-v4 (AVX-512)"; } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("avx512f") { os_cpu_version = "x86-64-v4 (AVX-512)"; } let musl_or_glibc = if cfg!(target_os = "linux") { let libc_versions_str = match glibc_musl_version::get_os_libc_versions() { Ok(libc_versions) => { let libc_versions_str = libc_versions.to_string(); match option_env!("CZKAWKA_LIBC_VERSIONS") { Some(env) if env == libc_versions_str => { format!(" [build + runtime ({libc_versions_str})]") } Some(env) => { format!(" [build ({env}), runtime ({libc_versions_str})]") } None => { format!(" [build (unknown), runtime ({libc_versions_str})]") } } } Err(e) => { warn!("Cannot get libc version: {e}"); "".to_string() } }; format!(", libc {}{libc_versions_str}", option_env!("CZKAWKA_LIBC").unwrap_or("unknown(cross-compilation?)")) } else { "".to_string() }; let git_commit = env!("CZKAWKA_GIT_COMMIT_SHORT"); let official_build = if env!("CZKAWKA_OFFICIAL_BUILD") == "1" { "O" // Official build } else { "U" // Unofficial build }; let git_date = env!("CZKAWKA_GIT_COMMIT_DATE"); info!( "{app} version: {CZKAWKA_VERSION}({git_commit} {official_build} {git_date}), {debug_release} mode, rust {rust_version}, os {} {} ({} {}), {processors} cpu/threads, features({}): [{}], app cpu version: {app_cpu_version}, os cpu version: {os_cpu_version}{musl_or_glibc}", info.os_type(), info.version(), env::consts::ARCH, info.bitness(), features.len(), features.join(", "), ); if cfg!(debug_assertions) { warn!("You are running debug version of app which is a lot of slower than release version."); } if option_env!("USING_CRANELIFT").is_some() { warn!("You are running app with cranelift which is intended only for fast compilation, not runtime performance."); } if cfg!(panic = "abort") { warn!("You are running app compiled with panic='abort', which may cause panics when processing untrusted data."); } } czkawka_core-11.0.1/src/common/mod.rs000064400000000000000000000746331046102023000155700ustar 00000000000000pub mod basic_gui_cli; pub mod cache; pub mod config_cache_path; pub mod consts; pub mod dir_traversal; pub mod directories; pub mod extensions; pub mod ffmpeg_utils; pub mod image; pub mod items; pub mod logger; pub mod model; pub mod process_utils; pub mod progress_data; pub mod progress_stop_handler; pub mod tool_data; pub mod traits; pub mod video_utils; use std::cmp::Ordering; use std::ffi::OsString; use std::io::Error; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; use std::{fs, io, thread}; use items::SingleExcludedItem; use log::debug; use crate::common::consts::DEFAULT_WORKER_THREAD_SIZE; use crate::flc; static NUMBER_OF_THREADS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(None)); static ALL_AVAILABLE_THREADS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(None)); const MAX_SYMLINK_HARDLINK_ATTEMPTS: u8 = 5; #[cfg(all(feature = "xdg_portal_trash", target_os = "linux"))] thread_local! { static TOKIO_RT: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } #[cfg(all(feature = "xdg_portal_trash", target_os = "linux"))] fn with_runtime(f: F) -> Result where F: FnOnce(&tokio::runtime::Runtime) -> Result, { TOKIO_RT.with(|cell| { let mut opt = cell.borrow_mut(); if opt.is_none() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|e| format!("Failed to build Tokio runtime: {e}")); *opt = Some(rt); } match opt.as_ref().expect("Tokio runtime is initialized before") { Ok(rt) => f(rt), Err(e) => Err(e.clone()), } }) } pub fn get_number_of_threads() -> usize { let data = NUMBER_OF_THREADS.lock().expect("Cannot fail").expect("Should be set before get"); if data >= 1 { data } else { get_all_available_threads() } } pub fn get_all_available_threads() -> usize { let mut available_threads = ALL_AVAILABLE_THREADS.lock().expect("Cannot fail"); if let Some(available_threads) = *available_threads { available_threads } else { let threads = thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1); *available_threads = Some(threads); threads } } pub fn set_number_of_threads(thread_number: usize) { *NUMBER_OF_THREADS.lock().expect("Cannot fail") = Some(thread_number); let additional_message = if thread_number == 0 { format!( " (0 - means that all available threads will be used({}))", thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1) ) } else { "".to_string() }; debug!("Number of threads set to {thread_number}{additional_message}"); rayon::ThreadPoolBuilder::new() .num_threads(get_number_of_threads()) .stack_size(DEFAULT_WORKER_THREAD_SIZE) .build_global() .expect("Cannot set number of threads"); } pub fn check_if_folder_contains_only_empty_folders>(path: P) -> Result<(), String> { let path = path.as_ref(); if !path.is_dir() { return Err(flc!("core_not_directory_remove", path = path.to_string_lossy())); } let mut entries_to_check = Vec::new(); let Ok(initial_entry) = path.read_dir() else { return Err(flc!("core_cannot_read_directory", path = path.to_string_lossy())); }; for entry in initial_entry { if let Ok(entry) = entry { entries_to_check.push(entry); } else { return Err(flc!("core_cannot_read_entry_from_directory", path = path.to_string_lossy())); } } loop { let Some(entry) = entries_to_check.pop() else { break; }; let Some(file_type) = entry.file_type().ok() else { return Err(flc!( "core_unknown_directory_entry", entry = entry.path().to_string_lossy().to_string(), path = path.to_string_lossy() )); }; if !file_type.is_dir() { return Err(flc!( "core_folder_contains_file_inside", entry = entry.path().to_string_lossy().to_string(), folder = path.to_string_lossy() )); } let Ok(internal_read_dir) = entry.path().read_dir() else { return Err(flc!("core_cannot_read_directory", path = path.to_string_lossy().to_string())); }; for internal_elements in internal_read_dir { if let Ok(internal_element) = internal_elements { entries_to_check.push(internal_element); } else { return Err(flc!("core_cannot_read_entry_from_directory", path = path.to_string_lossy().to_string())); } } } Ok(()) } /// A wrapper around `trash::delete`. Note that for platforms that do not have native trash support /// (Android, iOS), this function will always return an [`Error`]. When the `xdg_portal_trash` feature is /// enabled, the portal-based implementation will only be used on Linux; on other desktop OSes the /// regular `trash::delete` fallback will be used instead. fn trash_delete>(path: P) -> Result<(), String> { let path = path.as_ref(); #[cfg(not(any(target_os = "android", target_os = "ios", all(feature = "xdg_portal_trash", target_os = "linux"))))] { trash::delete(path).map_err(|err| err.to_string()) } #[cfg(all(feature = "xdg_portal_trash", target_os = "linux"))] { use std::os::fd::AsFd; let file = std::fs::OpenOptions::new().write(true).read(true).open(path).map_err(|err| err.to_string())?; with_runtime(|rt| rt.block_on(async move { ashpd::desktop::trash::trash_file(&file.as_fd()).await.map_err(|e| e.to_string()) }))?; Ok(()) } #[cfg(any(target_os = "android", target_os = "ios"))] { let _path = path; Err("trash is not supported on this platform".to_string()) } } /// Remove the folder if it only contains empty folders/is empty. If `remove_to_trash` is set, the folder /// will instead be sent to the system's recycle bin/trash equivalent rather than being deleted. /// /// Note: if used on Android or iOS platforms, ensure `remove_to_trash` is false, as trash is not supported /// and will always return an [`Error`]. pub fn remove_folder_if_contains_only_empty_folders>(path: P, remove_to_trash: bool) -> Result<(), String> { check_if_folder_contains_only_empty_folders(&path)?; let path = path.as_ref(); if remove_to_trash { trash_delete(path).map_err(|e| format!("Cannot move folder \"{}\" to trash, reason {e}", path.to_string_lossy())) } else { fs::remove_dir_all(path).map_err(|e| format!("Cannot remove directory \"{}\", reason {e}", path.to_string_lossy())) } } /// Remove a single file. If `remove_to_trash` is set, the folder will instead be sent to the system's /// recycle bin/trash equivalent rather than being deleted. /// /// Note: if used on Android or iOS platforms, ensure `remove_to_trash` is false, as trash is not supported /// and will always return an [`Error`]. pub fn remove_single_file>(full_path: P, remove_to_trash: bool) -> Result<(), String> { if remove_to_trash { if let Err(e) = trash_delete(&full_path) { return Err(flc!("core_error_moving_to_trash", file = full_path.as_ref().to_string_lossy().to_string(), error = e)); } } else { if let Err(e) = fs::remove_file(&full_path) { return Err(flc!("core_error_removing", file = full_path.as_ref().to_string_lossy().to_string(), error = e.to_string())); } } Ok(()) } /// Remove a single folder recursively. If `remove_to_trash` is set, the folder will instead be sent to the system's /// recycle bin/trash equivalent rather than being deleted. /// /// Note: if used on Android or iOS platforms, ensure `remove_to_trash` is false, as trash is not supported /// and will always return an [`Error`]. pub fn remove_single_folder(full_path: &str, remove_to_trash: bool) -> Result<(), String> { if remove_to_trash { if let Err(e) = trash_delete(full_path) { return Err(flc!("core_error_moving_to_trash", file = full_path, error = e)); } } else { if let Err(e) = fs::remove_dir_all(full_path) { return Err(flc!("core_error_removing", file = full_path, error = e.to_string())); } } Ok(()) } pub fn split_path(path: &Path) -> (String, String) { match (path.parent(), path.file_name()) { (Some(dir), Some(file)) => (dir.to_string_lossy().to_string(), file.to_string_lossy().into_owned()), (Some(dir), None) => (dir.to_string_lossy().to_string(), String::new()), (None, _) => (String::new(), String::new()), } } pub fn split_path_compare(path_a: &Path, path_b: &Path) -> Ordering { match path_a.parent().cmp(&path_b.parent()) { Ordering::Equal => path_a.file_name().cmp(&path_b.file_name()), other => other, } } pub fn format_time(duration: Duration) -> String { let hours = duration.as_secs() / 3600; let minutes = duration.as_secs() % 3600 / 60; let secs = duration.as_secs() % 60; let millis = duration.subsec_millis(); if hours == 0 && minutes == 0 && secs == 0 { format!("{millis}ms") } else if hours == 0 && minutes == 0 { if millis / 10 == 0 { format!("{secs}s") } else { format!("{secs}.{:02}s", millis / 10) } } else if hours == 0 { if secs == 0 { format!("{minutes}m") } else { format!("{minutes}m {secs}s") } } else { if secs == 0 && minutes == 0 { format!("{hours}h") } else if secs == 0 { format!("{hours}h {minutes}m") } else { format!("{hours}h {minutes}m {secs}s") } } } pub(crate) fn create_crash_message(library_name: &str, file_path: &str, home_library_url: &str) -> String { format!( "{library_name} library crashed when opening \"{file_path}\", please check if this is fixed with the latest version of {library_name} and if it is not fixed, please report bug here - {home_library_url}" ) } #[expect(clippy::string_slice)] #[expect(clippy::indexing_slicing)] pub fn regex_check(expression_item: &SingleExcludedItem, directory_name: &str) -> bool { if expression_item.expression_splits.is_empty() { return true; } // Early checking if directory contains all parts needed by expression for split in &expression_item.unique_extensions_splits { if !directory_name.contains(split) { return false; } } // `git*` shouldn't be true for `/gitsfafasfs` if !expression_item.expression.starts_with('*') && directory_name .find(&expression_item.expression_splits[0]) .expect("Cannot fail, because split must exists in directory_name") > 0 { return false; } // `*home` shouldn't be true for `/homeowner` if !expression_item.expression.ends_with('*') && !directory_name.ends_with(expression_item.expression_splits.last().expect("Cannot fail, because at least one item is available")) { return false; } // At the end we check if parts between * are correctly positioned let mut last_split_point = directory_name.find(&expression_item.expression_splits[0]).expect("Cannot fail, because is checked earlier"); let mut current_index: usize = 0; let mut found_index: usize; for spl in &expression_item.expression_splits[1..] { found_index = match directory_name[current_index..].find(spl) { Some(t) => t, None => return false, }; current_index = last_split_point + spl.len(); last_split_point = found_index + current_index; } true } #[expect(clippy::string_slice)] // Is in char boundary pub fn normalize_windows_path>(path_to_change: P) -> PathBuf { let path = path_to_change.as_ref(); // Don't do anything, because network path may be case intensive if path.to_string_lossy().starts_with('\\') { return path.to_path_buf(); } match path.to_str() { Some(path) if path.is_char_boundary(1) => { let replaced = path.replace('/', "\\"); let mut new_path = OsString::new(); if replaced[1..].starts_with(':') { new_path.push(replaced[..1].to_ascii_uppercase()); new_path.push(replaced[1..].to_ascii_lowercase()); } else { new_path.push(replaced.to_ascii_lowercase()); } PathBuf::from(new_path) } _ => path.to_path_buf(), } } // Function to create hardlink, when destination exists // This is always true in this app, because creating hardlink, to newly created file is pointless pub fn make_hard_link, Q: AsRef>(src: P, dst: Q) -> io::Result<()> { let src = src.as_ref(); let dst = dst.as_ref(); let dst_dir = dst.parent().ok_or_else(|| Error::other("No parent"))?; let mut temp; let mut attempts = MAX_SYMLINK_HARDLINK_ATTEMPTS; loop { temp = dst_dir.join(format!("{}.czkawka_tmp", rand::random::())); if !temp.exists() { break; } attempts -= 1; if attempts == 0 { return Err(Error::other("Cannot choose temporary file for hardlink creation")); } } fs::rename(dst, temp.as_path())?; match fs::hard_link(src, dst) { Ok(()) => { fs::remove_file(&temp)?; Ok(()) } Err(e) => { let _ = fs::rename(&temp, dst); Err(e) } } } #[cfg(any(target_family = "unix", target_family = "windows"))] pub fn make_file_symlink, Q: AsRef>(src: P, dst: Q) -> io::Result<()> { let src = src.as_ref(); let dst = dst.as_ref(); let dst_dir = dst.parent().ok_or_else(|| Error::other("No parent"))?; let mut temp; let mut attempts = MAX_SYMLINK_HARDLINK_ATTEMPTS; loop { temp = dst_dir.join(format!("{}.czkawka_tmp", rand::random::())); if !temp.exists() { break; } attempts -= 1; if attempts == 0 { return Err(Error::other("Cannot choose temporary file for symlink creation")); } } fs::rename(dst, temp.as_path())?; let result: Result<_, _>; #[cfg(target_family = "unix")] { result = std::os::unix::fs::symlink(src, dst); } #[cfg(target_family = "windows")] { result = std::os::windows::fs::symlink_file(src, dst); } match result { Ok(()) => { fs::remove_file(&temp)?; Ok(()) } Err(e) => { let _ = fs::rename(&temp, dst); Err(e) } } } #[cfg(not(any(target_family = "unix", target_family = "windows")))] pub fn make_file_symlink, Q: AsRef>(src: P, dst: Q) -> io::Result<()> { Err(Error::new(io::ErrorKind::Other, "Soft links are not supported on this platform")) } pub fn debug_save_file(path: &str, data: &str) { use std::io::Write; if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(f, "{data}"); } } #[cfg(test)] mod test { use std::fs::{File, Metadata, read_dir}; use std::io::Write; #[cfg(target_family = "unix")] use std::os::unix::fs::MetadataExt; use tempfile::tempdir; use super::*; use crate::common::items::new_excluded_item; #[cfg(target_family = "unix")] fn assert_inode(before: &Metadata, after: &Metadata) { assert_eq!(before.ino(), after.ino()); } #[cfg(target_family = "windows")] fn assert_inode(_: &Metadata, _: &Metadata) {} #[cfg(target_family = "unix")] fn assert_different_inode(before: &Metadata, after: &Metadata) { assert_ne!(before.ino(), after.ino()); } #[cfg(target_family = "windows")] fn assert_different_inode(_before: &Metadata, _after: &Metadata) {} #[test] fn test_make_hard_link() -> io::Result<()> { // Test 1: Basic hardlink creation { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); File::create(&src)?; let metadata = fs::metadata(&src)?; File::create(&dst)?; let dst_metadata_before = fs::metadata(&dst)?; assert_different_inode(&metadata, &dst_metadata_before); make_hard_link(&src, &dst)?; make_hard_link(&src, &dst)?; assert_inode(&metadata, &fs::metadata(&dst)?); assert_eq!(metadata.permissions(), fs::metadata(&dst)?.permissions()); assert_eq!(metadata.modified()?, fs::metadata(&dst)?.modified()?); assert_inode(&metadata, &fs::metadata(&src)?); assert_eq!(metadata.permissions(), fs::metadata(&src)?.permissions()); assert_eq!(metadata.modified()?, fs::metadata(&src)?.modified()?); let mut actual = read_dir(&dir)?.flatten().map(|e| e.path()).collect::>(); actual.sort_unstable(); assert_eq!(vec![src, dst], actual); } // Test 2: Hardlink creation fails when source doesn't exist { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); File::create(&dst)?; let metadata = fs::metadata(&dst)?; assert!(make_hard_link(&src, &dst).is_err()); assert_inode(&metadata, &fs::metadata(&dst)?); assert_eq!(metadata.permissions(), fs::metadata(&dst)?.permissions()); assert_eq!(metadata.modified()?, fs::metadata(&dst)?.modified()?); assert_eq!(vec![dst], read_dir(&dir)?.flatten().map(|e| e.path()).collect::>()); } // Test 3: Hardlink with content preservation { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("src_file"), dir.path().join("dst_file")); let content = "test content for hardlink"; { let mut f = File::create(&src)?; writeln!(f, "{content}")?; } { let mut f = File::create(&dst)?; writeln!(f, "old content")?; } let src_metadata = fs::metadata(&src)?; let dst_metadata_before = fs::metadata(&dst)?; assert_different_inode(&src_metadata, &dst_metadata_before); make_hard_link(&src, &dst)?; let src_content = fs::read_to_string(&src)?; let dst_content = fs::read_to_string(&dst)?; assert_eq!(src_content, dst_content); assert_eq!(src_content, format!("{content}\n")); assert_inode(&src_metadata, &fs::metadata(&dst)?); } // Test 4: Hardlink on readonly file #[cfg(target_family = "unix")] { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("readonly_src"), dir.path().join("readonly_dst")); { let mut f = File::create(&src)?; writeln!(f, "readonly content")?; } let mut perms = fs::metadata(&src)?.permissions(); perms.set_readonly(true); fs::set_permissions(&src, perms)?; assert!(fs::metadata(&src)?.permissions().readonly()); { let mut f = File::create(&dst)?; writeln!(f, "dst content")?; } let src_metadata_before = fs::metadata(&src)?; let dst_metadata_before = fs::metadata(&dst)?; assert_different_inode(&src_metadata_before, &dst_metadata_before); make_hard_link(&src, &dst).unwrap(); assert_inode(&src_metadata_before, &fs::metadata(&dst)?); assert_eq!(fs::read_to_string(&src)?, fs::read_to_string(&dst)?); assert!(fs::metadata(&src)?.permissions().readonly()); assert!(fs::metadata(&dst)?.permissions().readonly()); } // Test 5: Hardlink on readonly destination file #[cfg(target_family = "unix")] { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("src_normal"), dir.path().join("dst_readonly")); { let mut f = File::create(&src)?; writeln!(f, "source content")?; } { let mut f = File::create(&dst)?; writeln!(f, "destination content")?; } let mut perms = fs::metadata(&dst)?.permissions(); perms.set_readonly(true); fs::set_permissions(&dst, perms)?; assert!(fs::metadata(&dst)?.permissions().readonly()); let src_metadata = fs::metadata(&src)?; let dst_metadata_before = fs::metadata(&dst)?; assert_different_inode(&src_metadata, &dst_metadata_before); make_hard_link(&src, &dst).unwrap(); assert_inode(&src_metadata, &fs::metadata(&dst)?); assert_eq!(fs::read_to_string(&src)?, fs::read_to_string(&dst)?); } // Test 6: Hardlink when destination doesn't exist - should fail { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("src"), dir.path().join("nonexistent")); File::create(&src)?; let result = make_hard_link(&src, &dst); assert!(result.is_err(), "Should fail when destination doesn't exist"); } // Test 7: Hardlink preserves file size { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("large_src"), dir.path().join("large_dst")); let large_content = "x".repeat(10000); { let mut f = File::create(&src)?; write!(f, "{large_content}")?; } File::create(&dst)?; let src_size = fs::metadata(&src)?.len(); let src_metadata = fs::metadata(&src)?; let dst_metadata_before = fs::metadata(&dst)?; assert_different_inode(&src_metadata, &dst_metadata_before); make_hard_link(&src, &dst)?; assert_eq!(src_size, fs::metadata(&dst)?.len()); assert_eq!(large_content, fs::read_to_string(&dst)?); } // Test 8: Multiple hardlinks to same file { let dir = tempfile::Builder::new().tempdir()?; let src = dir.path().join("original"); let dst1 = dir.path().join("link1"); let dst2 = dir.path().join("link2"); { let mut f = File::create(&src)?; writeln!(f, "original")?; } File::create(&dst1)?; File::create(&dst2)?; let src_metadata = fs::metadata(&src)?; let dst1_metadata_before = fs::metadata(&dst1)?; let dst2_metadata_before = fs::metadata(&dst2)?; // Before hardlinks - all files should have different inodes assert_different_inode(&src_metadata, &dst1_metadata_before); assert_different_inode(&src_metadata, &dst2_metadata_before); assert_different_inode(&dst1_metadata_before, &dst2_metadata_before); make_hard_link(&src, &dst1)?; make_hard_link(&src, &dst2)?; assert_inode(&src_metadata, &fs::metadata(&dst1)?); assert_inode(&src_metadata, &fs::metadata(&dst2)?); } Ok(()) } // Windows needs super user permissions #[cfg(target_family = "unix")] #[test] fn test_make_file_symlink() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); let content = "hello softlink"; { let mut f = File::create(&src)?; writeln!(f, "{content}")?; } File::create(&dst)?; make_file_symlink(&src, &dst)?; let symlink_meta = fs::symlink_metadata(&dst)?; assert!(symlink_meta.file_type().is_symlink()); let src_content = fs::read_to_string(&src)?; let dst_content = fs::read_to_string(&dst)?; assert_eq!(src_content, dst_content); let mut actual = read_dir(&dir)?.flatten().map(|e| e.path()).collect::>(); actual.sort_unstable(); assert_eq!(vec![src, dst], actual); Ok(()) } #[cfg(target_family = "unix")] #[test] fn test_make_file_symlink_fails() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); { let mut f = File::create(&dst)?; writeln!(f, "original")?; } let metadata = fs::metadata(&dst)?; match make_file_symlink(&src, &dst) { Err(_) => { assert_eq!(fs::read_to_string(&dst)?, "original\n"); assert_eq!(metadata.permissions(), fs::metadata(&dst)?.permissions()); } Ok(()) => { let symlink_meta = fs::symlink_metadata(&dst)?; assert!(symlink_meta.file_type().is_symlink()); fs::read_to_string(&dst).unwrap_err(); } } Ok(()) } #[test] fn test_remove_folder_if_contains_only_empty_folders() { let dir = tempdir().expect("Cannot create temporary directory"); let sub_dir = dir.path().join("sub_dir"); fs::create_dir(&sub_dir).expect("Cannot create directory"); // Test with empty directory remove_folder_if_contains_only_empty_folders(&sub_dir, false).unwrap(); assert!(!Path::new(&sub_dir).exists()); // Test with directory containing an empty directory fs::create_dir(&sub_dir).expect("Cannot create directory"); fs::create_dir(sub_dir.join("empty_sub_dir")).expect("Cannot create directory"); remove_folder_if_contains_only_empty_folders(&sub_dir, false).unwrap(); assert!(!Path::new(&sub_dir).exists()); // Test with directory containing a file fs::create_dir(&sub_dir).expect("Cannot create directory"); let mut file = File::create(sub_dir.join("file.txt")).expect("Cannot create file"); writeln!(file, "Hello, world!").expect("Cannot write to file"); assert!(remove_folder_if_contains_only_empty_folders(&sub_dir, false).is_err()); assert!(Path::new(&sub_dir).exists()); } #[test] fn test_regex() { assert!(regex_check(&new_excluded_item("*"), "/home/rafal")); assert!(regex_check(&new_excluded_item("*home*"), "/home/rafal")); assert!(regex_check(&new_excluded_item("*home"), "/home")); assert!(regex_check(&new_excluded_item("*home/"), "/home/")); assert!(regex_check(&new_excluded_item("*home/*"), "/home/")); assert!(regex_check(&new_excluded_item("*.git*"), "/home/.git")); assert!(regex_check(&new_excluded_item("/home/*/.*"), "/home/user/.random")); assert!(regex_check(&new_excluded_item("*/home/rafal*rafal*rafal*rafal*"), "/home/rafal/rafalrafalrafal")); assert!(regex_check(&new_excluded_item("AAA"), "AAA")); assert!(regex_check(&new_excluded_item("AAA*"), "AAABDGG/QQPW*")); assert!(!regex_check(&new_excluded_item("*home"), "/home/")); assert!(!regex_check(&new_excluded_item("*home"), "/homefasfasfasfasf/")); assert!(!regex_check(&new_excluded_item("*home"), "/homefasfasfasfasf")); assert!(!regex_check(&new_excluded_item("rafal*afal*fal"), "rafal")); assert!(!regex_check(&new_excluded_item("rafal*a"), "rafal")); assert!(!regex_check(&new_excluded_item("AAAAAAAA****"), "/AAAAAAAAAAAAAAAAA")); assert!(!regex_check(&new_excluded_item("*.git/*"), "/home/.git")); assert!(!regex_check(&new_excluded_item("*home/*koc"), "/koc/home/")); assert!(!regex_check(&new_excluded_item("*home/"), "/home")); assert!(!regex_check(&new_excluded_item("*TTT"), "/GGG")); assert!(regex_check( &new_excluded_item("*/home/*/.local/share/containers"), "/var/home/roman/.local/share/containers" )); if cfg!(target_family = "windows") { assert!(regex_check(&new_excluded_item("*\\home"), "C:\\home")); } } #[test] fn test_windows_path() { assert_eq!(PathBuf::from("C:\\path.txt"), normalize_windows_path("c:/PATH.tXt")); assert_eq!(PathBuf::from("H:\\reka\\weza\\roman.txt"), normalize_windows_path("h:/RekA/Weza\\roMan.Txt")); assert_eq!(PathBuf::from("T:\\a"), normalize_windows_path("T:\\A")); assert_eq!(PathBuf::from("\\\\aBBa"), normalize_windows_path("\\\\aBBa")); assert_eq!(PathBuf::from("a"), normalize_windows_path("a")); assert_eq!(PathBuf::from(""), normalize_windows_path("")); } #[test] fn test_format_time() { assert_eq!(format_time(Duration::from_millis(0)), "0ms"); assert_eq!(format_time(Duration::from_millis(1)), "1ms"); assert_eq!(format_time(Duration::from_millis(999)), "999ms"); assert_eq!(format_time(Duration::from_millis(1000)), "1s"); assert_eq!(format_time(Duration::from_millis(1234)), "1.23s"); assert_eq!(format_time(Duration::from_millis(5678)), "5.67s"); assert_eq!(format_time(Duration::from_secs(59)), "59s"); assert_eq!(format_time(Duration::from_secs(60)), "1m"); assert_eq!(format_time(Duration::from_secs(61)), "1m 1s"); assert_eq!(format_time(Duration::from_millis(61234)), "1m 1s"); assert_eq!(format_time(Duration::from_secs(125)), "2m 5s"); assert_eq!(format_time(Duration::from_secs(3599)), "59m 59s"); assert_eq!(format_time(Duration::from_secs(3600)), "1h"); assert_eq!(format_time(Duration::from_secs(3661)), "1h 1m 1s"); assert_eq!(format_time(Duration::from_secs(7384)), "2h 3m 4s"); assert_eq!(format_time(Duration::from_secs(86400)), "24h"); assert_eq!(format_time(Duration::from_millis(999)), "999ms"); assert_eq!(format_time(Duration::from_millis(1001)), "1s"); assert_eq!(format_time(Duration::from_millis(59999)), "59.99s"); assert_eq!(format_time(Duration::from_millis(60000)), "1m"); assert_eq!(format_time(Duration::from_millis(60100)), "1m"); assert_eq!(format_time(Duration::from_millis(120000)), "2m"); } } czkawka_core-11.0.1/src/common/model.rs000064400000000000000000000061251046102023000161000ustar 00000000000000use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use xxhash_rust::xxh3::Xxh3; use crate::common::traits::ResultEntry; use crate::tools::duplicate::MyHasher; #[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] pub enum ToolType { Duplicate, EmptyFolders, EmptyFiles, InvalidSymlinks, BrokenFiles, BadExtensions, BadNames, BigFile, SameMusic, SimilarImages, SimilarVideos, TemporaryFiles, ExifRemover, VideoOptimizer, #[default] None, } impl ToolType { pub fn may_use_reference_paths(self) -> bool { matches!(self, Self::Duplicate | Self::SameMusic | Self::SimilarImages | Self::SimilarVideos) } } #[derive(PartialEq, Eq, Clone, Debug, Copy, Default, Deserialize, Serialize)] pub enum CheckingMethod { #[default] None, Name, SizeName, Size, Hash, AudioTags, AudioContent, } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct FileEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, } impl ResultEntry for FileEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } #[derive(PartialEq, Eq, Clone, Debug, Copy, Default)] pub enum HashType { #[default] Blake3, Crc32, Xxh3, } impl HashType { pub(crate) fn hasher(self) -> Box { match self { Self::Blake3 => Box::new(blake3::Hasher::new()), Self::Crc32 => Box::new(crc32fast::Hasher::new()), Self::Xxh3 => Box::new(Xxh3::new()), } } } #[derive(Debug, PartialEq)] pub enum WorkContinueStatus { Continue, Stop, } #[cfg(test)] mod tests { use super::*; #[test] fn test_file_entry_basic_operations() { let entry = FileEntry { path: PathBuf::from("/test/file.txt"), size: 1024, modified_date: 123456, }; assert_eq!(entry.get_path(), Path::new("/test/file.txt")); assert_eq!(entry.get_size(), 1024); assert_eq!(entry.get_modified_date(), 123456); let entry2 = entry.clone(); assert_eq!(entry, entry2); } #[test] fn test_hash_type_creates_hashers() { let blake3_hasher = HashType::Blake3.hasher(); let crc32_hasher = HashType::Crc32.hasher(); let xxh3_hasher = HashType::Xxh3.hasher(); // Just verify they can be created assert!(std::mem::size_of_val(&blake3_hasher) > 0); assert!(std::mem::size_of_val(&crc32_hasher) > 0); assert!(std::mem::size_of_val(&xxh3_hasher) > 0); } #[test] fn test_checking_method_default() { assert_eq!(CheckingMethod::default(), CheckingMethod::None); } #[test] fn test_tool_type_default() { assert_eq!(ToolType::default(), ToolType::None); } #[test] fn test_delete_method_default() { use crate::common::tool_data::DeleteMethod; assert_eq!(DeleteMethod::default(), DeleteMethod::None); } } czkawka_core-11.0.1/src/common/process_utils.rs000064400000000000000000000117401046102023000176750ustar 00000000000000use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use log::{error, warn}; use crate::flc; #[expect(clippy::needless_pass_by_ref_mut)] pub fn disable_windows_console_window(command: &mut Command) { #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; command.creation_flags(CREATE_NO_WINDOW); } #[cfg(not(target_os = "windows"))] { let _ = command; } } pub struct CommandOutput { pub status: std::process::ExitStatus, pub stdout: String, pub stderr: String, } // Remember - Ok returned by this function does not necessarily mean that the command executed successfully // it only means that the command was executed and its output was captured. // The actual success of the command should be determined by checking the `status` field of the returned `CommandOutput`. pub fn run_command_interruptible(mut command: Command, stop_flag: &Arc) -> Option> { if stop_flag.load(Ordering::Relaxed) { return None; } disable_windows_console_window(&mut command); command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = match command.spawn() { Ok(c) => c, Err(e) => return Some(Err(flc!("core_failed_to_spawn_command", reason = e.to_string()))), }; let Some(mut stdout) = child.stdout.take() else { error!("Failed to take stdout from child process"); return Some(Err("Failed to take stdout from child process".to_string())); }; let Some(mut stderr) = child.stderr.take() else { error!("Failed to take stderr from child process"); return Some(Err("Failed to take stderr from child process".to_string())); }; let stdout_buf = Arc::new(Mutex::new(Vec::new())); let stderr_buf = Arc::new(Mutex::new(Vec::new())); let out_buf = stdout_buf.clone(); let err_buf = stderr_buf.clone(); let out_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = std::io::copy(&mut stdout, &mut buf); match out_buf.lock() { Ok(mut lock) => *lock = buf, Err(e) => error!("Failed to lock stdout buffer: {e}"), } }); let err_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = std::io::copy(&mut stderr, &mut buf); match err_buf.lock() { Ok(mut lock) => *lock = buf, Err(e) => error!("Failed to lock stderr buffer: {e}"), } }); let start_time = Instant::now(); let warning_steps = [50, 250, 1250, 6000]; let mut next_warning_idx = 0; loop { if stop_flag.load(Ordering::Relaxed) { let _ = child.kill(); let _ = child.wait(); break; } let elapsed_secs = start_time.elapsed().as_secs(); if let Some(warning_time) = warning_steps.get(next_warning_idx) && elapsed_secs >= *warning_time { warn!("Command is still running after {warning_time} seconds, for command: {command:?}"); next_warning_idx += 1; } match child.try_wait() { Ok(Some(_)) => break, Ok(None) => thread::sleep(Duration::from_millis(100)), Err(e) => return Some(Err(flc!("core_failed_to_check_process_status", reason = e.to_string()))), } } let status = match child.wait() { Ok(s) => s, Err(e) => return Some(Err(flc!("core_failed_to_wait_for_process", reason = e.to_string()))), }; let _ = out_handle.join(); let _ = err_handle.join(); if stop_flag.load(Ordering::Relaxed) { return None; } let stdout = match Arc::try_unwrap(stdout_buf) { Ok(mutex) => match mutex.into_inner() { Ok(buf) => buf, Err(e) => { error!("Failed to get stdout inner buffer: {e}"); return Some(Err("Failed to get stdout inner buffer".to_string())); } }, Err(_) => { error!("Failed to unwrap stdout Arc - multiple references still exist"); return Some(Err("Failed to unwrap stdout Arc".to_string())); } }; let stderr = match Arc::try_unwrap(stderr_buf) { Ok(mutex) => match mutex.into_inner() { Ok(buf) => buf, Err(e) => { error!("Failed to get stderr inner buffer: {e}"); return Some(Err("Failed to get stderr inner buffer".to_string())); } }, Err(_) => { error!("Failed to unwrap stderr Arc - multiple references still exist"); return Some(Err("Failed to unwrap stderr Arc".to_string())); } }; Some(Ok(CommandOutput { status, stdout: String::from_utf8_lossy(&stdout).to_string(), stderr: String::from_utf8_lossy(&stderr).to_string(), })) } czkawka_core-11.0.1/src/common/progress_data.rs000064400000000000000000000337731046102023000176460ustar 00000000000000use log::error; use crate::common::model::{CheckingMethod, ToolType}; // Empty files // 0 - Collecting files // Empty folders // 0 - Collecting folders // Big files // 0 - Collecting files // Same music // 0 - Collecting files // 1 - Loading cache // 2 - Checking tags // 3 - Saving cache // 4 - TAGS - Comparing tags // 4 - CONTENT - Loading cache // 5 - CONTENT - Calculating fingerprints // 6 - CONTENT - Saving cache // 7 - CONTENT - Comparing fingerprints // Similar images // 0 - Collecting files // 1 - Scanning images // 2 - Comparing hashes // Similar videos // 0 - Collecting files // 1 - Scanning videos // 2 - Creating thumbnails // Temporary files // 0 - Collecting files // Invalid symlinks // 0 - Collecting files // Broken files // 0 - Collecting files // 1 - Scanning files // Bad extensions // 0 - Collecting files // 1 - Scanning files // Exif Remover // 0 - Collecting files // 1 - Loading cache // 2 - Extracting tags // 3 - Saving cache // Duplicates - Hash // 0 - Collecting files // 1 - Loading cache // 2 - Hash - first 1KB file // 3 - Saving cache // 4 - Loading cache // 5 - Hash - normal hash // 6 - Saving cache // Duplicates - Name or SizeName or Size // 0 - Collecting files // Deleting files // Renaming files #[derive(Debug, Clone, Copy)] pub struct ProgressData { pub sstage: CurrentStage, pub checking_method: CheckingMethod, pub current_stage_idx: u8, pub max_stage_idx: u8, pub entries_checked: usize, pub entries_to_check: usize, pub bytes_checked: u64, pub bytes_to_check: u64, pub tool_type: ToolType, } impl ProgressData { pub fn get_empty_state(current_stage: CurrentStage) -> Self { Self { sstage: current_stage, checking_method: CheckingMethod::None, current_stage_idx: 0, max_stage_idx: 0, entries_checked: 0, entries_to_check: 0, bytes_checked: 0, bytes_to_check: 0, tool_type: ToolType::None, } } } #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum CurrentStage { DeletingFiles, RenamingFiles, MovingFiles, HardlinkingFiles, SymlinkingFiles, OptimizingVideos, CleaningExif, CollectingFiles, DuplicateCacheSaving, DuplicateCacheLoading, DuplicatePreHashCacheSaving, DuplicatePreHashCacheLoading, DuplicateScanningName, DuplicateScanningSizeName, DuplicateScanningSize, DuplicatePreHashing, DuplicateFullHashing, SameMusicCacheSavingTags, SameMusicCacheLoadingTags, SameMusicCacheSavingFingerprints, SameMusicCacheLoadingFingerprints, SameMusicReadingTags, SameMusicCalculatingFingerprints, SameMusicComparingTags, SameMusicComparingFingerprints, SimilarImagesCalculatingHashes, SimilarImagesComparingHashes, SimilarVideosCalculatingHashes, SimilarVideosCreatingThumbnails, BrokenFilesChecking, BadExtensionsChecking, BadNamesChecking, ExifRemoverCacheLoading, ExifRemoverExtractingTags, ExifRemoverCacheSaving, VideoOptimizerCreatingThumbnails, VideoOptimizerProcessingVideos, } impl ProgressData { pub(crate) fn validate(&self) { assert!( self.current_stage_idx <= self.max_stage_idx, "Current stage index: {}, max stage index: {}, stage {:?}", self.current_stage_idx, self.max_stage_idx, self.sstage ); assert_eq!( self.max_stage_idx, self.tool_type.get_max_stage(self.checking_method), "Max stage index: {}, tool type: {:?}, checking method: {:?}", self.max_stage_idx, self.tool_type, self.checking_method ); if self.sstage != CurrentStage::CollectingFiles { assert!( self.entries_checked <= self.entries_to_check, "Entries checked: {}, entries to check: {}, stage {:?}", self.entries_checked, self.entries_to_check, self.sstage ); } // This could be an assert, but it is possible that in duplicate finder, file that will // be checked, will increase the size of the file between collecting file to scan and // scanning it. So it is better to just log it if self.bytes_checked > self.bytes_to_check { error!("Bytes checked: {}, bytes to check: {}, stage {:?}", self.bytes_checked, self.bytes_to_check, self.sstage); } let tool_type_checking_method: Option = match self.checking_method { CheckingMethod::AudioTags | CheckingMethod::AudioContent => Some(ToolType::SameMusic), CheckingMethod::Name | CheckingMethod::SizeName | CheckingMethod::Size | CheckingMethod::Hash => Some(ToolType::Duplicate), CheckingMethod::None => None, }; if let Some(tool_type) = tool_type_checking_method { assert_eq!(self.tool_type, tool_type, "Tool type: {:?}, checking method: {:?}", self.tool_type, self.checking_method); } let tool_type_current_stage: Option = match self.sstage { CurrentStage::CollectingFiles | CurrentStage::DeletingFiles | CurrentStage::RenamingFiles | CurrentStage::MovingFiles | CurrentStage::HardlinkingFiles | CurrentStage::SymlinkingFiles | CurrentStage::OptimizingVideos | CurrentStage::CleaningExif => None, CurrentStage::DuplicateCacheSaving | CurrentStage::DuplicateCacheLoading | CurrentStage::DuplicatePreHashCacheSaving | CurrentStage::DuplicatePreHashCacheLoading => { Some(ToolType::Duplicate) } CurrentStage::DuplicateScanningName | CurrentStage::DuplicateScanningSizeName | CurrentStage::DuplicateScanningSize | CurrentStage::DuplicatePreHashing | CurrentStage::DuplicateFullHashing => Some(ToolType::Duplicate), CurrentStage::SameMusicCacheLoadingTags | CurrentStage::SameMusicCacheSavingTags | CurrentStage::SameMusicCacheLoadingFingerprints | CurrentStage::SameMusicCacheSavingFingerprints | CurrentStage::SameMusicComparingTags | CurrentStage::SameMusicReadingTags | CurrentStage::SameMusicComparingFingerprints | CurrentStage::SameMusicCalculatingFingerprints => Some(ToolType::SameMusic), CurrentStage::SimilarImagesCalculatingHashes | CurrentStage::SimilarImagesComparingHashes => Some(ToolType::SimilarImages), CurrentStage::SimilarVideosCalculatingHashes | CurrentStage::SimilarVideosCreatingThumbnails => Some(ToolType::SimilarVideos), CurrentStage::BrokenFilesChecking => Some(ToolType::BrokenFiles), CurrentStage::BadExtensionsChecking => Some(ToolType::BadExtensions), CurrentStage::BadNamesChecking => Some(ToolType::BadNames), CurrentStage::ExifRemoverCacheLoading | CurrentStage::ExifRemoverExtractingTags | CurrentStage::ExifRemoverCacheSaving => Some(ToolType::ExifRemover), CurrentStage::VideoOptimizerCreatingThumbnails | CurrentStage::VideoOptimizerProcessingVideos => Some(ToolType::VideoOptimizer), }; if let Some(tool_type) = tool_type_current_stage { assert_eq!(self.tool_type, tool_type, "Tool type: {:?}, stage {:?}", self.tool_type, self.sstage); } } } impl ToolType { pub(crate) fn get_max_stage(self, checking_method: CheckingMethod) -> u8 { match self { Self::Duplicate => 6, Self::EmptyFolders | Self::EmptyFiles | Self::InvalidSymlinks | Self::BigFile | Self::TemporaryFiles => 0, Self::BrokenFiles | Self::BadExtensions | Self::BadNames => 1, Self::SimilarImages | Self::SimilarVideos | Self::VideoOptimizer => 2, Self::ExifRemover => 3, Self::None => unreachable!("ToolType::None is not allowed"), Self::SameMusic => match checking_method { CheckingMethod::AudioTags => 4, CheckingMethod::AudioContent => 7, _ => unreachable!("CheckingMethod {checking_method:?} in same music mode is not allowed"), }, } } } impl CurrentStage { pub fn is_special_non_tool_stage(self) -> bool { matches!( self, Self::DeletingFiles | Self::RenamingFiles | Self::MovingFiles | Self::HardlinkingFiles | Self::SymlinkingFiles | Self::OptimizingVideos | Self::CleaningExif ) } pub fn get_current_stage(self) -> u8 { #[expect(clippy::match_same_arms)] // Now it is easier to read match self { Self::DeletingFiles => 0, Self::RenamingFiles => 0, Self::MovingFiles => 0, Self::HardlinkingFiles => 0, Self::SymlinkingFiles => 0, Self::OptimizingVideos => 0, Self::CleaningExif => 0, Self::CollectingFiles => 0, Self::DuplicateScanningName => 0, Self::DuplicateScanningSizeName => 0, Self::DuplicateScanningSize => 0, Self::DuplicatePreHashCacheLoading => 1, Self::DuplicatePreHashing => 2, Self::DuplicatePreHashCacheSaving => 3, Self::DuplicateCacheLoading => 4, Self::DuplicateFullHashing => 5, Self::DuplicateCacheSaving => 6, Self::SimilarImagesCalculatingHashes => 1, Self::SimilarImagesComparingHashes => 2, Self::SimilarVideosCalculatingHashes => 1, Self::SimilarVideosCreatingThumbnails => 2, Self::BrokenFilesChecking => 1, Self::BadExtensionsChecking => 1, Self::BadNamesChecking => 1, Self::VideoOptimizerCreatingThumbnails => 2, Self::VideoOptimizerProcessingVideos => 1, Self::SameMusicCacheLoadingTags => 1, Self::SameMusicReadingTags => 2, Self::SameMusicCacheSavingTags => 3, Self::SameMusicComparingTags => 4, Self::SameMusicCacheLoadingFingerprints => 4, Self::SameMusicCalculatingFingerprints => 5, Self::SameMusicCacheSavingFingerprints => 6, Self::SameMusicComparingFingerprints => 7, Self::ExifRemoverCacheLoading => 1, Self::ExifRemoverExtractingTags => 2, Self::ExifRemoverCacheSaving => 3, } } pub fn check_if_loading_saving_cache(self) -> bool { self.check_if_saving_cache() || self.check_if_loading_cache() } pub fn check_if_loading_cache(self) -> bool { matches!( self, Self::SameMusicCacheLoadingFingerprints | Self::SameMusicCacheLoadingTags | Self::DuplicateCacheLoading | Self::DuplicatePreHashCacheLoading | Self::ExifRemoverCacheLoading ) } pub fn check_if_saving_cache(self) -> bool { matches!( self, Self::SameMusicCacheSavingFingerprints | Self::SameMusicCacheSavingTags | Self::DuplicateCacheSaving | Self::DuplicatePreHashCacheSaving | Self::ExifRemoverCacheSaving ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_tool_type_and_current_stage_integration() { assert_eq!(ToolType::Duplicate.get_max_stage(CheckingMethod::Hash), 6); assert_eq!(ToolType::SameMusic.get_max_stage(CheckingMethod::AudioTags), 4); assert_eq!(ToolType::SameMusic.get_max_stage(CheckingMethod::AudioContent), 7); assert_eq!(ToolType::SimilarImages.get_max_stage(CheckingMethod::None), 2); assert_eq!(ToolType::BrokenFiles.get_max_stage(CheckingMethod::None), 1); assert_eq!(CurrentStage::DuplicateFullHashing.get_current_stage(), 5); assert_eq!(CurrentStage::SameMusicComparingFingerprints.get_current_stage(), 7); assert!(CurrentStage::DeletingFiles.is_special_non_tool_stage()); assert!(!CurrentStage::CollectingFiles.is_special_non_tool_stage()); } #[test] fn test_cache_operations_detection() { assert!(CurrentStage::DuplicateCacheLoading.check_if_loading_cache()); assert!(CurrentStage::DuplicateCacheSaving.check_if_saving_cache()); assert!(CurrentStage::SameMusicCacheLoadingTags.check_if_loading_saving_cache()); assert!(!CurrentStage::DuplicateFullHashing.check_if_loading_saving_cache()); } #[test] fn test_progress_data_validation_and_empty_state() { let empty = ProgressData::get_empty_state(CurrentStage::CollectingFiles); assert_eq!(empty.entries_checked, 0); assert_eq!(empty.tool_type, ToolType::None); let valid = ProgressData { sstage: CurrentStage::DuplicateFullHashing, checking_method: CheckingMethod::Hash, current_stage_idx: 5, max_stage_idx: 6, entries_checked: 50, entries_to_check: 100, bytes_checked: 1000, bytes_to_check: 2000, tool_type: ToolType::Duplicate, }; valid.validate(); } #[test] #[should_panic(expected = "Current stage index")] fn test_validation_invalid_stage_idx() { ProgressData { sstage: CurrentStage::DuplicateFullHashing, checking_method: CheckingMethod::Hash, current_stage_idx: 7, max_stage_idx: 6, entries_checked: 0, entries_to_check: 100, bytes_checked: 0, bytes_to_check: 1000, tool_type: ToolType::Duplicate, } .validate(); } #[test] #[should_panic(expected = "Entries checked")] fn test_validation_too_many_entries() { ProgressData { sstage: CurrentStage::DuplicateFullHashing, checking_method: CheckingMethod::Hash, current_stage_idx: 5, max_stage_idx: 6, entries_checked: 150, entries_to_check: 100, bytes_checked: 0, bytes_to_check: 1000, tool_type: ToolType::Duplicate, } .validate(); } } czkawka_core-11.0.1/src/common/progress_stop_handler.rs000064400000000000000000000160741046102023000214120ustar 00000000000000use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize}; use std::sync::{Arc, atomic}; use std::thread; use std::thread::{JoinHandle, sleep}; use std::time::{Duration, Instant}; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::{CheckingMethod, ToolType}; use crate::common::progress_data::{CurrentStage, ProgressData}; pub const LOOP_DURATION: u32 = 20; pub const SEND_PROGRESS_DATA_TIME_BETWEEN: u32 = 200; pub(crate) struct ProgressThreadHandler { progress_thread_handle: JoinHandle<()>, progress_thread_running: Arc, progress_status: ProgressStatus, } impl ProgressThreadHandler { pub fn new(progress_thread_handle: JoinHandle<()>, progress_thread_running: Arc, progress_status: ProgressStatus) -> Self { Self { progress_thread_handle, progress_thread_running, progress_status, } } pub fn join_thread(self) { self.progress_thread_running.store(false, atomic::Ordering::Relaxed); self.progress_thread_handle .join() .expect("Cannot join progress thread - quite fatal error, but I hope, that it will never happen :)"); } pub fn increase_items(&self, count: usize) { self.progress_status.items_counter.fetch_add(count, atomic::Ordering::Relaxed); } pub fn increase_size(&self, size: u64) { self.progress_status.size_counter.fetch_add(size, atomic::Ordering::Relaxed); } pub fn items_counter(&self) -> &Arc { &self.progress_status.items_counter } pub fn size_counter(&self) -> &Arc { &self.progress_status.size_counter } } #[derive(Clone)] pub(crate) struct ProgressStatus { items_counter: Arc, size_counter: Arc, } impl ProgressStatus { pub fn new() -> Self { Self { items_counter: Arc::new(AtomicUsize::new(0)), size_counter: Arc::new(AtomicU64::new(0)), } } } pub(crate) fn prepare_thread_handler_common( progress_sender: Option<&Sender>, sstage: CurrentStage, max_items: usize, test_type: (ToolType, CheckingMethod), max_size: u64, ) -> ProgressThreadHandler { let (tool_type, checking_method) = test_type; assert_ne!(tool_type, ToolType::None, "Cannot send progress data for ToolType::None"); let progress_status = ProgressStatus::new(); let progress_thread_running = Arc::new(AtomicBool::new(true)); let progress_thread_sender = if let Some(progress_sender) = progress_sender.cloned() { let progress_status = progress_status.clone(); let progress_thread_running = progress_thread_running.clone(); thread::spawn(move || { // Use earlier time, to send immediately first message let mut time_since_last_send = Instant::now().checked_sub(Duration::from_secs(10u64)).unwrap_or_else(Instant::now); loop { if time_since_last_send.elapsed().as_millis() > SEND_PROGRESS_DATA_TIME_BETWEEN as u128 { let progress_data = ProgressData { sstage, checking_method, current_stage_idx: sstage.get_current_stage(), max_stage_idx: tool_type.get_max_stage(checking_method), entries_checked: progress_status.items_counter.load(atomic::Ordering::Relaxed), entries_to_check: max_items, bytes_checked: progress_status.size_counter.load(atomic::Ordering::Relaxed), bytes_to_check: max_size, tool_type, }; progress_data.validate(); progress_sender.send(progress_data).expect("Cannot send progress data"); time_since_last_send = Instant::now(); } if !progress_thread_running.load(atomic::Ordering::Relaxed) { break; } sleep(Duration::from_millis(LOOP_DURATION as u64)); } }) } else { thread::spawn(|| {}) }; ProgressThreadHandler::new(progress_thread_sender, progress_thread_running, progress_status) } #[inline] pub(crate) fn check_if_stop_received(stop_flag: &Arc) -> bool { stop_flag.load(atomic::Ordering::Relaxed) } #[fun_time(message = "send_info_and_wait_for_ending_all_threads", level = "debug")] pub(crate) fn send_info_and_wait_for_ending_all_threads(progress_thread_run: &Arc, progress_thread_handle: JoinHandle<()>) { progress_thread_run.store(false, atomic::Ordering::Relaxed); progress_thread_handle.join().expect("Cannot join progress thread - quite fatal error, but happens rarely"); } #[cfg(test)] mod tests { use super::*; #[test] fn test_progress_status_and_stop_flag() { let status = ProgressStatus::new(); assert_eq!(status.items_counter.load(atomic::Ordering::Relaxed), 0); assert_eq!(status.size_counter.load(atomic::Ordering::Relaxed), 0); status.items_counter.fetch_add(10, atomic::Ordering::Relaxed); status.size_counter.fetch_add(1024, atomic::Ordering::Relaxed); assert_eq!(status.items_counter.load(atomic::Ordering::Relaxed), 10); assert_eq!(status.size_counter.load(atomic::Ordering::Relaxed), 1024); let stop_flag = Arc::new(AtomicBool::new(false)); assert!(!check_if_stop_received(&stop_flag)); stop_flag.store(true, atomic::Ordering::Relaxed); assert!(check_if_stop_received(&stop_flag)); } #[test] fn test_progress_thread_handler_with_sender() { let (sender, _receiver) = crossbeam_channel::unbounded(); let handler = prepare_thread_handler_common(Some(&sender), CurrentStage::DuplicateFullHashing, 100, (ToolType::Duplicate, CheckingMethod::Hash), 10000); assert_eq!(handler.items_counter().load(atomic::Ordering::Relaxed), 0); assert_eq!(handler.size_counter().load(atomic::Ordering::Relaxed), 0); handler.increase_items(5); handler.increase_size(512); handler.increase_items(3); handler.increase_size(256); assert_eq!(handler.items_counter().load(atomic::Ordering::Relaxed), 8); assert_eq!(handler.size_counter().load(atomic::Ordering::Relaxed), 768); handler.join_thread(); } #[test] fn test_progress_thread_handler_without_sender() { let handler = prepare_thread_handler_common(None, CurrentStage::CollectingFiles, 50, (ToolType::EmptyFiles, CheckingMethod::None), 5000); handler.increase_items(10); handler.increase_size(1000); assert_eq!(handler.items_counter().load(atomic::Ordering::Relaxed), 10); assert_eq!(handler.size_counter().load(atomic::Ordering::Relaxed), 1000); handler.join_thread(); } #[test] #[should_panic(expected = "Cannot send progress data for ToolType::None")] fn test_panics_on_none_tool_type() { prepare_thread_handler_common(None, CurrentStage::CollectingFiles, 50, (ToolType::None, CheckingMethod::None), 5000); } } czkawka_core-11.0.1/src/common/tool_data.rs000064400000000000000000001262461046102023000167550ustar 00000000000000use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; use crossbeam_channel::Sender; use humansize::{BINARY, format_size}; use log::info; use rayon::prelude::*; use crate::common::directories::Directories; use crate::common::extensions::Extensions; use crate::common::items::ExcludedItems; use crate::common::model::{CheckingMethod, ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::check_if_stop_received; use crate::common::traits::ResultEntry; use crate::common::{make_hard_link, remove_folder_if_contains_only_empty_folders, remove_single_file}; use crate::helpers::delayed_sender::DelayedSender; use crate::helpers::messages::Messages; #[derive(Debug, Clone, Default)] pub struct CommonToolData { pub(crate) tool_type: ToolType, pub(crate) text_messages: Messages, pub(crate) directories: Directories, pub(crate) extensions: Extensions, pub(crate) excluded_items: ExcludedItems, pub(crate) recursive_search: bool, pub(crate) delete_method: DeleteMethod, pub(crate) maximal_file_size: u64, pub(crate) minimal_file_size: u64, pub(crate) stopped_search: bool, pub(crate) use_cache: bool, pub(crate) delete_outdated_cache: bool, pub(crate) save_also_as_json: bool, pub(crate) use_reference_folders: bool, pub(crate) dry_run: bool, pub(crate) move_to_trash: bool, pub(crate) hide_hard_links: bool, } #[derive(Debug, Clone, Default)] pub struct DeleteResult { deleted_files: usize, gained_bytes: u64, failed_to_delete_files: usize, errors: Vec, infos: Vec, } impl DeleteResult { pub(crate) fn add_to_messages(&self, messages: &mut Messages) { messages.errors.extend(self.errors.clone()); messages.messages.extend(self.infos.clone()); } } #[derive(Debug, Clone, Eq, PartialEq)] pub enum DeleteItemType { DeletingFiles(Vec), DeletingFolders(Vec), HardlinkingFiles(Vec<(T, Vec)>), } impl DeleteItemType { fn calculate_size_to_delete(&self) -> u64 { match &self { Self::DeletingFiles(items) | Self::DeletingFolders(items) => items.iter().map(|item| item.get_size()).sum(), Self::HardlinkingFiles(items) => items.iter().map(|(item, _)| item.get_size()).sum(), } } fn calculate_entries_to_delete(&self) -> usize { match &self { Self::DeletingFiles(items) | Self::DeletingFolders(items) => items.len(), Self::HardlinkingFiles(items) => items.iter().map(|(_original, files)| files.len()).sum(), } } } #[derive(Eq, PartialEq, Clone, Debug, Copy, Default)] pub enum DeleteMethod { #[default] None, Delete, // Just delete items AllExceptNewest, AllExceptOldest, OneOldest, OneNewest, HardLink, AllExceptBiggest, AllExceptSmallest, OneBiggest, OneSmallest, } impl CommonToolData { pub fn new(tool_type: ToolType) -> Self { Self { tool_type, text_messages: Messages::new(), directories: Directories::new(), extensions: Extensions::new(), excluded_items: ExcludedItems::new(), recursive_search: true, delete_method: DeleteMethod::None, maximal_file_size: u64::MAX, minimal_file_size: 0, stopped_search: false, use_cache: true, delete_outdated_cache: true, save_also_as_json: false, use_reference_folders: false, dry_run: false, move_to_trash: false, hide_hard_links: false, } } } pub trait CommonData { type Info; type Parameters; fn get_information(&self) -> Self::Info; fn get_params(&self) -> Self::Parameters; fn get_cd(&self) -> &CommonToolData; fn get_cd_mut(&mut self) -> &mut CommonToolData; fn get_check_method(&self) -> CheckingMethod { CheckingMethod::None } fn get_test_type(&self) -> (ToolType, CheckingMethod) { (self.get_cd().tool_type, self.get_check_method()) } fn found_any_items(&self) -> bool; fn get_tool_type(&self) -> ToolType { self.get_cd().tool_type } fn set_hide_hard_links(&mut self, hide_hard_links: bool) { self.get_cd_mut().hide_hard_links = hide_hard_links; } fn get_hide_hard_links(&self) -> bool { self.get_cd().hide_hard_links } fn set_dry_run(&mut self, dry_run: bool) { self.get_cd_mut().dry_run = dry_run; } fn get_dry_run(&self) -> bool { self.get_cd().dry_run } fn set_use_cache(&mut self, use_cache: bool) { self.get_cd_mut().use_cache = use_cache; } fn get_use_cache(&self) -> bool { self.get_cd().use_cache } fn set_delete_outdated_cache(&mut self, delete_outdated_cache: bool) { self.get_cd_mut().delete_outdated_cache = delete_outdated_cache; } fn get_delete_outdated_cache(&self) -> bool { self.get_cd().delete_outdated_cache } fn get_stopped_search(&self) -> bool { self.get_cd().stopped_search } fn set_stopped_search(&mut self, stopped_search: bool) { self.get_cd_mut().stopped_search = stopped_search; } fn set_maximal_file_size(&mut self, maximal_file_size: u64) { self.get_cd_mut().maximal_file_size = match maximal_file_size { 0 => 1, t => t, }; } fn get_maximal_file_size(&self) -> u64 { self.get_cd().maximal_file_size } fn set_minimal_file_size(&mut self, minimal_file_size: u64) { self.get_cd_mut().minimal_file_size = match minimal_file_size { 0 => 1, t => t, }; } fn get_minimal_file_size(&self) -> u64 { self.get_cd().minimal_file_size } #[cfg(target_family = "unix")] fn set_exclude_other_filesystems(&mut self, exclude_other_filesystems: bool) { self.get_cd_mut().directories.set_exclude_other_filesystems(exclude_other_filesystems); } #[cfg(not(target_family = "unix"))] fn set_exclude_other_filesystems(&mut self, _exclude_other_filesystems: bool) {} fn get_text_messages(&self) -> &Messages { &self.get_cd().text_messages } fn get_text_messages_mut(&mut self) -> &mut Messages { &mut self.get_cd_mut().text_messages } fn set_save_also_as_json(&mut self, save_also_as_json: bool) { self.get_cd_mut().save_also_as_json = save_also_as_json; } fn get_save_also_as_json(&self) -> bool { self.get_cd().save_also_as_json } fn set_recursive_search(&mut self, recursive_search: bool) { self.get_cd_mut().recursive_search = recursive_search; } fn get_recursive_search(&self) -> bool { self.get_cd().recursive_search } fn set_use_reference_folders(&mut self, use_reference_folders: bool) { self.get_cd_mut().use_reference_folders = use_reference_folders; } fn get_use_reference_folders(&self) -> bool { self.get_cd().use_reference_folders } fn set_delete_method(&mut self, delete_method: DeleteMethod) { self.get_cd_mut().delete_method = delete_method; } fn get_delete_method(&self) -> DeleteMethod { self.get_cd().delete_method } // Only used for internal deleting - probably only useful in CLI, but not in GUI which probably uses its own delete method selection fn set_move_to_trash(&mut self, move_to_trash: bool) { self.get_cd_mut().move_to_trash = move_to_trash; } fn get_move_to_trash(&self) -> bool { self.get_cd().move_to_trash } fn set_included_paths(&mut self, included_paths: Vec) { let messages = self.get_cd_mut().directories.set_included_paths(included_paths); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn set_excluded_paths(&mut self, excluded_paths: Vec) { let messages = self.get_cd_mut().directories.set_excluded_paths(excluded_paths); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn set_reference_paths(&mut self, reference_paths: Vec) { let messages = self.get_cd_mut().directories.set_reference_paths(reference_paths); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn set_allowed_extensions(&mut self, allowed_extensions: Vec) { let messages = self.get_cd_mut().extensions.set_allowed_extensions(allowed_extensions); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn set_excluded_extensions(&mut self, excluded_extensions: Vec) { let messages = self.get_cd_mut().extensions.set_excluded_extensions(excluded_extensions); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn set_excluded_items(&mut self, excluded_items: Vec) { let messages = self.get_cd_mut().excluded_items.set_excluded_items(excluded_items); self.get_cd_mut().text_messages.extend_with_another_messages(messages); } fn get_extensions_mut(&mut self) -> &mut Extensions { &mut self.get_cd_mut().extensions } #[expect(clippy::result_unit_err)] fn prepare_items(&mut self, tool_extensions: Option<&[&str]>) -> Result<(), ()> { let recursive_search = self.get_cd().recursive_search; // Optimizes directories and removes recursive calls match self.get_cd_mut().directories.optimize_directories(recursive_search, false) { Ok(messages) => { self.get_cd_mut().text_messages.extend_with_another_messages(messages); } Err(messages) => { self.get_cd_mut().text_messages.extend_with_another_messages(messages); return Err(()); } } if let Err(e) = self.get_extensions_mut().set_and_validate_extensions(tool_extensions) { self.get_cd_mut().text_messages.critical = Some(e); return Err(()); } Ok(()) } fn delete_simple_elements_and_add_to_messages( &mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, delete_item_type: DeleteItemType, ) -> WorkContinueStatus { let delete_results = self.delete_elements(stop_flag, progress_sender, delete_item_type); if check_if_stop_received(stop_flag) { WorkContinueStatus::Stop } else { delete_results.add_to_messages(self.get_text_messages_mut()); WorkContinueStatus::Continue } } #[expect(clippy::indexing_slicing)] // Safe, because input is always checked to have at least 1 element fn delete_advanced_elements_and_add_to_messages( &mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, files_to_process: Vec>, ) -> WorkContinueStatus { let delete_method = self.get_cd().delete_method; let sorting_by_size = matches!( delete_method, DeleteMethod::AllExceptBiggest | DeleteMethod::AllExceptSmallest | DeleteMethod::OneBiggest | DeleteMethod::OneSmallest ); let sort_items = |mut input: Vec| -> Vec { input.sort_unstable_by_key(if sorting_by_size { ResultEntry::get_size } else { ResultEntry::get_modified_date }); input }; let delete_results = if delete_method == DeleteMethod::HardLink { let res = files_to_process .into_iter() .map(|values| { let mut all_values = sort_items(values); let original = all_values.remove(0); (original, all_values) }) .collect::>(); self.delete_elements(stop_flag, progress_sender, DeleteItemType::HardlinkingFiles(res)) } else { let res = files_to_process .into_iter() .flat_map(|values| { // TODO - probably a little too much cloning, so later could be this optimized let len = values.len(); let all_values = sort_items(values); match delete_method { DeleteMethod::Delete => &all_values, DeleteMethod::AllExceptNewest | DeleteMethod::AllExceptBiggest => &all_values[..(len - 1)], DeleteMethod::AllExceptOldest | DeleteMethod::AllExceptSmallest => &all_values[1..], DeleteMethod::OneOldest | DeleteMethod::OneSmallest => &all_values[..1], DeleteMethod::OneNewest | DeleteMethod::OneBiggest => &all_values[(len - 1)..], DeleteMethod::HardLink | DeleteMethod::None => unreachable!("HardLink and None should be handled before"), } .to_vec() }) .collect::>(); self.delete_elements(stop_flag, progress_sender, DeleteItemType::DeletingFiles(res)) }; if check_if_stop_received(stop_flag) { WorkContinueStatus::Stop } else { delete_results.add_to_messages(self.get_text_messages_mut()); WorkContinueStatus::Continue } } fn delete_elements( &self, stop_flag: &Arc, progress_sender: Option<&Sender>, delete_item_type: DeleteItemType, ) -> DeleteResult { let dry_run = self.get_cd().dry_run; let move_to_trash = self.get_cd().move_to_trash; let mut progress = ProgressData::get_empty_state(CurrentStage::DeletingFiles); progress.bytes_to_check = delete_item_type.calculate_size_to_delete(); progress.entries_to_check = delete_item_type.calculate_entries_to_delete(); let is_hardlinking = matches!(delete_item_type, DeleteItemType::HardlinkingFiles(_)); let msg_common = format!( "{} items, total size: {} bytes, dry_run: {dry_run}", progress.entries_to_check, format_size(progress.bytes_to_check, BINARY) ); if is_hardlinking { info!("Hardlinking {msg_common}"); } else { info!("Deleting {msg_common}"); } let delayed_sender = progress_sender.map(|e| DelayedSender::new(e.clone(), Duration::from_millis(200))); let bytes_processed = Arc::new(std::sync::atomic::AtomicU64::new(0)); let files_processed = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let res = match delete_item_type { DeleteItemType::DeletingFiles(ref items) | DeleteItemType::DeletingFolders(ref items) => items .into_par_iter() .map(|e| { if check_if_stop_received(stop_flag) { return None; } let mut progress_tmp = progress; progress_tmp.bytes_checked = bytes_processed.fetch_add(e.get_size(), std::sync::atomic::Ordering::Relaxed); progress_tmp.entries_checked = files_processed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if let Some(e) = delayed_sender.as_ref() { e.send(progress_tmp); } if dry_run { return Some(vec![(e, None)]); } let delete_res = if matches!(delete_item_type, DeleteItemType::DeletingFiles(_)) { remove_single_file(e.get_path(), move_to_trash) } else { remove_folder_if_contains_only_empty_folders(e.get_path(), move_to_trash) }; match delete_res { Ok(()) => Some(vec![(e, None)]), Err(err) => Some(vec![(e, Some(err))]), } }) .while_some() .flatten() .collect::>(), DeleteItemType::HardlinkingFiles(ref items) => items .into_par_iter() .map(|(original, files)| { if check_if_stop_received(stop_flag) { return None; } let mut progress_tmp = progress; progress_tmp.bytes_checked = bytes_processed.fetch_add(files.iter().map(|e| e.get_size()).sum(), std::sync::atomic::Ordering::Relaxed); progress_tmp.entries_checked = files_processed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if let Some(e) = delayed_sender.as_ref() { e.send(progress_tmp); } if dry_run { return Some(files.iter().map(|e| (e, None)).collect::>()); } let res = files .iter() .map(|file| { let err = match make_hard_link(original.get_path(), file.get_path()) { Ok(()) => None, Err(err) => Some(format!( "Failed to hardlink \"{}\" to \"{}\": {err}", original.get_path().to_string_lossy(), file.get_path().to_string_lossy() )), }; (file, err) }) .collect::>(); Some(res) }) .while_some() .flatten() .collect::>(), }; let mut delete_result = DeleteResult::default(); for (file_entry, delete_err) in res { if let Some(err) = delete_err { delete_result.errors.push(err); delete_result.failed_to_delete_files += 1; } else { if dry_run { if is_hardlinking { delete_result.infos.push(format!( "Would hardlink: \"{}\" to \"{}\"", file_entry.get_path().to_string_lossy(), file_entry.get_path().to_string_lossy() )); } else { delete_result.infos.push(format!("Would delete: \"{}\"", file_entry.get_path().to_string_lossy())); } } delete_result.deleted_files += 1; delete_result.gained_bytes += file_entry.get_size(); } } if !dry_run { let action = if is_hardlinking { "hardlink" } else { "delete" }; let action2 = if is_hardlinking { "hardlinked" } else { "deleted" }; info!( "{} items {action2}, {} gained, {} failed to {action}", delete_result.deleted_files, format_size(delete_result.gained_bytes, BINARY), delete_result.failed_to_delete_files ); } delete_result } #[expect(clippy::print_stdout)] fn debug_print_common(&self) { println!("---------------DEBUG PRINT COMMON---------------"); println!("Included paths(before optimization) - {:?}", self.get_cd().directories.original_included_paths); println!("Excluded paths(before optimization) - {:?}", self.get_cd().directories.original_excluded_paths); println!("Reference paths(before optimization) - {:?}", self.get_cd().directories.original_reference_paths); println!("Included directories(optimized) - {:?}", self.get_cd().directories.included_directories); println!("Included files(optimized) - {:?}", self.get_cd().directories.included_files); println!("Excluded directories(optimized) - {:?}", self.get_cd().directories.excluded_directories); println!("Excluded files(optimized) - {:?}", self.get_cd().directories.excluded_files); println!("Reference directories(optimized) - {:?}", self.get_cd().directories.reference_directories); println!("Reference files(optimized) - {:?}", self.get_cd().directories.reference_files); println!("Tool type: {:?}", self.get_cd().tool_type); println!("Directories: {:?}", self.get_cd().directories); println!("Extensions: {:?}", self.get_cd().extensions); println!("Excluded items: {:?}", self.get_cd().excluded_items); println!("Recursive search: {}", self.get_cd().recursive_search); println!("Maximal file size: {}", self.get_cd().maximal_file_size); println!("Minimal file size: {}", self.get_cd().minimal_file_size); println!("Stopped search: {}", self.get_cd().stopped_search); println!("Use cache: {}", self.get_cd().use_cache); println!("Delete outdated cache: {}", self.get_cd().delete_outdated_cache); println!("Save also as json: {}", self.get_cd().save_also_as_json); println!("Delete method: {:?}", self.get_cd().delete_method); println!("Use reference folders: {}", self.get_cd().use_reference_folders); println!("Dry run: {}", self.get_cd().dry_run); println!("Hide hard links: {}", self.get_cd().hide_hard_links); println!("---------------DEBUG PRINT MESSAGES---------------"); println!("Errors size - {}", self.get_cd().text_messages.errors.len()); println!("Warnings size - {}", self.get_cd().text_messages.warnings.len()); println!("Messages size - {}", self.get_cd().text_messages.messages.len()); } } #[cfg(test)] mod tests { use std::fs; use tempfile::TempDir; use super::*; use crate::common::model::FileEntry; // Mock implementation for testing struct MockTool { common_data: CommonToolData, } impl CommonData for MockTool { type Info = (); type Parameters = (); fn get_information(&self) -> Self::Info {} fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { false } } impl MockTool { fn new() -> Self { Self { common_data: CommonToolData::new(ToolType::Duplicate), } } } #[test] fn test_delete_result_add_to_messages() { let delete_result = DeleteResult { deleted_files: 5, gained_bytes: 1024, failed_to_delete_files: 2, errors: vec!["Error 1".to_string(), "Error 2".to_string()], infos: vec!["Info 1".to_string()], }; let mut messages = Messages::new(); delete_result.add_to_messages(&mut messages); assert_eq!(messages.errors.len(), 2); assert_eq!(messages.messages.len(), 1); assert!(messages.errors.contains(&"Error 1".to_string())); assert!(messages.messages.contains(&"Info 1".to_string())); } #[test] fn test_delete_item_type_calculate_size_and_entries() { let files = vec![ FileEntry { path: PathBuf::from("/a"), size: 100, modified_date: 1, }, FileEntry { path: PathBuf::from("/b"), size: 200, modified_date: 2, }, FileEntry { path: PathBuf::from("/c"), size: 300, modified_date: 3, }, ]; let delete_files = DeleteItemType::DeletingFiles(files.clone()); assert_eq!(delete_files.calculate_size_to_delete(), 600); assert_eq!(delete_files.calculate_entries_to_delete(), 3); let delete_folders = DeleteItemType::DeletingFolders(files.clone()); assert_eq!(delete_folders.calculate_size_to_delete(), 600); assert_eq!(delete_folders.calculate_entries_to_delete(), 3); let hardlink_files = DeleteItemType::HardlinkingFiles(vec![ (files[0].clone(), vec![files[1].clone()]), (files[2].clone(), vec![files[0].clone(), files[1].clone()]), ]); assert_eq!(hardlink_files.calculate_size_to_delete(), 400); assert_eq!(hardlink_files.calculate_entries_to_delete(), 3); } #[test] fn test_common_tool_data_new() { let tool_data = CommonToolData::new(ToolType::Duplicate); assert_eq!(tool_data.tool_type, ToolType::Duplicate); assert_eq!(tool_data.delete_method, DeleteMethod::None); assert_eq!(tool_data.maximal_file_size, u64::MAX); assert_eq!(tool_data.minimal_file_size, 0); assert!(tool_data.recursive_search); assert!(!tool_data.stopped_search); assert!(tool_data.use_cache); assert!(tool_data.delete_outdated_cache); assert!(!tool_data.save_also_as_json); assert!(!tool_data.use_reference_folders); assert!(!tool_data.dry_run); } #[test] fn test_delete_elements_dry_run() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); fs::write(&file1, "test content 1").unwrap(); fs::write(&file2, "test content 2").unwrap(); let files = vec![ FileEntry { path: file1.clone(), size: 14, modified_date: 1, }, FileEntry { path: file2.clone(), size: 14, modified_date: 2, }, ]; let mut tool = MockTool::new(); tool.common_data.dry_run = true; let stop_flag = Arc::new(AtomicBool::new(false)); let delete_result = tool.delete_elements(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(delete_result.deleted_files, 2, "Should mark 2 files as deleted"); assert_eq!(delete_result.failed_to_delete_files, 0, "Should have no failed deletions"); assert_eq!(delete_result.gained_bytes, 28, "Should calculate gained bytes"); assert_eq!(delete_result.infos.len(), 2, "Should have 2 info messages in dry run"); assert!(file1.exists(), "File should still exist in dry run"); assert!(file2.exists(), "File should still exist in dry run"); } #[test] fn test_delete_elements_actual_deletion() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); fs::write(&file1, "test content 1").unwrap(); fs::write(&file2, "test content 2").unwrap(); let files = vec![ FileEntry { path: file1.clone(), size: 14, modified_date: 1, }, FileEntry { path: file2.clone(), size: 14, modified_date: 2, }, ]; let tool = MockTool::new(); let stop_flag = Arc::new(AtomicBool::new(false)); let delete_result = tool.delete_elements(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(delete_result.deleted_files, 2, "Should delete 2 files"); assert_eq!(delete_result.failed_to_delete_files, 0, "Should have no failed deletions"); assert_eq!(delete_result.gained_bytes, 28, "Should gain 28 bytes"); assert!(!file1.exists(), "File 1 should be deleted"); assert!(!file2.exists(), "File 2 should be deleted"); } #[test] fn test_delete_elements_with_stop_flag() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); fs::write(&file1, "test content").unwrap(); let files = vec![FileEntry { path: file1.clone(), size: 12, modified_date: 1, }]; let tool = MockTool::new(); let stop_flag = Arc::new(AtomicBool::new(true)); // Stop flag set to true let delete_result = tool.delete_elements(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(delete_result.deleted_files, 0, "Should not delete any files when stopped"); assert!(file1.exists(), "File should still exist"); } #[test] fn test_delete_elements_nonexistent_file() { let temp_dir = TempDir::new().unwrap(); let nonexistent_file = temp_dir.path().join("nonexistent.txt"); let files = vec![FileEntry { path: nonexistent_file, size: 100, modified_date: 1, }]; let tool = MockTool::new(); let stop_flag = Arc::new(AtomicBool::new(false)); let delete_result = tool.delete_elements(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(delete_result.deleted_files, 0, "Should not delete nonexistent file"); assert_eq!(delete_result.failed_to_delete_files, 1, "Should report 1 failed deletion"); assert_eq!(delete_result.errors.len(), 1, "Should have 1 error message"); } #[test] fn test_delete_simple_elements_and_add_to_messages() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); fs::write(&file1, "content1").unwrap(); fs::write(&file2, "content2").unwrap(); let files = vec![ FileEntry { path: file1.clone(), size: 8, modified_date: 1, }, FileEntry { path: file2.clone(), size: 8, modified_date: 2, }, ]; let mut tool = MockTool::new(); let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_simple_elements_and_add_to_messages(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "File 1 should be deleted"); assert!(!file2.exists(), "File 2 should be deleted"); assert_eq!(tool.common_data.text_messages.errors.len(), 0, "Should have no errors"); } #[test] fn test_delete_simple_elements_with_stop_flag() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); fs::write(&file1, "content").unwrap(); let files = vec![FileEntry { path: file1.clone(), size: 7, modified_date: 1, }]; let mut tool = MockTool::new(); let stop_flag = Arc::new(AtomicBool::new(true)); let status = tool.delete_simple_elements_and_add_to_messages(&stop_flag, None, DeleteItemType::DeletingFiles(files)); assert_eq!(status, WorkContinueStatus::Stop, "Should stop"); assert!(file1.exists(), "File should still exist"); } #[test] fn test_delete_advanced_elements_all_except_newest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); fs::write(&file3, "c").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, FileEntry { path: file3.clone(), size: 1, modified_date: 3, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptNewest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "Oldest file should be deleted"); assert!(!file2.exists(), "Middle file should be deleted"); assert!(file3.exists(), "Newest file should be kept"); } #[test] fn test_delete_advanced_elements_all_except_oldest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); fs::write(&file3, "c").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, FileEntry { path: file3.clone(), size: 1, modified_date: 3, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptOldest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(file1.exists(), "Oldest file should be kept"); assert!(!file2.exists(), "Middle file should be deleted"); assert!(!file3.exists(), "Newest file should be deleted"); } #[test] fn test_delete_advanced_elements_one_oldest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); fs::write(&file3, "c").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, FileEntry { path: file3.clone(), size: 1, modified_date: 3, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::OneOldest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "Oldest file should be deleted"); assert!(file2.exists(), "Middle file should be kept"); assert!(file3.exists(), "Newest file should be kept"); } #[test] fn test_delete_advanced_elements_one_newest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); fs::write(&file3, "c").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, FileEntry { path: file3.clone(), size: 1, modified_date: 3, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::OneNewest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(file1.exists(), "Oldest file should be kept"); assert!(file2.exists(), "Middle file should be kept"); assert!(!file3.exists(), "Newest file should be deleted"); } #[test] fn test_delete_advanced_elements_all_except_biggest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "bb").unwrap(); fs::write(&file3, "ccc").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 2, modified_date: 1, }, FileEntry { path: file3.clone(), size: 3, modified_date: 1, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptBiggest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "Smallest file should be deleted"); assert!(!file2.exists(), "Middle file should be deleted"); assert!(file3.exists(), "Biggest file should be kept"); } #[test] fn test_delete_advanced_elements_all_except_smallest() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "bb").unwrap(); fs::write(&file3, "ccc").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 2, modified_date: 1, }, FileEntry { path: file3.clone(), size: 3, modified_date: 1, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptSmallest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(file1.exists(), "Smallest file should be kept"); assert!(!file2.exists(), "Middle file should be deleted"); assert!(!file3.exists(), "Biggest file should be deleted"); } #[test] fn test_delete_advanced_elements_delete_all() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); let files_group = vec![vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::Delete; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "All files should be deleted"); assert!(!file2.exists(), "All files should be deleted"); } #[test] fn test_delete_advanced_elements_multiple_groups() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); let file3 = temp_dir.path().join("file3.txt"); let file4 = temp_dir.path().join("file4.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); fs::write(&file3, "c").unwrap(); fs::write(&file4, "d").unwrap(); let files_group = vec![ vec![ FileEntry { path: file1.clone(), size: 1, modified_date: 1, }, FileEntry { path: file2.clone(), size: 1, modified_date: 2, }, ], vec![ FileEntry { path: file3.clone(), size: 1, modified_date: 1, }, FileEntry { path: file4.clone(), size: 1, modified_date: 2, }, ], ]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptNewest; let stop_flag = Arc::new(AtomicBool::new(false)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Continue, "Should continue"); assert!(!file1.exists(), "Oldest from group 1 should be deleted"); assert!(file2.exists(), "Newest from group 1 should be kept"); assert!(!file3.exists(), "Oldest from group 2 should be deleted"); assert!(file4.exists(), "Newest from group 2 should be kept"); } #[test] fn test_delete_advanced_elements_with_stop_flag() { let temp_dir = TempDir::new().unwrap(); let file1 = temp_dir.path().join("file1.txt"); let file2 = temp_dir.path().join("file2.txt"); fs::write(&file1, "a").unwrap(); fs::write(&file2, "b").unwrap(); let files_group = vec![vec![ FileEntry { path: file1, size: 1, modified_date: 1, }, FileEntry { path: file2, size: 1, modified_date: 2, }, ]]; let mut tool = MockTool::new(); tool.common_data.delete_method = DeleteMethod::AllExceptNewest; let stop_flag = Arc::new(AtomicBool::new(true)); let status = tool.delete_advanced_elements_and_add_to_messages(&stop_flag, None, files_group); assert_eq!(status, WorkContinueStatus::Stop, "Should stop"); } } czkawka_core-11.0.1/src/common/traits.rs000064400000000000000000000132361046102023000163070ustar 00000000000000use std::fs::File; use std::io::{BufWriter, Write}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use serde::Serialize; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::CommonData; pub trait DebugPrint { fn debug_print(&self); } pub trait PrintResults: CommonData { fn write_base_search_paths(&self, writer: &mut T) -> std::io::Result<()> { let dirs = &self.get_cd().directories; let included_paths = dirs.included_files.iter().chain(dirs.included_directories.iter()).collect::>(); let excluded_paths = dirs.excluded_files.iter().chain(dirs.excluded_directories.iter()).collect::>(); let reference_paths = dirs.reference_files.iter().chain(dirs.reference_directories.iter()).collect::>(); let excluded_items = self.get_cd().excluded_items.get_excluded_items(); if self.get_cd().tool_type.may_use_reference_paths() { writeln!( writer, "Results of searching {included_paths:?} with reference paths {reference_paths:?}, excluded paths {excluded_paths:?} and excluded items {excluded_items:?}" )?; writeln!( writer, "(Before optimizations - included paths: {:?}, excluded paths: {:?}, reference paths: {:?})", dirs.original_included_paths, dirs.original_excluded_paths, dirs.original_reference_paths )?; } else { writeln!( writer, "Results of searching {included_paths:?} with excluded paths {excluded_paths:?} and excluded items {excluded_items:?}" )?; writeln!( writer, "(Before optimizations - included paths: {:?}, excluded paths: {:?})", dirs.original_included_paths, dirs.original_excluded_paths )?; } Ok(()) } fn write_results(&self, writer: &mut T) -> std::io::Result<()>; #[fun_time(message = "print_results_to_output", level = "debug")] fn print_results_to_output(&self) { let stdout = std::io::stdout(); let mut handle = stdout.lock(); // Panics here are allowed, because it is used only in CLI self.write_results(&mut handle).expect("Error while writing to stdout"); handle.flush().expect("Error while flushing stdout"); } #[fun_time(message = "print_results_to_file", level = "debug")] fn print_results_to_file(&self, file_name: &str) -> std::io::Result<()> { let file_name: String = match file_name { "" => "results.txt".to_string(), k => k.to_string(), }; let file_handler = File::create(file_name)?; let mut writer = BufWriter::new(file_handler); self.write_results(&mut writer)?; writer.flush()?; Ok(()) } #[fun_time(message = "print_results_to_writer", level = "debug")] fn print_results_to_writer(&self, writer: &mut T) -> std::io::Result<()> { self.write_results(writer) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()>; fn save_results_to_file_as_json_internal(&self, file_name: &str, item_to_serialize: &T, pretty_print: bool) -> std::io::Result<()> { if pretty_print { self.save_results_to_file_as_json_pretty(file_name, item_to_serialize) } else { self.save_results_to_file_as_json_compact(file_name, item_to_serialize) } } #[fun_time(message = "save_results_to_file_as_json_pretty", level = "debug")] fn save_results_to_file_as_json_pretty(&self, file_name: &str, item_to_serialize: &T) -> std::io::Result<()> { let file_handler = File::create(file_name)?; let mut writer = BufWriter::new(file_handler); serde_json::to_writer_pretty(&mut writer, item_to_serialize)?; Ok(()) } #[fun_time(message = "save_results_to_file_as_json_compact", level = "debug")] fn save_results_to_file_as_json_compact(&self, file_name: &str, item_to_serialize: &T) -> std::io::Result<()> { let file_handler = File::create(file_name)?; let mut writer = BufWriter::new(file_handler); serde_json::to_writer(&mut writer, item_to_serialize)?; Ok(()) } fn save_all_in_one(&self, folder: &str, base_file_name: &str) -> std::io::Result<()> { let pretty_name = format!("{folder}/{base_file_name}_pretty.json"); self.save_results_to_file_as_json(&pretty_name, true)?; let compact_name = format!("{folder}/{base_file_name}_compact.json"); self.save_results_to_file_as_json(&compact_name, false)?; let txt_name = format!("{folder}/{base_file_name}.txt"); self.print_results_to_file(&txt_name)?; Ok(()) } } pub trait DeletingItems { #[must_use] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus; } pub trait FixingItems { type FixParams; fn fix_items(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, fix_params: Self::FixParams); } pub trait ResultEntry { fn get_path(&self) -> &Path; fn get_modified_date(&self) -> u64; fn get_size(&self) -> u64; } pub trait Search { fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>); } pub trait AllTraits: DebugPrint + PrintResults + DeletingItems + CommonData + Search {} czkawka_core-11.0.1/src/common/video_utils.rs000064400000000000000000000230411046102023000173220ustar 00000000000000use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use blake3::Hasher; use image::{GenericImage, RgbImage}; use serde::{Deserialize, Serialize}; use crate::common::consts::VIDEO_RESOLUTION_LIMIT; use crate::common::process_utils::disable_windows_console_window; use crate::common::progress_stop_handler::check_if_stop_received; use crate::flc; use crate::helpers::ffprobe::ffprobe; pub const VIDEO_THUMBNAILS_SUBFOLDER: &str = "video_thumbnails"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct VideoMetadata { pub fps: Option, pub codec: Option, pub bitrate: Option, pub width: Option, pub height: Option, pub duration: Option, } impl VideoMetadata { pub fn from_path(path: &Path) -> Result { let info = ffprobe(path).map_err(|e| flc!("core_failed_to_read_video_properties", reason = e.to_string()))?; let mut metadata = Self::default(); if let Some(duration_str) = &info.format.duration && let Ok(d) = duration_str.parse::() { metadata.duration = Some(d); } if let Some(stream) = info.streams.into_iter().find(|s| s.codec_type.as_deref() == Some("video")) { metadata.codec = stream.codec_name; if let Some(bit_rate_str) = stream.bit_rate.or(info.format.bit_rate) && let Ok(b) = bit_rate_str.parse::() { metadata.bitrate = Some(b); } if let Some(w) = stream.width && w >= 0 { if w > VIDEO_RESOLUTION_LIMIT as i64 { return Err(flc!("core_video_width_exceeds_limit", width = w, limit = VIDEO_RESOLUTION_LIMIT)); } metadata.width = Some(w as u32); } if let Some(h) = stream.height && h >= 0 { if h > VIDEO_RESOLUTION_LIMIT as i64 { return Err(flc!("core_video_height_exceeds_limit", height = h, limit = VIDEO_RESOLUTION_LIMIT)); } metadata.height = Some(h as u32); } let fps_opt = if !stream.avg_frame_rate.is_empty() && stream.avg_frame_rate != "0/0" { Some(stream.avg_frame_rate) } else if !stream.r_frame_rate.is_empty() && stream.r_frame_rate != "0/0" { Some(stream.r_frame_rate) } else { None }; if let Some(fps_str) = fps_opt { let fps_val = if fps_str.contains('/') { let mut parts = fps_str.splitn(2, '/'); if let (Some(n), Some(d)) = (parts.next(), parts.next()) { if let (Ok(nv), Ok(dv)) = (n.parse::(), d.parse::()) { if dv != 0.0 { Some(nv / dv) } else { None } } else { None } } else { None } } else { fps_str.parse::().ok() }; if let Some(fps_v) = fps_val { metadata.fps = Some(fps_v); } } } Ok(metadata) } } pub(crate) fn extract_frame_ffmpeg(video_path: &Path, timestamp: f32, max_values: Option<(u32, u32)>) -> Result { // This function returns strange status 234, when path contains non default UTF-8 characters, not sure why if !video_path.exists() { return Err(flc!("core_video_file_does_not_exist", path = video_path.to_string_lossy())); } let mut command = Command::new("ffmpeg"); let command_mut = &mut command; disable_windows_console_window(command_mut); command_mut.arg("-threads").arg("1").arg("-ss").arg(timestamp.to_string()).arg("-i").arg(video_path); if let Some((max_width, max_height)) = max_values { let vf_filter = format!("scale='min({max_width},iw)':'min({max_height},ih)':force_original_aspect_ratio=decrease"); command_mut.arg("-vf").arg(&vf_filter); } let output = command_mut .arg("-vframes") .arg("1") .arg("-f") .arg("image2pipe") .arg("-vcodec") .arg("png") .arg("pipe:1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .output() .map_err(|e| flc!("core_failed_to_execute_ffmpeg", reason = e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n").replace("\n", " "); return Err(flc!( "core_ffmpeg_failed_with_status", status = output.status.to_string(), stderr = stderr, command = format!("{:?}", command) )); } let img = image::load_from_memory(&output.stdout).map_err(|e| flc!("core_failed_to_load_image_frame", reason = e.to_string()))?; Ok(img.into_rgb8()) } pub fn generate_thumbnail( stop_flag: &Arc, video_path: &Path, size: u64, modified_date: u64, duration: Option, thumbnails_dir: &Path, thumbnail_video_percentage_from_start: u8, generate_grid_instead_of_single: bool, thumbnail_grid_tiles_per_side: u8, ) -> Result { let mut hasher = Hasher::new(); if generate_grid_instead_of_single { hasher.update(format!("{size}___{modified_date}___{}___GRID_{thumbnail_grid_tiles_per_side}", video_path.to_string_lossy()).as_bytes()); } else { hasher.update( format!( "{thumbnail_video_percentage_from_start}___{size}___{modified_date}___{}___SINGLE", video_path.to_string_lossy() ) .as_bytes(), ); } let hash = hasher.finalize(); let thumbnail_filename = format!("{}.jpg", hash.to_hex()); let thumbnail_path = thumbnails_dir.join(thumbnail_filename); if thumbnail_path.exists() { let _ = filetime::set_file_mtime(&thumbnail_path, filetime::FileTime::now()); return Ok(thumbnail_path); } let seek_time = duration.map_or(5.0, |d| d * (thumbnail_video_percentage_from_start as f64) / 100.0); let duration_per_tile_items = duration.map_or(0.5, |d| d / (thumbnail_grid_tiles_per_side * thumbnail_grid_tiles_per_side + 2) as f64); let max_height = 1080 / thumbnail_grid_tiles_per_side as u32; let max_width = 1920 / thumbnail_grid_tiles_per_side as u32; if generate_grid_instead_of_single { let frame_times = (0..(thumbnail_grid_tiles_per_side * thumbnail_grid_tiles_per_side)) .map(|i| duration_per_tile_items as f32 * (i + 1) as f32) .collect::>(); let mut imgs = Vec::new(); for ft in frame_times { if check_if_stop_received(stop_flag) { return Err(flc!("core_thumbnail_generation_stopped_by_user")); } match extract_frame_ffmpeg(video_path, ft, Some((max_width, max_height))) { Ok(img) => imgs.push(img), Err(e) => { let _ = fs::write(&thumbnail_path, b""); return Err(flc!("core_failed_to_extract_frame", time = ft, file = video_path.to_string_lossy(), reason = e)); } } } assert_eq!(imgs.len(), (thumbnail_grid_tiles_per_side * thumbnail_grid_tiles_per_side) as usize); let first_img = &imgs.first().expect("Cannot be empty here, because at least tiles_size^2 images are extracted"); if imgs.iter().any(|img| img.height() != first_img.height() || img.width() != first_img.width()) { let _ = fs::write(&thumbnail_path, b""); return Err(flc!("core_failed_to_generate_thumbnail_frames_different_dimensions", file = video_path.to_string_lossy())); } let mut new_thumbnail = RgbImage::new( first_img.width() * thumbnail_grid_tiles_per_side as u32, first_img.height() * thumbnail_grid_tiles_per_side as u32, ); for (idx, img) in imgs.iter().enumerate() { let x = (idx % thumbnail_grid_tiles_per_side as usize) as u32 * img.width(); let y = (idx / thumbnail_grid_tiles_per_side as usize) as u32 * img.height(); new_thumbnail .copy_from(img, x, y) .map_err(|e| flc!("core_failed_to_generate_thumbnail", file = video_path.to_string_lossy(), reason = e.to_string()))?; } if let Err(e) = new_thumbnail.save(&thumbnail_path) { let _ = fs::write(&thumbnail_path, b""); return Err(flc!("core_failed_to_save_thumbnail", file = video_path.to_string_lossy(), reason = e.to_string())); } } else { match extract_frame_ffmpeg(video_path, seek_time as f32, Some((max_width, max_height))) { Ok(img) => { if let Err(e) = img.save(&thumbnail_path) { let _ = fs::write(&thumbnail_path, b""); return Err(flc!("core_failed_to_save_thumbnail", file = video_path.to_string_lossy(), reason = e.to_string())); } } Err(e) => { let _ = fs::write(&thumbnail_path, b""); return Err(flc!( "core_failed_to_extract_frame_at_seek_time", time = seek_time, file = video_path.to_string_lossy(), reason = e )); } } } Ok(thumbnail_path) } czkawka_core-11.0.1/src/helpers/audio_checker.rs000064400000000000000000000030451046102023000177350ustar 00000000000000use std::fs::File; use std::io; use symphonia::core::codecs::CODEC_TYPE_NULL; use symphonia::core::errors::Error; use symphonia::core::errors::Error::IoError; use symphonia::core::io::MediaSourceStream; pub fn parse_audio_file(file_handler: File) -> Result<(), Error> { let mss = MediaSourceStream::new(Box::new(file_handler), Default::default()); let Ok(probed) = symphonia::default::get_probe().format(&Default::default(), mss, &Default::default(), &Default::default()) else { return Err(Error::Unsupported("probe info not available/file not recognized")); }; let mut format = probed.format; let Some(track) = format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL) else { return Err(Error::Unsupported("not supported audio track")); }; let Ok(mut decoder) = symphonia::default::get_codecs().make(&track.codec_params, &Default::default()) else { return Err(Error::Unsupported("not supported codec")); }; loop { let packet = match format.next_packet() { Ok(packet) => packet, Err(Error::ResetRequired) => { return Err(Error::ResetRequired); } Err(err) => { if let IoError(ref er) = err { // Catch eof, not sure how to do it properly if er.kind() == io::ErrorKind::UnexpectedEof { return Ok(()); } } return Err(err); } }; decoder.decode(&packet)?; } } czkawka_core-11.0.1/src/helpers/debug_timer.rs000064400000000000000000000121511046102023000174340ustar 00000000000000use std::time::{Duration, Instant}; /// Timer for measuring elapsed time between checkpoints. /// /// # How to use - examples /// /// Basic usage: /// ``` /// use czkawka_core::helpers::debug_timer::Timer; /// use std::thread::sleep; /// use std::time::Duration; /// /// let mut timer = Timer::new("MyTimer"); /// sleep(Duration::from_millis(50)); /// timer.checkpoint("step1"); /// sleep(Duration::from_millis(30)); /// timer.checkpoint("step2"); /// let report = timer.report("all_steps", false); /// println!("{}", report); /// ``` /// /// Output example: /// ```text /// MyTimer - step1: 50.0ms, /// MyTimer - step2: 30.0ms, /// MyTimer - all_steps: 80.0ms /// ``` /// /// One-line output: /// ``` /// use czkawka_core::helpers::debug_timer::Timer; /// use std::thread::sleep; /// use std::time::Duration; /// /// let mut timer = Timer::new("MyTimer"); /// sleep(Duration::from_millis(10)); /// timer.checkpoint("a"); /// sleep(Duration::from_millis(20)); /// timer.checkpoint("b"); /// let report = timer.report("total", true); /// println!("{}", report); /// ``` /// /// Output example: /// ```text /// MyTimer - a: 10.0ms, b: 20.0ms, total: 30.0ms /// ``` pub struct Timer { /// Name or label for the timer. base: String, /// Time when the timer was started. start_time: Instant, /// Time of the last checkpoint. last_time: Instant, /// List of (checkpoint name, duration since last checkpoint). times: Vec<(String, Duration)>, } impl Timer { /// Creates a new timer with a given label. pub fn new(base: &str) -> Self { Self { base: base.to_string(), start_time: Instant::now(), last_time: Instant::now(), times: Vec::new(), } } /// Records a checkpoint with the given name. pub fn checkpoint(&mut self, name: &str) { let elapsed = self.last_time.elapsed(); self.times.push((name.to_string(), elapsed)); self.last_time = Instant::now(); } /// Returns a formatted report of all checkpoints and total time. /// /// If `in_one_line` is true, outputs all checkpoints in a single line. /// Otherwise, outputs each checkpoint on a separate line. pub fn report(&mut self, all_steps_name: &str, in_one_line: bool) -> String { let all_elapsed = self.start_time.elapsed(); self.times.push((all_steps_name.to_string(), all_elapsed)); if in_one_line { let times = self.times.iter().map(|(name, time)| format!("{name}: {time:?}")).collect::>().join(", "); format!("{} - {}", self.base, times) } else { self.times .iter() .map(|(name, time)| format!("{} - {name}: {time:?}", self.base)) .collect::>() .join(", \n") } } } #[cfg(test)] mod tests { use std::thread::sleep; use super::*; #[test] fn test_timer_basic_functionality() { let mut timer = Timer::new("TestTimer"); assert_eq!(timer.base, "TestTimer"); assert_eq!(timer.times.len(), 0); sleep(Duration::from_millis(10)); timer.checkpoint("step1"); assert_eq!(timer.times.len(), 1); assert_eq!(timer.times[0].0, "step1"); sleep(Duration::from_millis(10)); timer.checkpoint("step2"); assert_eq!(timer.times.len(), 2); assert_eq!(timer.times[1].0, "step2"); } #[test] fn test_timer_report_multiline() { let mut timer = Timer::new("MultilineTimer"); sleep(Duration::from_millis(5)); timer.checkpoint("checkpoint1"); sleep(Duration::from_millis(5)); timer.checkpoint("checkpoint2"); let report = timer.report("total", false); assert!(report.contains("MultilineTimer - checkpoint1:")); assert!(report.contains("MultilineTimer - checkpoint2:")); assert!(report.contains("MultilineTimer - total:")); assert!(report.contains(", \n")); } #[test] fn test_timer_report_oneline() { let mut timer = Timer::new("OnelineTimer"); sleep(Duration::from_millis(5)); timer.checkpoint("a"); sleep(Duration::from_millis(5)); timer.checkpoint("b"); let report = timer.report("final", true); assert!(report.starts_with("OnelineTimer - ")); assert!(report.contains("a:")); assert!(report.contains("b:")); assert!(report.contains("final:")); assert!(report.contains(", ")); assert!(!report.contains("\n")); } #[test] fn test_timer_no_checkpoints() { let mut timer = Timer::new("EmptyTimer"); let report = timer.report("done", false); assert!(report.contains("EmptyTimer - done:")); assert_eq!(report.matches('\n').count(), 0); } #[test] fn test_timer_elapsed_time_accumulates() { let mut timer = Timer::new("AccumulateTimer"); sleep(Duration::from_millis(20)); timer.checkpoint("step1"); assert!(timer.times[0].1.as_millis() >= 15); sleep(Duration::from_millis(20)); timer.checkpoint("step2"); assert!(timer.times[1].1.as_millis() >= 15); } } czkawka_core-11.0.1/src/helpers/delayed_sender.rs000064400000000000000000000137511046102023000201240ustar 00000000000000//! DelayedSender: A utility for batching or throttling messages sent between threads. use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; /// A sender that delays sending values until a specified wait time has passed since the last sent value. /// /// This is useful for batching updates or reducing the frequency of sending messages in a multi-threaded environment. /// Note: Using mutexes in the send function from multiple threads can lead to performance issues (waiting for mutex release), /// but for now, the performance impact is minimal. In the future, a more efficient channel could be used. pub struct DelayedSender { slot: Arc>>, stop_flag: Arc, } impl DelayedSender { /// Creates a new DelayedSender. /// /// # Arguments /// * `sender` - The channel sender to forward values to. /// * `wait_time` - The minimum duration to wait between sends. pub fn new(sender: crossbeam_channel::Sender, wait_time: Duration) -> Self { let slot = Arc::new(Mutex::new(None)); let slot_clone = Arc::clone(&slot); let stop_flag = Arc::new(AtomicBool::new(false)); let stop_flag_clone = Arc::clone(&stop_flag); let _join = thread::spawn(move || { let mut last_send_time: Option = None; let duration_between_checks = Duration::from_secs_f64(wait_time.as_secs_f64() / 5.0); loop { if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { break; } if let Some(last_send_time) = last_send_time && last_send_time.elapsed() < wait_time { thread::sleep(duration_between_checks); continue; } let Some(value) = slot_clone.lock().expect("Failed to lock slot in DelayedSender").take() else { thread::sleep(duration_between_checks); continue; }; if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { break; } if let Err(e) = sender.send(value) { log::error!("Failed to send value: {e:?}"); } last_send_time = Some(Instant::now()); } }); Self { slot, stop_flag } } /// Sends a value, replacing any previous value that has not yet been sent. pub fn send(&self, value: T) { let mut slot = self.slot.lock().expect("Failed to lock slot in DelayedSender"); *slot = Some(value); } } impl Drop for DelayedSender { fn drop(&mut self) { // After dropping DelayedSender, no more values will be sent. // Previously, some values were cached and sent after later operations. self.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_delayed_sender_basic_send() { let (sender, receiver) = crossbeam_channel::unbounded(); let delayed_sender = DelayedSender::new(sender, Duration::from_millis(50)); delayed_sender.send(42); thread::sleep(Duration::from_millis(100)); let result = receiver.try_recv(); result.unwrap(); assert_eq!(result.unwrap(), 42); } #[test] fn test_delayed_sender_batching() { let (sender, receiver) = crossbeam_channel::unbounded(); let delayed_sender = DelayedSender::new(sender, Duration::from_millis(100)); delayed_sender.send(1); thread::sleep(Duration::from_millis(50)); let first = receiver.try_recv(); first.unwrap(); assert_eq!(first.unwrap(), 1); delayed_sender.send(2); thread::sleep(Duration::from_millis(10)); delayed_sender.send(3); thread::sleep(Duration::from_millis(10)); delayed_sender.send(4); thread::sleep(Duration::from_millis(150)); let result = receiver.try_recv(); result.unwrap(); assert_eq!(result.unwrap(), 4); let result2 = receiver.try_recv(); result2.unwrap_err(); } #[test] fn test_delayed_sender_multiple_sends() { let (sender, receiver) = crossbeam_channel::unbounded(); let delayed_sender = DelayedSender::new(sender, Duration::from_millis(50)); delayed_sender.send(10); thread::sleep(Duration::from_millis(100)); delayed_sender.send(20); thread::sleep(Duration::from_millis(100)); let first = receiver.try_recv(); first.unwrap(); assert_eq!(first.unwrap(), 10); let second = receiver.try_recv(); second.unwrap(); assert_eq!(second.unwrap(), 20); } #[test] fn test_delayed_sender_drop_stops_thread() { let (sender, receiver) = crossbeam_channel::unbounded(); { let delayed_sender = DelayedSender::new(sender, Duration::from_millis(50)); delayed_sender.send(100); } thread::sleep(Duration::from_millis(150)); let count = receiver.try_iter().count(); assert!(count <= 1); } #[test] fn test_delayed_sender_no_send_without_wait() { let (sender, receiver) = crossbeam_channel::unbounded(); let delayed_sender = DelayedSender::new(sender, Duration::from_millis(100)); delayed_sender.send(5); thread::sleep(Duration::from_millis(20)); let first = receiver.try_recv(); first.unwrap(); assert_eq!(first.unwrap(), 5); delayed_sender.send(10); thread::sleep(Duration::from_millis(20)); let result = receiver.try_recv(); result.unwrap_err(); // But should be sent after full wait_time thread::sleep(Duration::from_millis(100)); let result = receiver.try_recv(); result.unwrap(); assert_eq!(result.unwrap(), 10); } } czkawka_core-11.0.1/src/helpers/ffprobe.rs000064400000000000000000000225431046102023000165770ustar 00000000000000//! Simple wrapper for the [ffprobe](https://ffmpeg.org/ffprobe.html) CLI utility, //! which is part of the ffmpeg tool suite. //! //! This crate allows retrieving typed information about media files (images and videos) //! by invoking `ffprobe` with JSON output options and deserializing the data //! into convenient Rust types. //! //! //! //! ```rust, no_run //! use czkawka_core::helpers::ffprobe::ffprobe; //! match ffprobe("path/to/video.mp4") { //! Ok(info) => { //! dbg!(info); //! }, //! Err(err) => { //! eprintln!("Could not analyze file with ffprobe: {:?}", err); //! }, //! } //! ``` //! //! CODE IS COPIED FROM https://github.com/theduke/ffprobe-rs //! I WILL BE ABLE TO AGAIN USE IT AFTER A NEW VERSION IS RELEASED //! https://github.com/theduke/ffprobe-rs/issues/33 //! LICENSE: MIT #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; /// Execute ffprobe with default settings and return the extracted data. /// /// See [`ffprobe_config`] if you need to customize settings. pub fn ffprobe(path: impl AsRef) -> Result { ffprobe_config( Config { count_frames: false, ffprobe_bin: "ffprobe".into(), }, path, ) } /// Run ffprobe with a custom config. /// See [`ConfigBuilder`] for more details. pub fn ffprobe_config(config: Config, path: impl AsRef) -> Result { let path = path.as_ref(); let mut cmd = std::process::Command::new(config.ffprobe_bin); // Default args. cmd.args(["-v", "error", "-show_format", "-show_streams", "-print_format", "json"]); if config.count_frames { cmd.arg("-count_frames"); } cmd.arg(path); // Prevent CMD popup on Windows. #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); let out = cmd.output().map_err(FfProbeError::Io)?; if !out.status.success() { return Err(FfProbeError::Status(out)); } serde_json::from_slice::(&out.stdout).map_err(FfProbeError::Deserialize) } /// ffprobe configuration. /// /// Use [`Config::builder`] for constructing a new config. #[derive(Clone, Debug)] pub struct Config { count_frames: bool, ffprobe_bin: std::path::PathBuf, } impl Config { /// Construct a new ConfigBuilder. pub fn builder() -> ConfigBuilder { ConfigBuilder::new() } } /// Build the ffprobe configuration. pub struct ConfigBuilder { config: Config, } impl ConfigBuilder { pub fn new() -> Self { Self { config: Config { count_frames: false, ffprobe_bin: "ffprobe".into(), }, } } /// Enable the -count_frames setting. /// Will fully decode the file and count the frames. /// Frame count will be available in [`Stream::nb_read_frames`]. pub fn count_frames(mut self, count_frames: bool) -> Self { self.config.count_frames = count_frames; self } /// Specify which binary name (e.g. `"ffprobe-6"`) or path (e.g. `"/opt/bin/ffprobe"`) to use /// for executing `ffprobe`. pub fn ffprobe_bin(mut self, ffprobe_bin: impl AsRef) -> Self { self.config.ffprobe_bin = ffprobe_bin.as_ref().to_path_buf(); self } /// Finalize the builder into a [`Config`]. pub fn build(self) -> Config { self.config } /// Run ffprobe with the config produced by this builder. pub fn run(self, path: impl AsRef) -> Result { ffprobe_config(self.config, path) } } impl Default for ConfigBuilder { fn default() -> Self { Self::new() } } #[derive(Debug)] #[non_exhaustive] pub enum FfProbeError { Io(std::io::Error), Status(std::process::Output), Deserialize(serde_json::Error), } impl std::fmt::Display for FfProbeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => e.fmt(f), Self::Status(o) => { write!(f, "ffprobe exited with status code {}: {}", o.status, String::from_utf8_lossy(&o.stderr)) } Self::Deserialize(e) => e.fmt(f), } } } impl std::error::Error for FfProbeError {} #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FfProbe { pub streams: Vec, pub format: Format, } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Stream { pub index: i64, pub codec_name: Option, pub sample_aspect_ratio: Option, pub display_aspect_ratio: Option, pub color_range: Option, pub color_space: Option, pub bits_per_raw_sample: Option, pub channel_layout: Option, pub max_bit_rate: Option, pub nb_frames: Option, /// Number of frames seen by the decoder. /// Requires full decoding and is only available if the 'count_frames' /// setting was enabled. pub nb_read_frames: Option, pub codec_long_name: Option, pub codec_type: Option, pub codec_time_base: Option, pub codec_tag_string: String, pub codec_tag: String, pub sample_fmt: Option, pub sample_rate: Option, pub channels: Option, pub bits_per_sample: Option, pub r_frame_rate: String, pub avg_frame_rate: String, pub time_base: String, pub start_pts: Option, pub start_time: Option, pub duration_ts: Option, pub duration: Option, pub bit_rate: Option, pub disposition: Disposition, pub tags: Option, pub profile: Option, pub width: Option, pub height: Option, pub coded_width: Option, pub coded_height: Option, pub closed_captions: Option, pub has_b_frames: Option, pub pix_fmt: Option, pub level: Option, pub chroma_location: Option, pub refs: Option, pub is_avc: Option, pub nal_length: Option, pub nal_length_size: Option, pub field_order: Option, pub id: Option, #[serde(default)] pub side_data_list: Vec, } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] // Allowed to prevent having to break compatibility of float fields are added. #[expect(clippy::derive_partial_eq_without_eq)] pub struct SideData { pub side_data_type: String, pub rotation: Option, } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] // Allowed to prevent having to break compatibility of float fields are added. #[expect(clippy::derive_partial_eq_without_eq)] pub struct Disposition { pub default: i64, pub dub: i64, pub original: i64, pub comment: i64, pub lyrics: i64, pub karaoke: i64, pub forced: i64, pub hearing_impaired: i64, pub visual_impaired: i64, pub clean_effects: i64, pub attached_pic: i64, pub timed_thumbnails: i64, } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] // Allowed to prevent having to break compatibility of float fields are added. #[expect(clippy::derive_partial_eq_without_eq)] pub struct StreamTags { pub language: Option, pub creation_time: Option, pub handler_name: Option, pub encoder: Option, pub timecode: Option, pub reel_name: Option, } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Format { pub filename: String, pub nb_streams: i64, pub nb_programs: i64, pub format_name: String, pub format_long_name: Option, pub start_time: Option, pub duration: Option, pub size: Option, pub bit_rate: Option, pub probe_score: i64, pub tags: Option, } impl Format { /// Get the duration parsed into a [`std::time::Duration`]. pub fn try_get_duration(&self) -> Option> { self.duration.as_ref().map(|duration| match duration.parse::() { Ok(num) => Ok(std::time::Duration::from_secs_f64(num)), Err(error) => Err(error), }) } /// Get the duration parsed into a [`std::time::Duration`]. /// /// Will return [`None`] if no duration is available, or if parsing fails. /// See [`Self::try_get_duration`] for a method that returns an error. pub fn get_duration(&self) -> Option { self.try_get_duration()?.ok() } } #[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FormatTags { #[serde(rename = "WMFSDKNeeded")] pub wmfsdkneeded: Option, #[serde(rename = "DeviceConformanceTemplate")] pub device_conformance_template: Option, #[serde(rename = "WMFSDKVersion")] pub wmfsdkversion: Option, #[serde(rename = "IsVBR")] pub is_vbr: Option, pub major_brand: Option, pub minor_version: Option, pub compatible_brands: Option, pub creation_time: Option, pub encoder: Option, #[serde(flatten)] pub extra: std::collections::HashMap, } czkawka_core-11.0.1/src/helpers/messages.rs000064400000000000000000000174721046102023000167700ustar 00000000000000//! Messages: Utility for collecting and printing messages, warnings, and errors. use crate::flc; /// Stores messages, warnings, and errors for reporting. #[derive(Debug, Default, Clone)] pub struct Messages { pub critical: Option, /// Informational messages. pub messages: Vec, /// Warning messages. pub warnings: Vec, /// Error messages. pub errors: Vec, } #[derive(Debug, Clone, Copy)] pub enum MessageLimit { NoLimit, Characters(usize), Lines(usize), } impl Messages { /// Creates a new, empty `Messages` struct. pub fn new() -> Self { Default::default() } /// Creates a new `Messages` struct with errors. pub fn new_from_errors(errors: Vec) -> Self { Self { errors, ..Default::default() } } /// Creates a new `Messages` struct with warnings. pub fn new_from_warnings(warnings: Vec) -> Self { Self { warnings, ..Default::default() } } /// Creates a new `Messages` struct with messages. pub fn new_from_messages(messages: Vec) -> Self { Self { messages, ..Default::default() } } /// Prints all messages, warnings, and errors to the provided writer. pub fn print_messages_to_writer(&self, writer: &mut T) -> std::io::Result<()> { let text = self.create_messages_text(MessageLimit::NoLimit); writer.write_all(text.as_bytes()) } /// Creates a formatted string containing all messages, warnings, and errors. pub fn create_messages_text(&self, limit: MessageLimit) -> String { let mut text_to_return: String = String::new(); if let Some(critical) = &self.critical { text_to_return += "------------------------------CRITICAL ERROR---------------------------\n"; text_to_return += critical; text_to_return += "\n"; text_to_return += "--------------------------END OF CRITICAL ERROR------------------------\n"; } if !self.errors.is_empty() { text_to_return += "--------------------------------ERRORS---------------------------------\n"; for i in &self.errors { text_to_return += i; text_to_return += "\n"; } text_to_return += "----------------------------END OF ERRORS------------------------------\n"; } if !self.messages.is_empty() { text_to_return += "-------------------------------MESSAGES--------------------------------\n"; for i in &self.messages { text_to_return += i; text_to_return += "\n"; } text_to_return += "---------------------------END OF MESSAGES-----------------------------\n"; } if !self.warnings.is_empty() { text_to_return += "-------------------------------WARNINGS--------------------------------\n"; for i in &self.warnings { text_to_return += i; text_to_return += "\n"; } text_to_return += "---------------------------END OF WARNINGS-----------------------------\n"; } let mut text_to_return = text_to_return.trim().to_string(); match limit { MessageLimit::NoLimit => {} MessageLimit::Characters(max_chars) => { let char_count = text_to_return.chars().count(); if char_count > max_chars { let truncated: String = text_to_return.chars().take(max_chars).collect(); text_to_return = truncated; text_to_return += "\n\n"; text_to_return += &flc!("core_messages_limit_reached_characters", current = char_count, limit = max_chars); text_to_return += "\n"; } } MessageLimit::Lines(max_lines) => { let line_count = text_to_return.lines().count(); if line_count > max_lines { let lines: Vec<&str> = text_to_return.lines().take(max_lines).collect(); text_to_return = lines.join("\n"); text_to_return += "\n\n"; text_to_return += &flc!("core_messages_limit_reached_lines", current = line_count, limit = max_lines); text_to_return += "\n"; } } } text_to_return } /// Extends this `Messages` struct with another, appending all messages, warnings, and errors. pub fn extend_with_another_messages(&mut self, messages: Self) { let (messages, warnings, errors, critical) = (messages.messages, messages.warnings, messages.errors, messages.critical); self.messages.extend(messages); self.warnings.extend(warnings); self.errors.extend(errors); if critical.is_some() { self.critical = critical; } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_messages_constructors_and_text_formatting() { // Test new() let msg = Messages::new(); assert!(msg.messages.is_empty()); assert!(msg.warnings.is_empty()); assert!(msg.errors.is_empty()); assert_eq!(msg.create_messages_text(MessageLimit::NoLimit), ""); // Test new_from_errors() let errors = vec!["Error 1".to_string(), "Error 2".to_string()]; let msg = Messages::new_from_errors(errors.clone()); assert_eq!(msg.errors, errors); let text = msg.create_messages_text(MessageLimit::NoLimit); assert!(text.contains("ERRORS")); assert!(text.contains("Error 1")); // Test new_from_warnings() let warnings = vec!["Warning 1".to_string()]; let msg = Messages::new_from_warnings(warnings.clone()); assert_eq!(msg.warnings, warnings); let text = msg.create_messages_text(MessageLimit::NoLimit); assert!(text.contains("WARNINGS")); // Test new_from_messages() let messages = vec!["Message 1".to_string()]; let msg = Messages::new_from_messages(messages.clone()); assert_eq!(msg.messages, messages); let text = msg.create_messages_text(MessageLimit::NoLimit); assert!(text.contains("MESSAGES")); // Test all types together let mut msg = Messages::new(); msg.messages.push("Info".to_string()); msg.warnings.push("Warn".to_string()); msg.errors.push("Err".to_string()); let text = msg.create_messages_text(MessageLimit::NoLimit); assert!(text.contains("MESSAGES")); assert!(text.contains("Info")); assert!(text.contains("WARNINGS")); assert!(text.contains("Warn")); assert!(text.contains("ERRORS")); assert!(text.contains("Err")); } #[test] fn test_extend_and_writer() { // Test extend_with_another_messages() let mut msg1 = Messages::new(); msg1.messages.push("Msg1".to_string()); msg1.warnings.push("Warn1".to_string()); msg1.errors.push("Err1".to_string()); let mut msg2 = Messages::new(); msg2.messages.push("Msg2".to_string()); msg2.warnings.push("Warn2".to_string()); msg2.errors.push("Err2".to_string()); msg1.extend_with_another_messages(msg2); assert_eq!(msg1.messages.len(), 2); assert_eq!(msg1.warnings.len(), 2); assert_eq!(msg1.errors.len(), 2); assert!(msg1.messages.contains(&"Msg1".to_string())); assert!(msg1.messages.contains(&"Msg2".to_string())); // Test print_messages_to_writer() let mut buffer = Vec::new(); let result = msg1.print_messages_to_writer(&mut buffer); result.unwrap(); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("Msg1")); assert!(output.contains("Warn2")); assert!(output.contains("Err1")); } } czkawka_core-11.0.1/src/helpers/mod.rs000064400000000000000000000003071046102023000157250ustar 00000000000000//! Helper modules: generic utilities, traits, structs, ready to copy/paste to other projects. pub mod audio_checker; pub mod debug_timer; pub mod delayed_sender; pub mod ffprobe; pub mod messages; czkawka_core-11.0.1/src/lib.rs000064400000000000000000000004401046102023000142500ustar 00000000000000pub mod common; pub mod helpers; pub mod localizer_core; pub mod tools; pub mod re_exported { pub use image_hasher::{FilterType, HashAlg}; pub use vid_dup_finder_lib::Cropdetect; } pub const CZKAWKA_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const TOOLS_NUMBER: usize = 14; czkawka_core-11.0.1/src/localizer_core.rs000064400000000000000000000023701046102023000165020ustar 00000000000000use std::collections::HashMap; use i18n_embed::fluent::{FluentLanguageLoader, fluent_language_loader}; use i18n_embed::{DefaultLocalizer, LanguageLoader, Localizer}; use rust_embed::RustEmbed; #[derive(RustEmbed)] #[folder = "i18n/"] struct Localizations; pub static LANGUAGE_LOADER_CORE: std::sync::LazyLock = std::sync::LazyLock::new(|| { let loader: FluentLanguageLoader = fluent_language_loader!(); loader.load_fallback_language(&Localizations).expect("Error while loading fallback language"); loader }); #[macro_export] macro_rules! flc { ( $($tt:tt)* ) => {{ i18n_embed_fl::fl!($crate::localizer_core::LANGUAGE_LOADER_CORE, $($tt)*) }}; } pub fn localizer_core() -> Box { Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER_CORE, &Localizations)) } pub fn generate_translation_hashmap(vec: Vec<(&'static str, String)>) -> HashMap<&'static str, String> { let mut hashmap: HashMap<&'static str, String> = Default::default(); for (key, value) in vec { hashmap.insert(key, value); } hashmap } pub fn fnc_get_similarity_very_high() -> String { flc!("core_similarity_very_high") } pub fn fnc_get_similarity_minimal() -> String { flc!("core_similarity_minimal") } czkawka_core-11.0.1/src/tools/bad_extensions/core.rs000064400000000000000000000216741046102023000206130ustar 00000000000000use std::collections::BTreeSet; use std::mem; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use crossbeam_channel::Sender; use fun_time::fun_time; use indexmap::IndexMap; use log::debug; use mime_guess::get_mime_extensions; use rayon::prelude::*; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{FileEntry, ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::tools::bad_extensions::workarounds::{DISABLED_EXTENSIONS, WORKAROUNDS}; use crate::tools::bad_extensions::{BadExtensions, BadExtensionsParameters, BadFileEntry, Info}; // Text longer than 10 characters is not considered as extension const MAX_EXTENSION_LENGTH: usize = 10; impl BadExtensions { pub fn new(params: BadExtensionsParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::BadExtensions), information: Info::default(), files_to_check: Default::default(), bad_extensions_files: Default::default(), params, } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.files_to_check = grouped_file_entries.into_values().flatten().collect(); self.common_data.text_messages.warnings.extend(warnings); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "look_for_bad_extensions_files", level = "debug")] pub(crate) fn look_for_bad_extensions_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.files_to_check.is_empty() { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::BadExtensionsChecking, self.files_to_check.len(), self.get_test_type(), 0); let files_to_check = mem::take(&mut self.files_to_check); let mut workarounds: IndexMap<&str, Vec<&str>> = Default::default(); for (proper, found) in WORKAROUNDS { workarounds.entry(found).or_default().push(proper); } self.bad_extensions_files = self.verify_extensions(files_to_check, progress_handler.items_counter(), stop_flag, &workarounds); progress_handler.join_thread(); // Break if stop was clicked if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } self.information.number_of_files_with_bad_extension = self.bad_extensions_files.len(); debug!("Found {} files with invalid extension.", self.information.number_of_files_with_bad_extension); WorkContinueStatus::Continue } fn verify_extension_of_file(&self, file_entry: FileEntry, workarounds: &IndexMap<&str, Vec<&str>>) -> Option { // Check what exactly content file contains let kind = match infer::get_from_path(&file_entry.path) { Ok(k) => k?, Err(_) => return None, }; let proper_extension = kind.extension(); let current_extension = Self::get_and_validate_extension(&file_entry, proper_extension)?; // Check for all extensions that file can use(not sure if it is worth to do it) let (mut all_available_extensions, valid_extensions) = Self::check_for_all_extensions_that_file_can_use(workarounds, ¤t_extension, proper_extension); if all_available_extensions.is_empty() { // Not found any extension return None; } else if current_extension.is_empty() { if !self.params.include_files_without_extension { return None; } } else if all_available_extensions.take(¤t_extension).is_some() { // Found proper extension return None; } Some(BadFileEntry { path: file_entry.path, modified_date: file_entry.modified_date, size: file_entry.size, current_extension, proper_extensions_group: valid_extensions, proper_extension: proper_extension.to_string(), }) } #[fun_time(message = "verify_extensions", level = "debug")] fn verify_extensions( &self, files_to_check: Vec, items_counter: &Arc, stop_flag: &Arc, workarounds: &IndexMap<&str, Vec<&str>>, ) -> Vec { files_to_check .into_par_iter() .map(|file_entry| { if check_if_stop_received(stop_flag) { return None; } let res = self.verify_extension_of_file(file_entry, workarounds); items_counter.fetch_add(1, Ordering::Relaxed); Some(res) }) .while_some() .flatten() .collect::>() } fn get_and_validate_extension(file_entry: &FileEntry, proper_extension: &str) -> Option { let current_extension; // Extract current extension from file if let Some(extension) = file_entry.path.extension() { let extension = extension.to_string_lossy().to_lowercase(); if DISABLED_EXTENSIONS.contains(&extension.as_str()) { return None; } if extension.len() > MAX_EXTENSION_LENGTH { current_extension = String::new(); } else { current_extension = extension; } } else { current_extension = String::new(); } // Already have proper extension, no need to do more things if current_extension == proper_extension { return None; } Some(current_extension) } fn check_for_all_extensions_that_file_can_use(workarounds: &IndexMap<&str, Vec<&str>>, current_extension: &str, proper_extension: &str) -> (BTreeSet, String) { let mut all_available_extensions: BTreeSet = Default::default(); for mim in mime_guess::from_ext(proper_extension) { if let Some(all_ext) = get_mime_extensions(&mim) { for ext in all_ext { all_available_extensions.insert((*ext).to_string()); } } } // Workarounds: if !current_extension.is_empty() && let Some(vec_pre) = workarounds.get(current_extension) { for pre in vec_pre { if all_available_extensions.contains(*pre) { all_available_extensions.insert(current_extension.to_string()); break; } } } let valid_extensions = if all_available_extensions.is_empty() { String::new() } else { let mut guessed_multiple_extensions = format!("({proper_extension}) - "); for ext in &all_available_extensions { guessed_multiple_extensions.push_str(ext); guessed_multiple_extensions.push(','); } guessed_multiple_extensions.pop(); guessed_multiple_extensions }; (all_available_extensions, valid_extensions) } #[fun_time(message = "fix_bad_extensions", level = "debug")] pub fn fix_bad_extensions(&mut self, _fix_params: super::BadExtensionsFixParams, stop_flag: &Arc) { let warnings: Vec<_> = mem::take(&mut self.bad_extensions_files) .into_par_iter() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } let new_path = entry.path.with_extension(&entry.proper_extension); if new_path.exists() { return Some(Some(format!("Cannot rename {:?} to {:?}: target file already exists", entry.path, new_path))); } match std::fs::rename(&entry.path, &new_path) { Ok(()) => Some(None), Err(e) => Some(Some(format!("Failed to rename {:?} to {:?}: {}", entry.path, new_path, e))), } }) .while_some() .flatten() .collect(); self.common_data.text_messages.warnings.extend(warnings); } } czkawka_core-11.0.1/src/tools/bad_extensions/mod.rs000064400000000000000000000031621046102023000204320ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; mod workarounds; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::Serialize; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; #[derive(Clone, Serialize, Debug)] pub struct BadFileEntry { pub path: PathBuf, pub modified_date: u64, pub size: u64, pub current_extension: String, pub proper_extensions_group: String, pub proper_extension: String, } impl ResultEntry for BadFileEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_files_with_bad_extension: usize, pub scanning_time: Duration, } #[derive(Clone, Debug, Default, Copy)] pub struct BadExtensionsFixParams {} #[derive(Clone)] pub struct BadExtensionsParameters { pub include_files_without_extension: bool, } impl BadExtensionsParameters { pub fn new() -> Self { Self { include_files_without_extension: false, } } } impl Default for BadExtensionsParameters { fn default() -> Self { Self::new() } } pub struct BadExtensions { common_data: CommonToolData, information: Info, files_to_check: Vec, bad_extensions_files: Vec, params: BadExtensionsParameters, } impl BadExtensions { pub const fn get_bad_extensions_files(&self) -> &Vec { &self.bad_extensions_files } } czkawka_core-11.0.1/src/tools/bad_extensions/tests.rs000064400000000000000000000075221046102023000210210ustar 00000000000000use std::fs; use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::bad_extensions::{BadExtensions, BadExtensionsParameters}; #[test] fn test_find_bad_extension_png_as_jpg() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create a PNG file with .jpg extension let png_data = vec![ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR chunk ]; let mut file = fs::File::create(path.join("image.jpg")).unwrap(); file.write_all(&png_data).unwrap(); let params = BadExtensionsParameters::new(); let mut finder = BadExtensions::new(params); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let bad_files = finder.get_bad_extensions_files(); assert_eq!(bad_files.len(), 1, "Should find 1 file with bad extension"); assert_eq!(bad_files[0].current_extension, "jpg", "Current extension should be jpg"); assert_eq!(bad_files[0].proper_extension, "png", "Proper extension should be png"); } #[test] fn test_correct_extension() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create a PNG file with correct .png extension let png_data = vec![ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, ]; let mut file = fs::File::create(path.join("image.png")).unwrap(); file.write_all(&png_data).unwrap(); let params = BadExtensionsParameters::new(); let mut finder = BadExtensions::new(params); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let bad_files = finder.get_bad_extensions_files(); assert_eq!(bad_files.len(), 0, "Should find no files with bad extension"); } #[test] fn test_file_without_extension_excluded() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create a PNG file without extension let png_data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D]; let mut file = fs::File::create(path.join("image_no_ext")).unwrap(); file.write_all(&png_data).unwrap(); let mut params = BadExtensionsParameters::new(); params.include_files_without_extension = false; let mut finder = BadExtensions::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let bad_files = finder.get_bad_extensions_files(); assert_eq!(bad_files.len(), 0, "Should not include files without extension when disabled"); } #[test] fn test_file_without_extension_included() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create a PNG file without extension let png_data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D]; let mut file = fs::File::create(path.join("image_no_ext")).unwrap(); file.write_all(&png_data).unwrap(); let mut params = BadExtensionsParameters::new(); params.include_files_without_extension = true; let mut finder = BadExtensions::new(params); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let bad_files = finder.get_bad_extensions_files(); assert_eq!(bad_files.len(), 1, "Should include files without extension when enabled"); assert_eq!(bad_files[0].current_extension, "", "Current extension should be empty"); assert_eq!(bad_files[0].proper_extension, "png"); } czkawka_core-11.0.1/src/tools/bad_extensions/traits.rs000064400000000000000000000075621046102023000211710ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, FixingItems, PrintResults, Search}; use crate::tools::bad_extensions::{BadExtensions, BadExtensionsFixParams, BadExtensionsParameters, Info}; impl AllTraits for BadExtensions {} impl Search for BadExtensions { #[fun_time(message = "find_bad_extensions_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.look_for_bad_extensions_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DeletingItems for BadExtensions { fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFolders(self.bad_extensions_files.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } impl FixingItems for BadExtensions { type FixParams = BadExtensionsFixParams; #[fun_time(message = "fix_items", level = "debug")] fn fix_items(&mut self, stop_flag: &Arc, _progress_sender: Option<&Sender>, fix_params: Self::FixParams) { self.fix_bad_extensions(fix_params, stop_flag); } } impl DebugPrint for BadExtensions { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for BadExtensions { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; writeln!(writer, "Found {} files with invalid extension.\n", self.information.number_of_files_with_bad_extension)?; for file_entry in &self.bad_extensions_files { writeln!(writer, "\"{}\" ----- {}", file_entry.path.to_string_lossy(), file_entry.proper_extensions_group)?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.bad_extensions_files, pretty_print) } } impl CommonData for BadExtensions { type Info = Info; type Parameters = BadExtensionsParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.get_information().number_of_files_with_bad_extension > 0 } } czkawka_core-11.0.1/src/tools/bad_extensions/workarounds.rs000064400000000000000000000122251046102023000222310ustar 00000000000000pub(crate) const DISABLED_EXTENSIONS: &[&str] = &["file", "cache", "bak", "data", "tmp"]; // Such files can have any type inside // This adds several workarounds for bugs/invalid recognizing types by external libraries // ("real_content_extension", "current_file_extension") pub(crate) const WORKAROUNDS: &[(&str, &str)] = &[ // Wine/Windows ("der", "cat"), ("exe", "acm"), ("exe", "ax"), ("exe", "bck"), ("exe", "com"), ("exe", "cpl"), ("exe", "dll16"), ("exe", "dll"), ("exe", "drv16"), ("exe", "drv"), ("exe", "ds"), ("exe", "efi"), ("exe", "exe16"), ("exe", "fon"), // Type of font or something else ("exe", "mod16"), ("exe", "msstyles"), ("exe", "mui"), ("exe", "mun"), ("exe", "orig"), ("exe", "ps1xml"), ("exe", "rll"), ("exe", "rs"), ("exe", "scr"), ("exe", "signed"), ("exe", "sys"), ("exe", "tlb"), ("exe", "tsp"), ("exe", "vdm"), ("exe", "vxd"), ("exe", "winmd"), ("gz", "loggz"), ("xml", "adml"), ("xml", "admx"), ("xml", "camp"), ("xml", "cdmp"), ("xml", "cdxml"), ("xml", "dgml"), ("xml", "diagpkg"), ("xml", "gmmp"), ("xml", "library-ms"), ("xml", "man"), ("xml", "manifest"), ("xml", "msc"), ("xml", "mum"), ("xml", "resx"), ("zip", "msix"), ("zip", "wmz"), // Games specific extensions - cannot be used here common extensions like zip ("gz", "h3m"), // Heroes 3 ("zip", "hashdb"), // Gog ("c2", "zip"), // King of the Dark Age ("c2", "bmp"), // King of the Dark Age ("c2", "avi"), // King of the Dark Age ("c2", "exe"), // King of the Dark Age // Raw images ("tif", "nef"), ("tif", "dng"), ("tif", "arw"), // Other ("der", "keystore"), // Godot/Android keystore ("exe", "pyd"), // Python/Mingw ("gz", "blend"), // Blender ("gz", "crate"), // Cargo ("gz", "svgz"), // Archive svg ("gz", "tgz"), // Archive ("heic", "heif"), // Image ("heif", "heic"), // Image ("html", "dtd"), // Mingw ("html", "ent"), // Mingw ("html", "md"), // Markdown ("html", "svelte"), // Svelte ("jpg", "jfif"), // Photo format ("m4v", "mp4"), // m4v and mp4 are interchangeable ("mobi", "azw3"), // Ebook format ("mpg", "vob"), // Weddings in parts have usually vob extension ("obj", "bin"), // Multiple apps, Czkawka, Nvidia, Windows ("obj", "o"), // Compilators ("odp", "otp"), // LibreOffice ("ods", "ots"), // Libreoffice ("odt", "ott"), // Libreoffice ("ogg", "ogv"), // Audio format ("pem", "key"), // curl, openssl ("png", "kpp"), // Krita presets ("pptx", "ppsx"), // Powerpoint ("sh", "bash"), // Linux ("sh", "guess"), // GNU ("sh", "lua"), // Lua ("sh", "js"), // Javascript ("sh", "pl"), // Gnome/Linux ("sh", "pm"), // Gnome/Linux ("sh", "py"), // Python ("sh", "pyx"), // Python ("sh", "rs"), // Rust ("sh", "sample"), // Git ("xml", "bsp"), // Quartus ("xml", "cbp"), // CodeBlocks config ("xml", "cfg"), // Multiple apps - Godot ("xml", "cmb"), // Cambalache ("xml", "conf"), // Multiple apps - Python ("xml", "config"), // Multiple apps - QT Creator ("xml", "dae"), // 3D models ("xml", "docbook"), // ("xml", "fb2"), // ("xml", "filters"), // Visual studio ("xml", "gir"), // GTK ("xml", "glade"), // Glade ("xml", "iml"), // Intelij Idea ("xml", "kdenlive"), // KDenLive ("xml", "lang"), // ? ("xml", "nuspec"), // Nuget ("xml", "policy"), // SystemD ("xml", "qsys"), // Quartus ("xml", "sopcinfo"), // Quartus ("xml", "svg"), // SVG ("xml", "ui"), // Cambalache, Glade ("xml", "user"), // Qtcreator ("xml", "vbox"), // VirtualBox ("xml", "vbox-prev"), // VirtualBox ("xml", "vcproj"), // VisualStudio ("xml", "vcxproj"), // VisualStudio ("xml", "xba"), // Libreoffice ("xml", "xcd"), // Libreoffice files ("zip", "apk"), // Android apk ("zip", "cbz"), // Comics ("zip", "dat"), // Multiple - python, brave ("zip", "doc"), // Word ("zip", "docx"), // Word ("zip", "epub"), // Ebook format ("zip", "jar"), // Java ("zip", "kra"), // Krita ("zip", "kgm"), // Krita ("zip", "nupkg"), // Nuget packages ("zip", "odg"), // Libreoffice ("zip", "pptx"), // Powerpoint ("zip", "whl"), // Python packages ("zip", "xlsx"), // Excel ("zip", "xpi"), // Firefox extensions ("zip", "zcos"), // Scilab // Probably invalid ("html", "svg"), ("xml", "html"), // Probably bug in external library ("msi", "ppt"), // Not sure why ppt is not recognized ("msi", "doc"), // Not sure why doc is not recognized ("exe", "xls"), // Not sure why xls is not recognized ]; czkawka_core-11.0.1/src/tools/bad_names/core.rs000064400000000000000000000213561046102023000175140ustar 00000000000000use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::{fs, mem}; use crossbeam_channel::Sender; use fun_time::fun_time; use log::debug; use rayon::prelude::*; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::tools::bad_names::{BadNameEntry, BadNames, BadNamesParameters, Info, NameFixerParams, NameIssues}; impl BadNames { pub fn new(params: BadNamesParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::BadNames), information: Info::default(), files_to_check: Default::default(), bad_names_files: Default::default(), params, } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.files_to_check = grouped_file_entries.into_values().flatten().collect(); self.common_data.text_messages.warnings.extend(warnings); debug!("check_files - Found {} files to check.", self.files_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "look_for_bad_names_files", level = "debug")] pub(crate) fn look_for_bad_names_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.files_to_check.is_empty() { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::BadNamesChecking, self.files_to_check.len(), self.get_test_type(), self.files_to_check.iter().map(|item| item.size).sum::(), ); let files_to_check = std::mem::take(&mut self.files_to_check); let checked_issues = self.params.checked_issues.clone(); debug!("look_for_bad_names_files - started checking for bad names"); let bad_names_files: Vec = files_to_check .into_par_iter() .filter_map(|file_entry| { if check_if_stop_received(stop_flag) { return None; } let size = file_entry.size; let result = check_and_generate_new_name(&file_entry.path, &checked_issues).map(|new_name| BadNameEntry { path: file_entry.path, modified_date: file_entry.modified_date, size: file_entry.size, new_name, }); progress_handler.increase_items(1); progress_handler.increase_size(size); result }) .collect(); debug!("look_for_bad_names_files - ended checking for bad names"); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } self.bad_names_files = bad_names_files; self.information.number_of_files_with_bad_names = self.bad_names_files.len(); debug!("Found {} files with bad names.", self.information.number_of_files_with_bad_names); WorkContinueStatus::Continue } #[fun_time(message = "fix_bad_names", level = "debug")] pub fn fix_bad_names(&mut self, _fix_params: NameFixerParams, stop_flag: &Arc) { let warnings: Vec<_> = mem::take(&mut self.bad_names_files) .into_par_iter() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } let new_path = entry.path.with_file_name(&entry.new_name); match fs::rename(&entry.path, &new_path) { Ok(()) => Some(None), Err(e) => Some(Some(format!("Failed to rename {:?}: {}", entry.path, e))), } }) .while_some() .flatten() .collect(); self.common_data.text_messages.warnings.extend(warnings); } } // Check file name against NameIssues and generate a new fixed name if issues are found pub fn check_and_generate_new_name(path: &Path, checked_issues: &NameIssues) -> Option { let file_name = path.file_name()?.to_string_lossy(); let mut stem = path.file_stem()?.to_string_lossy().to_string(); let mut extension = path.extension().map(|e| e.to_string_lossy().to_string()); if checked_issues.uppercase_extension && let Some(ref mut ext) = extension && ext.chars().any(|c| c.is_uppercase()) { *ext = ext.to_lowercase(); } if checked_issues.emoji_used { stem = stem.chars().filter(|c| !is_emoji(*c)).collect(); if let Some(ref mut ext) = extension { *ext = ext.chars().filter(|c| !is_emoji(*c)).collect(); } } if checked_issues.non_ascii_graphical { stem = deunicode::deunicode(&stem); if let Some(ref mut ext) = extension { *ext = deunicode::deunicode(ext).chars().filter(|e| e.is_ascii_graphic() || *e == ' ').collect(); } } if let Some(allowed_chars) = &checked_issues.restricted_charset_allowed { stem = deunicode::deunicode(&stem).chars().filter(|c| is_alphanumeric(*c) || allowed_chars.contains(c)).collect(); if let Some(ref mut ext) = extension { *ext = deunicode::deunicode(ext).chars().filter(|c| is_alphanumeric(*c) || allowed_chars.contains(c)).collect(); } } if checked_issues.remove_duplicated_non_alphanumeric { stem = remove_duplicated_non_alphanumeric(&stem); if let Some(ref mut ext) = extension { *ext = remove_duplicated_non_alphanumeric(ext); } } if checked_issues.space_at_start_or_end { stem = stem.trim().to_string(); if let Some(ref mut ext) = extension { *ext = ext.trim().to_string(); } } let new_name = if let Some(ext) = extension { if ext.is_empty() { stem } else { format!("{stem}.{ext}") } } else { stem }; if new_name != file_name.as_ref() as &str { Some(new_name) } else { None } } fn is_alphanumeric(c: char) -> bool { c.is_ascii_alphanumeric() } fn remove_duplicated_non_alphanumeric(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { result.push(c); if !c.is_ascii_alphanumeric() { // Skip consecutive identical non-alphanumeric characters while let Some(&next_c) = chars.peek() { if next_c == c { chars.next(); } else { break; } } } } result } fn is_emoji(c: char) -> bool { let code = c as u32; matches!(code, // Misc symbols + pictographs 0x231A..=0x231B | 0x23E9..=0x23EC | 0x23F0 | 0x23F3 | 0x25FD..=0x25FE | 0x2600..=0x2604 | 0x2614..=0x2615 | 0x2648..=0x2653 | 0x267F | 0x2693 | 0x26A1 | 0x26AA..=0x26AB | 0x26BD..=0x26BE | 0x26C4..=0x26C8 | 0x26CE | 0x26D4 | 0x26EA | 0x26F2..=0x26F3 | 0x26F5 | 0x26FA | 0x26FD | 0x2705 | 0x270A..=0x270B | 0x2728 | 0x274C | 0x274E | 0x2753..=0x2757 | 0x2763..=0x2764 | 0x2795..=0x2797 | 0x27B0 | 0x27BF | 0x2B1B..=0x2B1C | 0x2B50 | 0x2B55 | // Enclosed characters 0x1F004 | 0x1F0CF | 0x1F18E | 0x1F191..=0x1F19A | 0x1F201 | 0x1F21A | 0x1F22F | 0x1F232..=0x1F23A | 0x1F250..=0x1F251 | // Main emoji blocks 0x1F300..=0x1F5FF | 0x1F600..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | // Regional indicator symbols (flags) 0x1F1E6..=0x1F1FF ) } czkawka_core-11.0.1/src/tools/bad_names/mod.rs000064400000000000000000000054171046102023000173430ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct BadNameEntry { pub path: PathBuf, pub modified_date: u64, pub size: u64, pub new_name: String, } impl ResultEntry for BadNameEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct NameIssues { pub uppercase_extension: bool, pub emoji_used: bool, pub space_at_start_or_end: bool, pub non_ascii_graphical: bool, pub restricted_charset_allowed: Option>, pub remove_duplicated_non_alphanumeric: bool, } impl NameIssues { pub fn all() -> Self { Self { uppercase_extension: true, emoji_used: true, space_at_start_or_end: true, non_ascii_graphical: true, restricted_charset_allowed: Some(vec!['_', '-', ' ', '.']), remove_duplicated_non_alphanumeric: true, } } pub fn none() -> Self { Self::default() } pub fn is_empty(&self) -> bool { !self.uppercase_extension && !self.emoji_used && !self.space_at_start_or_end && !self.non_ascii_graphical && self.restricted_charset_allowed.is_none() && !self.remove_duplicated_non_alphanumeric } } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct NameFixerParams { // Empty - fixing has no parameters } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_files_with_bad_names: usize, pub scanning_time: Duration, } #[derive(Clone)] pub struct BadNamesParameters { pub checked_issues: NameIssues, } impl BadNamesParameters { pub fn new(checked_issues: NameIssues) -> Self { Self { checked_issues } } } impl Default for BadNamesParameters { fn default() -> Self { Self { checked_issues: NameIssues::all(), } } } pub struct BadNames { common_data: CommonToolData, information: Info, files_to_check: Vec, bad_names_files: Vec, params: BadNamesParameters, } impl BadNames { pub const fn get_bad_names_files(&self) -> &Vec { &self.bad_names_files } pub fn get_params(&self) -> &BadNamesParameters { &self.params } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/bad_names/tests.rs000064400000000000000000000574711046102023000177350ustar 00000000000000#[cfg(test)] mod tests2 { use std::fs; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::bad_names::{BadNames, BadNamesParameters, NameIssues}; #[test] fn test_uppercase_extension_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("test.TXT"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: true, emoji_used: false, space_at_start_or_end: false, non_ascii_graphical: false, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } #[test] fn test_emoji_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("test😀.txt"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: true, space_at_start_or_end: false, non_ascii_graphical: false, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } #[test] fn test_space_at_start_end_stem_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join(" test .txt"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: false, space_at_start_or_end: true, non_ascii_graphical: false, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } #[test] fn test_space_at_start_end_extension_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("test. txt "); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: false, space_at_start_or_end: true, non_ascii_graphical: false, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } #[test] fn test_non_ascii_graphical_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("tëst.txt"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: false, space_at_start_or_end: false, non_ascii_graphical: true, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } #[test] fn test_restricted_charset_detection() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("test@file.txt"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: false, space_at_start_or_end: false, non_ascii_graphical: false, restricted_charset_allowed: Some(vec!['_', '-', ' ']), remove_duplicated_non_alphanumeric: false, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "testfile.txt"); } #[test] fn test_duplicated_non_alphanumeric() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("test__file--name.txt"); fs::write(&test_file, "test").unwrap(); let params = BadNamesParameters::new(NameIssues { uppercase_extension: false, emoji_used: false, space_at_start_or_end: false, non_ascii_graphical: false, restricted_charset_allowed: None, remove_duplicated_non_alphanumeric: true, }); let mut bad_names = BadNames::new(params); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test_file-name.txt"); } #[test] fn test_multiple_issues() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join(" tëst😀 .TXT "); fs::write(&test_file, "test").unwrap(); let mut bad_names = BadNames::new(BadNamesParameters::new(NameIssues::all())); bad_names.get_cd_mut().directories.set_included_paths(vec![temp_dir.path().to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); bad_names.search(&stop_flag, None); assert_eq!(bad_names.get_bad_names_files().len(), 1); assert_eq!(bad_names.get_bad_names_files()[0].new_name, "test.txt"); } use std::path::Path; use crate::tools::bad_names::core::check_and_generate_new_name; #[test] fn test_uppercase_extension_unit() { let check_params = NameIssues { uppercase_extension: true, ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ ("test.TXT", "test.txt"), ("file.Jpg", "file.jpg"), ("document.PDF", "document.pdf"), ("image.PnG", "image.png"), ("video.MP4", "video.mp4"), ("archive.ZIP", "archive.zip"), ("data.CSV", "data.csv"), ("presentation.PPTX", "presentation.pptx"), ("script.Py", "script.py"), ("code.Js", "code.js"), ("style.Css", "style.css"), ("page.Html", "page.html"), ("config.Json", "config.json"), ("readme.Md", "readme.md"), ("Makefile.Mk", "Makefile.mk"), ("abc.cde.TXT", "abc.cde.txt"), ("file.backup.PDF", "file.backup.pdf"), ("my.file.name.JPG", "my.file.name.jpg"), ("test.1.2.3.Zip", "test.1.2.3.zip"), ("document.v2.0.Doc", "document.v2.0.doc"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Uppercase extension tests failed:\n{}", errors.join("\n")); } #[test] fn test_emoji_removal_unit() { let check_params = NameIssues { emoji_used: true, ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ ("test😀.txt", "test.txt"), ("file🎉🎊.doc", "file.doc"), ("image❤.png", "image.png"), ("video🔥.mp4", "video.mp4"), ("doc👍.pdf", "doc.pdf"), ("report😊😊😊.xlsx", "report.xlsx"), ("photo🌟.jpg", "photo.jpg"), ("music🎵🎶.mp3", "music.mp3"), ("readme📝.md", "readme.md"), ("party🎈🎉🎊🎁.txt", "party.txt"), ("love💕💖💗💘.doc", "love.doc"), ("fire🔥🔥🔥.log", "fire.log"), ("star⭐.txt", "star.txt"), ("food🍕🍔🍟.jpg", "food.jpg"), ("weather☀🌧⛈.csv", "weather.csv"), ("test😀.backup.txt", "test.backup.txt"), ("my.file🎉.doc", "my.file.doc"), ("archive.v1.2🔥.zip", "archive.v1.2.zip"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Emoji removal tests failed:\n{}", errors.join("\n")); } #[test] fn test_space_at_start_end_unit() { let check_params = NameIssues { space_at_start_or_end: true, ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ (" test.txt", "test.txt"), ("test .txt", "test.txt"), (" test .txt", "test.txt"), (" test .txt", "test.txt"), ("test. txt ", "test.txt"), (" file .doc", "file.doc"), ("image .png", "image.png"), (" video.mp4", "video.mp4"), ("document .pdf", "document.pdf"), (" report .xlsx", "report.xlsx"), (" data .csv", "data.csv"), ("photo . jpg ", "photo.jpg"), (" music .mp3", "music.mp3"), ("readme . md ", "readme.md"), (" archive . zip ", "archive.zip"), (" abc.cde.txt", "abc.cde.txt"), ("abc.cde .txt", "abc.cde.txt"), (" my.file.name .doc", "my.file.name.doc"), (" test.1.2 . pdf ", "test.1.2.pdf"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Space at start/end tests failed:\n{}", errors.join("\n")); } #[test] fn test_non_ascii_graphical_unit() { let check_params = NameIssues { non_ascii_graphical: true, ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ ("tëst.txt", "test.txt"), ("café.pdf", "cafe.pdf"), ("Kraków.doc", "Krakow.doc"), ("Łódź.txt", "Lodz.txt"), ("naïve.doc", "naive.doc"), ("résumé.pdf", "resume.pdf"), ("São Paulo.txt", "Sao Paulo.txt"), ("Zürich.doc", "Zurich.doc"), ("Москва.txt", "Moskva.txt"), ("日本.txt", "Ri Ben.txt"), ("über.pdf", "uber.pdf"), ("señor.txt", "senor.txt"), ("Ærø.doc", "AEro.doc"), ("niño.txt", "nino.txt"), ("Björk.mp3", "Bjork.mp3"), ("François.doc", "Francois.doc"), ("Ñoño.txt", "Nono.txt"), ("Østergård.pdf", "Ostergard.pdf"), ("Łukasz.txt", "Lukasz.txt"), ("Müller.doc", "Muller.doc"), ("pièces", "pieces"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Non-ASCII graphical tests failed:\n{}", errors.join("\n")); } #[test] fn test_restricted_charset_unit() { let check_params = NameIssues { restricted_charset_allowed: Some(vec!['_', '-', ' ']), ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ ("test@file.txt", "testfile.txt"), ("my#doc.pdf", "mydoc.pdf"), ("file$name.doc", "filename.doc"), ("data%set.csv", "dataset.csv"), ("script&code.js", "scriptcode.js"), ("image*pic.png", "imagepic.png"), ("video(1).mp4", "video1.mp4"), ("photo[2].jpg", "photo2.jpg"), ("doc{test}.pdf", "doctest.pdf"), ("file|name.txt", "filename.txt"), ("test:file.doc", "testfile.doc"), ("name;value.csv", "namevalue.csv"), ("file'name.txt", "filename.txt"), ("test\"quote.doc", "testquote.doc"), ("datamore.txt", "filemore.txt"), ("question?.log", "question.log"), ("wild*.txt", "wild.txt"), ("comma,.csv", "comma.csv"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Restricted charset tests failed:\n{}", errors.join("\n")); } #[test] fn test_duplicated_non_alphanumeric_unit() { let check_params = NameIssues { remove_duplicated_non_alphanumeric: true, ..NameIssues::default() }; let mut errors = Vec::new(); let test_cases = [ ("test__file.txt", "test_file.txt"), ("my--doc.pdf", "my-doc.pdf"), ("file name.doc", "file name.doc"), ("data...set.csv", "data.set.csv"), ("script___code.js", "script_code.js"), ("image---pic.png", "image-pic.png"), ("test____file----name.txt", "test_file-name.txt"), ("multiple spaces.doc", "multiple spaces.doc"), ("under______score.log", "under_score.log"), ("dash-------line.txt", "dash-line.txt"), ("mixed__--__test.doc", "mixed_-_test.doc"), ("file,,,,name.csv", "file,name.csv"), ("test;;;;code.txt", "test;code.txt"), ("data::::value.xml", "data:value.xml"), ("triple___---...test.txt", "triple_-.test.txt"), ("many spaces.doc", "many spaces.doc"), ("dots......dots.txt", "dots.dots.txt"), ("under_score.txt", "under_score.txt"), ("normal-file.txt", "normal-file.txt"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); let result = check_and_generate_new_name(path, &check_params); if input == expected_output { // No change expected if let Some(result) = result { errors.push(format!("Input: '{input}' should not be modified but got: '{result}'")); } } else { // Change expected if let Some(new_name) = result { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } } assert!(errors.is_empty(), "Duplicated non-alphanumeric tests failed:\n{}", errors.join("\n")); } #[test] fn test_combined_all_issues_unit() { let check_params = NameIssues { uppercase_extension: true, emoji_used: true, space_at_start_or_end: true, non_ascii_graphical: true, restricted_charset_allowed: Some(vec!['_', '-', ' ']), remove_duplicated_non_alphanumeric: true, }; let mut errors = Vec::new(); let test_cases = [ (" tëst😀 .TXT ", "test.txt"), (" café☕ .Pdf ", "cafe.pdf"), (" über@file😊 .Txt ", "uberfile.txt"), ("test__😀__file.JPG", "test_file.jpg"), (" Kraków🎉 .Doc ", "Krakow.doc"), (" résumé## .PDF ", "resume.pdf"), ("São Paulo .TXT", "Sao Paulo.txt"), (" file___name😀😀.PNG ", "file_name.png"), ("test @@ emoji🎉.MP4", "test emoji.mp4"), (" Łódź---file .CSV ", "Lodz-file.csv"), ("über__müller😊.XLSX", "uber_muller.xlsx"), (" data___set🔥 . JSON ", "data_set.json"), ("test ## ëmoji😀.Doc", "test emoji.doc"), (" François___Müller .PDF ", "Francois_Muller.pdf"), ("multi___issue___test😀😀 .TXT ", "multi_issue_test.txt"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { if new_name != expected_output { errors.push(format!("Input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } let fixed_path = Path::new(&new_name); if check_and_generate_new_name(fixed_path, &check_params).is_some() { errors.push(format!("Double fix should return None for: '{new_name}'")); } } else { errors.push(format!("Input: '{input}' was not fixed")); } } assert!(errors.is_empty(), "Combined all issues tests failed:\n{}", errors.join("\n")); } #[test] fn test_no_issues_no_changes() { let check_params = NameIssues::all(); let mut errors = Vec::new(); let test_cases = [ "normal_file.txt", "test-file.doc", "MyDocument.pdf", "data_2024.csv", "image-001.jpg", "video_final.mp4", "report-2024-01.xlsx", "README.md", "config.json", "script.py", ]; for input in test_cases { let path = Path::new(input); if let Some(new_name) = check_and_generate_new_name(path, &check_params) { errors.push(format!("Input: '{input}' should not be changed but got: '{new_name}'")); } } assert!(errors.is_empty(), "No issues no changes tests failed:\n{}", errors.join("\n")); } #[test] fn test_edge_cases_unit() { let check_params = NameIssues::all(); let mut errors = Vec::new(); let test_cases = [ ("😀.txt", ".txt"), (" .TXT", ".txt"), ("😀😀😀.txt", ".txt"), ("___", "_"), ("---", "-"), ("...", "."), (" 😀 .TXT ", ".txt"), ("test.", "test"), (".test", ".test"), ]; for (input, expected_output) in test_cases { let path = Path::new(input); let result = check_and_generate_new_name(path, &check_params); if input == expected_output { if let Some(new_name) = result { errors.push(format!("Edge case input: '{input}' should not be modified but got: '{new_name}'")); } } else { if let Some(new_name) = result { if new_name != expected_output { errors.push(format!("Edge case input: '{input}', Expected: '{expected_output}', Got: '{new_name}'")); } } else { errors.push(format!("Edge case input: '{input}' was not fixed")); } } } assert!(errors.is_empty(), "Edge cases tests failed:\n{}", errors.join("\n")); } } czkawka_core-11.0.1/src/tools/bad_names/traits.rs000064400000000000000000000102661046102023000200700ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, FixingItems, PrintResults, Search}; use crate::flc; use crate::tools::bad_names::{BadNames, BadNamesParameters, Info, NameFixerParams}; impl AllTraits for BadNames {} impl Search for BadNames { #[fun_time(message = "find_bad_names", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.params.checked_issues.is_empty() { self.common_data.text_messages.critical = Some(flc!("core_needs_to_set_at_least_one_bad_name_option")); return; } if self.prepare_items(None).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.look_for_bad_names_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for BadNames { fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } self.debug_print_common(); } } impl PrintResults for BadNames { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.bad_names_files.is_empty() { writeln!(writer, "Found {} files with bad names.", self.information.number_of_files_with_bad_names)?; for file_entry in &self.bad_names_files { writeln!(writer, "\"{}\" -> \"{}\"", file_entry.path.to_string_lossy(), file_entry.new_name)?; } } else { write!(writer, "Not found any files with bad names.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.bad_names_files, pretty_print) } } impl DeletingItems for BadNames { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(self.bad_names_files.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } impl FixingItems for BadNames { type FixParams = NameFixerParams; #[fun_time(message = "fix_items", level = "debug")] fn fix_items(&mut self, stop_flag: &Arc, _progress_sender: Option<&Sender>, fix_params: Self::FixParams) { self.fix_bad_names(fix_params, stop_flag); } } impl CommonData for BadNames { type Info = Info; type Parameters = BadNamesParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_files_with_bad_names > 0 } } czkawka_core-11.0.1/src/tools/big_file/core.rs000064400000000000000000000043761046102023000173460ustar 00000000000000use std::cmp::Reverse; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use log::debug; use rayon::prelude::*; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::tools::big_file::{BigFile, BigFileParameters, Info, SearchMode}; impl BigFile { pub fn new(params: BigFileParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::BigFile), information: Info::default(), big_files: Default::default(), params, } } #[fun_time(message = "look_for_big_files", level = "debug")] pub(crate) fn look_for_big_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .minimal_file_size(1) .maximal_file_size(u64::MAX) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { let mut all_files = grouped_file_entries.into_values().flatten().collect::>(); if self.get_params().search_mode == SearchMode::BiggestFiles { all_files.par_sort_unstable_by_key(|fe| Reverse(fe.size)); } else { all_files.par_sort_unstable_by_key(|fe| fe.size); } all_files.truncate(self.get_params().number_of_files_to_check); self.big_files = all_files; self.common_data.text_messages.warnings.extend(warnings); self.information.number_of_real_files = self.big_files.len(); debug!("check_files - Found {} biggest/smallest files.", self.big_files.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } } czkawka_core-11.0.1/src/tools/big_file/mod.rs000064400000000000000000000017351046102023000171710ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::time::Duration; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum SearchMode { BiggestFiles, SmallestFiles, } #[derive(Debug, Default, Clone)] pub struct Info { pub number_of_real_files: usize, pub scanning_time: Duration, } #[derive(Clone)] pub struct BigFileParameters { pub number_of_files_to_check: usize, pub search_mode: SearchMode, } impl BigFileParameters { pub fn new(number_of_files: usize, search_mode: SearchMode) -> Self { Self { number_of_files_to_check: number_of_files.max(1), search_mode, } } } pub struct BigFile { common_data: CommonToolData, information: Info, big_files: Vec, params: BigFileParameters, } impl BigFile { pub const fn get_big_files(&self) -> &Vec { &self.big_files } } czkawka_core-11.0.1/src/tools/big_file/tests.rs000064400000000000000000000062461046102023000175560ustar 00000000000000use std::fs; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::big_file::{BigFile, BigFileParameters, SearchMode}; #[test] fn test_find_biggest_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create files with different sizes fs::write(path.join("small.txt"), b"12").unwrap(); // 2 bytes fs::write(path.join("medium.txt"), b"12345").unwrap(); // 5 bytes fs::write(path.join("large.txt"), vec![b'A'; 100]).unwrap(); // 100 bytes let params = BigFileParameters::new(2, SearchMode::BiggestFiles); let mut finder = BigFile::new(params); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let big_files = finder.get_big_files(); assert_eq!(big_files.len(), 2, "Should find 2 biggest files"); assert_eq!(big_files[0].size, 100, "First file should be 100 bytes"); assert_eq!(big_files[1].size, 5, "Second file should be 5 bytes"); } #[test] fn test_find_smallest_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create files with different sizes fs::write(path.join("small.txt"), b"12").unwrap(); // 2 bytes fs::write(path.join("medium.txt"), b"12345").unwrap(); // 5 bytes fs::write(path.join("large.txt"), vec![b'A'; 100]).unwrap(); // 100 bytes let params = BigFileParameters::new(2, SearchMode::SmallestFiles); let mut finder = BigFile::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let big_files = finder.get_big_files(); assert_eq!(big_files.len(), 2, "Should find 2 smallest files"); assert_eq!(big_files[0].size, 2, "First file should be 2 bytes"); assert_eq!(big_files[1].size, 5, "Second file should be 5 bytes"); } #[test] fn test_limit_number_of_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create 5 files for i in 1..=5 { fs::write(path.join(format!("file{i}.txt")), vec![b'A'; i * 10]).unwrap(); } let params = BigFileParameters::new(3, SearchMode::BiggestFiles); let mut finder = BigFile::new(params); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let big_files = finder.get_big_files(); assert_eq!(big_files.len(), 3, "Should limit results to 3 files"); } #[test] fn test_empty_directory() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let params = BigFileParameters::new(5, SearchMode::BiggestFiles); let mut finder = BigFile::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let big_files = finder.get_big_files(); assert!(big_files.is_empty(), "Should find no files in empty directory"); } czkawka_core-11.0.1/src/tools/big_file/traits.rs000064400000000000000000000101151046102023000177100ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::big_file::{BigFile, BigFileParameters, Info, SearchMode}; impl AllTraits for BigFile {} impl DeletingItems for BigFile { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(self.big_files.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } impl DebugPrint for BigFile { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("### INDIVIDUAL DEBUG PRINT ###"); println!("Info: {:?}", self.information); println!("Number of files to check - {}", self.get_params().number_of_files_to_check); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for BigFile { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if self.information.number_of_real_files != 0 { if self.get_params().search_mode == SearchMode::BiggestFiles { writeln!(writer, "{} the biggest files.\n\n", self.information.number_of_real_files)?; } else { writeln!(writer, "{} the smallest files.\n\n", self.information.number_of_real_files)?; } for file_entry in &self.big_files { writeln!( writer, "{} ({}) - \"{}\"", format_size(file_entry.size, BINARY), file_entry.size, file_entry.path.to_string_lossy() )?; } } else { writeln!(writer, "Not found any files.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.big_files, pretty_print) } } impl Search for BigFile { #[fun_time(message = "find_big_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.look_for_big_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl CommonData for BigFile { type Info = Info; type Parameters = BigFileParameters; fn get_information(&self) -> Self::Info { self.information.clone() } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_real_files > 0 } } czkawka_core-11.0.1/src/tools/broken_files/core.rs000064400000000000000000000403131046102023000202370ustar 00000000000000use std::collections::BTreeMap; use std::fs::File; use std::path::Path; use std::process::Command; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::{mem, panic}; use crossbeam_channel::Sender; use fun_time::fun_time; use log::{debug, error}; use lopdf::Document; use rayon::prelude::*; use crate::common::cache::{CACHE_BROKEN_FILES_VERSION, load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::consts::{AUDIO_FILES_EXTENSIONS, IMAGE_RS_BROKEN_FILES_EXTENSIONS, PDF_FILES_EXTENSIONS, VIDEO_FILES_EXTENSIONS, ZIP_FILES_EXTENSIONS}; use crate::common::create_crash_message; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::process_utils::run_command_interruptible; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::helpers::audio_checker; use crate::tools::broken_files::{BrokenEntry, BrokenFiles, BrokenFilesParameters, Info, TypeOfFile}; impl BrokenFiles { pub fn new(params: BrokenFilesParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::BrokenFiles), information: Info::default(), files_to_check: Default::default(), broken_files: Default::default(), params, } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.files_to_check = grouped_file_entries .into_values() .flatten() .map(|fe| { let broken_entry = fe.into_broken_entry(); (broken_entry.path.to_string_lossy().to_string(), broken_entry) }) .collect(); self.common_data.text_messages.warnings.extend(warnings); debug!("check_files - Found {} files to check.", self.files_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn check_broken_image(mut file_entry: BrokenEntry) -> BrokenEntry { let mut file_entry_clone = file_entry.clone(); panic::catch_unwind(|| { match image::open(&file_entry.path) { Ok(img) => { if img.width() == 0 || img.height() == 0 { file_entry.error_string = "Image has zero width or height".to_string(); } } Err(e) => { file_entry.error_string = e.to_string().trim().to_string(); } } file_entry }) .unwrap_or_else(|_| { let message = create_crash_message("Image-rs", &file_entry_clone.path.to_string_lossy(), "https://github.com/image-rs/image"); error!("{message}"); file_entry_clone.error_string = message; file_entry_clone }) } fn check_broken_zip(mut file_entry: BrokenEntry) -> Option { match File::open(&file_entry.path) { Ok(file) => { if let Err(e) = zip::ZipArchive::new(file) { file_entry.error_string = e.to_string().trim().to_string(); } Some(file_entry) } Err(_inspected) => None, } } fn check_broken_audio(mut file_entry: BrokenEntry) -> Option { match File::open(&file_entry.path) { Ok(file) => { let mut file_entry_clone = file_entry.clone(); panic::catch_unwind(|| { if let Err(e) = audio_checker::parse_audio_file(file) { let err_str = e.to_string(); if !err_str.contains("not supported codec") { file_entry.error_string = err_str.trim().to_string(); } } Some(file_entry) }) .unwrap_or_else(|_| { let message = create_crash_message("Symphonia", &file_entry_clone.path.to_string_lossy(), "https://github.com/pdeljanov/Symphonia"); error!("{message}"); file_entry_clone.error_string = message; Some(file_entry_clone) }) } Err(_inspected) => None, } } fn check_broken_pdf(mut file_entry: BrokenEntry) -> BrokenEntry { let mut file_entry_clone = file_entry.clone(); panic::catch_unwind(|| { match File::open(&file_entry.path) { Ok(file) => { if let Err(e) = Document::load_from(file) { file_entry.error_string = e.to_string().trim().to_string(); } } Err(e) => { file_entry.error_string = e.to_string().trim().to_string(); } } file_entry }) .unwrap_or_else(|_| { let message = create_crash_message("lopdf", &file_entry_clone.path.to_string_lossy(), "https://github.com/J-F-Liu/lopdf"); error!("{message}"); file_entry_clone.error_string = message; file_entry_clone }) } // None if stopped, otherwise Some fn check_broken_video(mut file_entry: BrokenEntry, stop_flag: &Arc) -> Option { let ffprobe_errors = [ ("moov atom not found", Some("broken file structure")), ("error reading header", Some("broken file structure")), ("EBML header parsing failed", None), ("exceeds containing master element", Some("broken file structure")), ("invalid frame index table", Some("broken file structure")), ("Invalid argument", Some("ffprobe seems to not recognize file format")), ]; let mut command = Command::new("ffprobe"); command.arg("-v").arg("error").arg(&file_entry.path); match run_command_interruptible(command, stop_flag) { None => return None, Some(Err(e)) => { debug!("Failed to run ffprobe on {:?}: {}", file_entry.path, e); file_entry.error_string = format!("Failed to run ffprobe: {e}").trim().to_string(); return Some(file_entry); } Some(Ok(output)) => { let combined = format!("{}{}", output.stdout.trim(), output.stderr.trim()); if let Some((error_message, additional_message)) = ffprobe_errors.iter().find(|(err, _)| combined.contains(err)) { file_entry.error_string = format!("{error_message}{}", additional_message.map(|e| format!(" ({e})")).unwrap_or_default()); return Some(file_entry); } else if !output.status.success() { // debug_save_file("ffprobe_failed_output.txt", &format!("{} --- \n{}", file_entry.path.to_string_lossy(), combined)); file_entry.error_string = format!("ffprobe exited with non-zero status: {}", output.status); return Some(file_entry); } } } let ffmpeg_message = [ ("Output file does not contain any stream", Some("cannot find video stream - possible not even video file")), ("missing mandatory atoms, broken header", Some("broken file structure")), ("Cannot determine format of input", None), ("decode_slice_header error", Some("corrupted video data, may be still fully/partially playable")), ("Truncating packet", Some("corrupted video data, may be still fully/partially playable")), ("Invalid NAL unit size", Some("corrupted video data, may be still fully/partially playable")), ( "exceeds containing master element ending", Some("corrupted video data, may be still fully/partially playable"), ), ("corrupt input packet in stream", Some("Possible corruption in audio/video stream, may be still playable")), ( "invalid as first byte of an EBML number", Some("corrupted video data, may be still fully/partially playable"), ), // Last resort for all other errors ("Invalid data found when processing input", Some("generic error")), // Must be last to not override more precise errors // Warnings ("corrupt decoded frame", Some("may be still playable")), ]; let ffmpeg_allowed_messages = [ "Input buffer exhausted before END element found", // Looks like quite popular message, so ignoring it "Invalid color space", // https://fftrac-bg.ffmpeg.org/ticket/11020 - seems to be non-fatal ]; let mut command = Command::new("ffmpeg"); command .arg("-v") .arg("error") .arg("-xerror") .arg("-threads") .arg("1") .arg("-i") .arg(&file_entry.path) .arg("-f") .arg("null") .arg("-"); match run_command_interruptible(command, stop_flag) { None => return None, Some(Err(e)) => { debug!("Failed to run ffmpeg on {:?}: {}", file_entry.path, e); file_entry.error_string = format!("Failed to run ffmpeg: {}", e.trim()); } Some(Ok(output)) => { let combined = format!("{}{}", output.stdout.trim(), output.stderr.trim()); if ffmpeg_allowed_messages.iter().any(|msg| combined.contains(msg)) { // Allowed message, do nothing } else if let Some((error_message, additional_message)) = ffmpeg_message.iter().find(|(err, _)| combined.contains(err)) { file_entry.error_string = format!("{error_message}{}", additional_message.map(|e| format!(" ({e})")).unwrap_or_default()); } else if !output.status.success() { // debug_save_file("ffmpeg_failed_output.txt", &format!("{} --- \n{}", file_entry.path.to_string_lossy(), combined)); file_entry.error_string = format!("ffmpeg exited with non-zero status: {}", output.status); } } } Some(file_entry) } #[fun_time(message = "load_cache", level = "debug")] fn load_cache(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { load_and_split_cache_generalized_by_path(&get_broken_files_cache_file(), mem::take(&mut self.files_to_check), self) } #[fun_time(message = "save_to_cache", level = "debug")] fn save_to_cache(&mut self, vec_file_entry: &[BrokenEntry], loaded_hash_map: BTreeMap) { save_and_connect_cache_generalized_by_path(&get_broken_files_cache_file(), vec_file_entry, loaded_hash_map, self); } fn check_file(file_entry: BrokenEntry, stop_flag: &Arc) -> Option> { match check_extension_availability(&file_entry.path) { TypeOfFile::Image => Some(Some(Self::check_broken_image(file_entry))), TypeOfFile::ArchiveZip => Some(Self::check_broken_zip(file_entry)), TypeOfFile::Audio => Some(Self::check_broken_audio(file_entry)), TypeOfFile::Pdf => Some(Some(Self::check_broken_pdf(file_entry))), TypeOfFile::Video => Self::check_broken_video(file_entry, stop_flag).map(Some), TypeOfFile::Unknown => { error!("Unknown file type of: {file_entry:?}"); Some(None) } } } #[fun_time(message = "look_for_broken_files", level = "debug")] pub(crate) fn look_for_broken_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.files_to_check.is_empty() { return WorkContinueStatus::Continue; } let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_cache(); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::BrokenFilesChecking, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|item| item.size).sum::(), ); let non_cached_files_to_check = non_cached_files_to_check.into_iter().collect::>(); debug!("look_for_broken_files - started finding for broken files"); let mut vec_file_entry: Vec = non_cached_files_to_check .into_par_iter() .with_max_len(3) .map(|(_, file_entry)| { if check_if_stop_received(stop_flag) { return None; } let size = file_entry.size; let res = Self::check_file(file_entry, stop_flag); progress_handler.increase_items(1); progress_handler.increase_size(size); res }) .while_some() .flatten() .collect::>(); debug!("look_for_broken_files - ended finding for broken files"); progress_handler.join_thread(); // Just connect loaded results with already calculated vec_file_entry.extend(records_already_cached.into_values()); self.save_to_cache(&vec_file_entry, loaded_hash_map); self.broken_files = vec_file_entry.into_iter().filter_map(|f| if f.error_string.is_empty() { None } else { Some(f) }).collect(); self.information.number_of_broken_files = self.broken_files.len(); debug!("Found {} broken files.", self.information.number_of_broken_files); // Clean unused data self.files_to_check = Default::default(); WorkContinueStatus::Continue } } #[expect(clippy::string_slice)] // Valid, because we address up to the dot, which is known ascii character fn check_extension_availability(full_name: &Path) -> TypeOfFile { let Some(file_name) = full_name.file_name() else { error!("Missing file name in file - \"{}\"", full_name.to_string_lossy()); debug_assert!(false, "Missing file name in file - \"{}\"", full_name.to_string_lossy()); return TypeOfFile::Unknown; }; // Faster manual conversion than using Path::extension() let Some(file_name_str) = file_name.to_str() else { return TypeOfFile::Unknown }; let Some(extension_idx) = file_name_str.rfind('.') else { return TypeOfFile::Unknown }; let extension_str = &file_name_str[extension_idx + 1..]; let extension_lowercase = extension_str.to_ascii_lowercase(); if IMAGE_RS_BROKEN_FILES_EXTENSIONS.contains(&extension_lowercase.as_str()) { TypeOfFile::Image } else if ZIP_FILES_EXTENSIONS.contains(&extension_lowercase.as_str()) { TypeOfFile::ArchiveZip } else if PDF_FILES_EXTENSIONS.contains(&extension_lowercase.as_str()) { TypeOfFile::Pdf } else if AUDIO_FILES_EXTENSIONS.contains(&extension_lowercase.as_str()) { TypeOfFile::Audio } else if VIDEO_FILES_EXTENSIONS.contains(&extension_lowercase.as_str()) { TypeOfFile::Video } else { error!("File with unknown extension: \"{}\" - {extension_lowercase}", full_name.to_string_lossy()); debug_assert!(false, "File with unknown extension - \"{}\" - {extension_lowercase}", full_name.to_string_lossy()); TypeOfFile::Unknown } } pub fn get_broken_files_cache_file() -> String { format!("cache_broken_files_{CACHE_BROKEN_FILES_VERSION}.bin") } czkawka_core-11.0.1/src/tools/broken_files/mod.rs000064400000000000000000000043361046102023000200730ustar 00000000000000use bitflags::bitflags; pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct BrokenEntry { pub path: PathBuf, pub modified_date: u64, pub size: u64, pub error_string: String, } impl ResultEntry for BrokenEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_broken_entry(self) -> BrokenEntry { BrokenEntry { size: self.size, path: self.path, modified_date: self.modified_date, error_string: String::new(), } } } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] pub enum TypeOfFile { Unknown = -1, Image = 0, ArchiveZip, Audio, Pdf, Video, } bitflags! { #[derive(PartialEq, Copy, Clone, Debug)] pub struct CheckedTypes : u32 { const NONE = 0; const PDF = 0b1; const AUDIO = 0b10; const IMAGE = 0b100; const ARCHIVE = 0b1000; const VIDEO = 0b10000; } } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_broken_files: usize, pub scanning_time: Duration, } #[derive(Clone)] pub struct BrokenFilesParameters { pub checked_types: CheckedTypes, } impl BrokenFilesParameters { pub fn new(checked_types: CheckedTypes) -> Self { Self { checked_types } } } pub struct BrokenFiles { common_data: CommonToolData, information: Info, files_to_check: BTreeMap, broken_files: Vec, params: BrokenFilesParameters, } impl BrokenFiles { pub const fn get_broken_files(&self) -> &Vec { &self.broken_files } pub(crate) fn get_params(&self) -> &BrokenFilesParameters { &self.params } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/broken_files/tests.rs000064400000000000000000000153561046102023000204620ustar 00000000000000use std::fs; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::broken_files::{BrokenFiles, BrokenFilesParameters, CheckedTypes}; fn get_test_resources_path() -> PathBuf { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_resources"); assert!(path.exists(), "Test resources not found at \"{}\"", path.to_string_lossy()); path } fn corrupt_file(source: &PathBuf, dest: &PathBuf, bytes_to_corrupt: usize) { let mut content = fs::read(source).unwrap(); for byte in content.iter_mut().take(bytes_to_corrupt) { *byte = 0x11; } fs::write(dest, content).unwrap(); } #[test] fn test_find_broken_image() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_image = test_resources.join("images").join("normal.jpg"); let broken_image = temp_dir.path().join("broken.jpg"); corrupt_file(&source_image, &broken_image, 10); let params = BrokenFilesParameters::new(CheckedTypes::IMAGE); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 1, "Should find 1 broken image file"); assert!(!broken_files[0].error_string.is_empty(), "Error string should not be empty"); } #[test] fn test_valid_image() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_image = test_resources.join("images").join("normal.jpg"); let valid_image = temp_dir.path().join("valid.jpg"); fs::copy(&source_image, &valid_image).unwrap(); let params = BrokenFilesParameters::new(CheckedTypes::IMAGE); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 0, "Should find no broken image files"); } #[test] fn test_broken_audio() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_audio = test_resources.join("audio").join("base.mp3"); let broken_audio = temp_dir.path().join("broken.mp3"); let file_len = fs::metadata(&source_audio).unwrap().len(); corrupt_file(&source_audio, &broken_audio, file_len as usize); let good_audio = temp_dir.path().join("good.mp3"); fs::copy(&source_audio, &good_audio).unwrap(); let params = BrokenFilesParameters::new(CheckedTypes::AUDIO); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 1, "Should find 1 broken audio file"); assert!(!broken_files[0].error_string.is_empty(), "Error string should not be empty"); } #[test] fn test_mixed_valid_and_broken_images() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_image1 = test_resources.join("images").join("normal.jpg"); fs::copy(&source_image1, temp_dir.path().join("valid.jpg")).unwrap(); let source_image2 = test_resources.join("images").join("normal2.jpg"); corrupt_file(&source_image2, &temp_dir.path().join("broken.jpg"), 10); let params = BrokenFilesParameters::new(CheckedTypes::IMAGE); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); let info = finder.get_information(); assert_eq!(broken_files.len(), 1, "Should find only 1 broken file out of 2 total"); assert_eq!(info.number_of_broken_files, 1, "Info should report 1 broken file"); } #[test] fn test_multiple_file_types() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_image = test_resources.join("images").join("normal.jpg"); corrupt_file(&source_image, &temp_dir.path().join("broken.jpg"), 10); let source_audio = test_resources.join("audio").join("base.mp3"); let file_len = fs::metadata(&source_audio).unwrap().len(); corrupt_file(&source_audio, &temp_dir.path().join("broken.mp3"), file_len as usize); let params = BrokenFilesParameters::new(CheckedTypes::IMAGE | CheckedTypes::AUDIO); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 2, "Should find 2 broken files"); } #[test] fn test_empty_directory() { let temp_dir = TempDir::new().unwrap(); let params = BrokenFilesParameters::new(CheckedTypes::IMAGE); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 0, "Should find no broken files in empty directory"); } #[test] fn test_no_file_types_selected() { let temp_dir = TempDir::new().unwrap(); let test_resources = get_test_resources_path(); let source_image = test_resources.join("images").join("normal.jpg"); corrupt_file(&source_image, &temp_dir.path().join("broken.jpg"), 10); let params = BrokenFilesParameters::new(CheckedTypes::NONE); let mut finder = BrokenFiles::new(params); finder.set_included_paths(vec![temp_dir.path().to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let broken_files = finder.get_broken_files(); assert_eq!(broken_files.len(), 0, "Should find no files when no types are selected"); } czkawka_core-11.0.1/src/tools/broken_files/traits.rs000064400000000000000000000120601046102023000206130ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::consts::{AUDIO_FILES_EXTENSIONS, IMAGE_RS_BROKEN_FILES_EXTENSIONS, PDF_FILES_EXTENSIONS, VIDEO_FILES_EXTENSIONS, ZIP_FILES_EXTENSIONS}; use crate::common::ffmpeg_utils::check_if_ffprobe_ffmpeg_exists; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::flc; use crate::tools::broken_files::{BrokenFiles, BrokenFilesParameters, CheckedTypes, Info}; impl AllTraits for BrokenFiles {} impl Search for BrokenFiles { #[fun_time(message = "find_broken_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.params.checked_types.contains(CheckedTypes::VIDEO) && !check_if_ffprobe_ffmpeg_exists() { self.common_data.text_messages.critical = Some(flc!("core_ffmpeg_not_found")); #[cfg(target_os = "windows")] self.common_data.text_messages.errors.push(flc!("core_ffmpeg_not_found_windows")); return; } let extension_types = [ (CheckedTypes::PDF, PDF_FILES_EXTENSIONS), (CheckedTypes::AUDIO, AUDIO_FILES_EXTENSIONS), (CheckedTypes::ARCHIVE, ZIP_FILES_EXTENSIONS), (CheckedTypes::IMAGE, IMAGE_RS_BROKEN_FILES_EXTENSIONS), (CheckedTypes::VIDEO, VIDEO_FILES_EXTENSIONS), ]; let extensions = extension_types .into_iter() .filter(|(checked_type, _)| self.get_params().checked_types.contains(*checked_type)) .flat_map(|(_, exts)| exts.to_vec()) .collect::>(); if extensions.is_empty() { self.common_data.text_messages.critical = Some(flc!("core_needs_to_set_at_least_one_broken_option")); return; } if self.prepare_items(Some(&extensions)).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.look_for_broken_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for BrokenFiles { fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } self.debug_print_common(); } } impl PrintResults for BrokenFiles { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.broken_files.is_empty() { writeln!(writer, "Found {} broken files.", self.information.number_of_broken_files)?; for file_entry in &self.broken_files { writeln!(writer, "\"{}\" - {}", file_entry.path.to_string_lossy(), file_entry.error_string)?; } } else { write!(writer, "Not found any broken files.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.broken_files, pretty_print) } } impl DeletingItems for BrokenFiles { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(self.broken_files.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } impl CommonData for BrokenFiles { type Info = Info; type Parameters = BrokenFilesParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_broken_files > 0 } } czkawka_core-11.0.1/src/tools/duplicate/core.rs000064400000000000000000001102751046102023000175540ustar 00000000000000use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use std::{mem, thread}; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use indexmap::IndexMap; use log::debug; use rayon::prelude::*; use crate::common::cache::{CACHE_DUPLICATE_VERSION, load_cache_from_file_generalized_by_size, save_cache_to_file_generalized}; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{CheckingMethod, FileEntry, HashType, ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::traits::ResultEntry; use crate::tools::duplicate::{ DuplicateEntry, DuplicateFinder, DuplicateFinderParameters, Info, PREHASHING_BUFFER_SIZE, THREAD_BUFFER, filter_hard_links, hash_calculation, hash_calculation_limit, }; impl DuplicateFinder { pub fn new(params: DuplicateFinderParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::Duplicate), information: Info::default(), files_with_identical_names: Default::default(), files_with_identical_size: Default::default(), files_with_identical_size_names: Default::default(), files_with_identical_hashes: Default::default(), files_with_identical_names_referenced: Default::default(), files_with_identical_size_names_referenced: Default::default(), files_with_identical_size_referenced: Default::default(), files_with_identical_hashes_referenced: Default::default(), params, } } #[fun_time(message = "check_files_name", level = "debug")] pub(crate) fn check_files_name(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let group_by_func = if self.get_params().case_sensitive_name_comparison { |fe: &FileEntry| { fe.path .file_name() .unwrap_or_else(|| panic!("Found invalid file_name \"{}\" (cannot panic, because it is always normal file)", fe.path.to_string_lossy())) .to_string_lossy() .to_string() } } else { |fe: &FileEntry| { fe.path .file_name() .unwrap_or_else(|| panic!("Found invalid file_name \"{}\" (cannot panic, because it is always normal file)", fe.path.to_string_lossy())) .to_string_lossy() .to_lowercase() } }; let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(group_by_func) .stop_flag(stop_flag) .progress_sender(progress_sender) .checking_method(CheckingMethod::Name) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.common_data.text_messages.warnings.extend(warnings); // Create new BTreeMap without single size entries(files have not duplicates) self.files_with_identical_names = grouped_file_entries .into_iter() .filter_map(|(name, vector)| { if vector.len() > 1 { Some((name, vector.into_iter().map(FileEntry::into_duplicate_entry).collect())) } else { None } }) .collect(); // Reference - only use in size, because later hash will be counted differently if self.common_data.use_reference_folders { let vec = mem::take(&mut self.files_with_identical_names) .into_iter() .filter_map(|(_name, vec_file_entry)| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>(); for (fe, vec_fe) in vec { self.files_with_identical_names_referenced.insert(fe.path.to_string_lossy().to_string(), (fe, vec_fe)); } } self.calculate_name_stats(); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn calculate_name_stats(&mut self) { if self.common_data.use_reference_folders { for (_fe, vector) in self.files_with_identical_names_referenced.values() { self.information.number_of_duplicated_files_by_name += vector.len(); self.information.number_of_groups_by_name += 1; } } else { for vector in self.files_with_identical_names.values() { self.information.number_of_duplicated_files_by_name += vector.len() - 1; self.information.number_of_groups_by_name += 1; } } } #[fun_time(message = "check_files_size_name", level = "debug")] pub(crate) fn check_files_size_name(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let group_by_func = if self.get_params().case_sensitive_name_comparison { |fe: &FileEntry| { ( fe.size, fe.path .file_name() .unwrap_or_else(|| panic!("Found invalid file_name \"{}\" (cannot panic, because it is always normal file)", fe.path.to_string_lossy())) .to_string_lossy() .to_string(), ) } } else { |fe: &FileEntry| { ( fe.size, fe.path .file_name() .unwrap_or_else(|| panic!("Found invalid file_name \"{}\" (cannot panic, because it is always normal file)", fe.path.to_string_lossy())) .to_string_lossy() .to_lowercase(), ) } }; let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(group_by_func) .stop_flag(stop_flag) .progress_sender(progress_sender) .checking_method(CheckingMethod::SizeName) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.common_data.text_messages.warnings.extend(warnings); self.files_with_identical_size_names = grouped_file_entries .into_iter() .filter_map(|(size_name, vector)| { if vector.len() > 1 { Some((size_name, vector.into_iter().map(FileEntry::into_duplicate_entry).collect())) } else { None } }) .collect(); // Reference - only use in size, because later hash will be counted differently if self.common_data.use_reference_folders { let vec = mem::take(&mut self.files_with_identical_size_names) .into_iter() .filter_map(|(_size, vec_file_entry)| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>(); for (fe, vec_fe) in vec { self.files_with_identical_size_names_referenced .insert((fe.size, fe.path.to_string_lossy().to_string()), (fe, vec_fe)); } } self.calculate_size_name_stats(); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn calculate_size_name_stats(&mut self) { if self.common_data.use_reference_folders { for ((size, _name), (_fe, vector)) in &self.files_with_identical_size_names_referenced { self.information.number_of_duplicated_files_by_size_name += vector.len(); self.information.number_of_groups_by_size_name += 1; self.information.lost_space_by_size += (vector.len() as u64) * size; } } else { for ((size, _name), vector) in &self.files_with_identical_size_names { self.information.number_of_duplicated_files_by_size_name += vector.len() - 1; self.information.number_of_groups_by_size_name += 1; self.information.lost_space_by_size += (vector.len() as u64 - 1) * size; } } } #[fun_time(message = "check_files_size", level = "debug")] pub(crate) fn check_files_size(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(|fe| fe.size) .stop_flag(stop_flag) .progress_sender(progress_sender) .checking_method(self.get_params().check_method) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.common_data.text_messages.warnings.extend(warnings); let grouped_file_entries: Vec<(u64, Vec)> = grouped_file_entries.into_iter().collect(); let rayon_max_len = if self.get_hide_hard_links() { 3 } else { 100 }; let start_time = Instant::now(); // We only gather files with more than 1 entry, because only this will be later used let initial_size = grouped_file_entries .iter() .map(|(_size, vec)| if vec.len() > 1 { vec.len() as u64 } else { 0 }) .sum::(); self.files_with_identical_size = grouped_file_entries .into_par_iter() .with_max_len(rayon_max_len) .filter_map(|(size, vec)| { if vec.len() <= 1 { return None; } let vector = if self.get_hide_hard_links() { filter_hard_links(vec) } else { vec }; if vector.len() > 1 { Some((size, vector.into_iter().map(FileEntry::into_duplicate_entry).collect())) } else { None } }) .collect(); let filtered_size = self.files_with_identical_size.values().map(|v| v.len() as u64).sum::(); debug!( "check_file_size - filtered hard links in {:?}, removed {} hardlinks ({} -> {})", start_time.elapsed(), initial_size - filtered_size, initial_size, filtered_size ); self.filter_reference_folders_by_size(); self.calculate_size_stats(); debug!( "check_file_size - after calculating size stats/duplicates, found in {} groups, {} files with same size | referenced {} groups, {} files", self.files_with_identical_size.len(), self.files_with_identical_size.values().map(Vec::len).sum::(), self.files_with_identical_size_referenced.len(), self.files_with_identical_size_referenced.values().map(|(_fe, vec)| vec.len()).sum::() ); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn calculate_size_stats(&mut self) { if self.common_data.use_reference_folders { for (size, (_fe, vector)) in &self.files_with_identical_size_referenced { self.information.number_of_duplicated_files_by_size += vector.len(); self.information.number_of_groups_by_size += 1; self.information.lost_space_by_size += (vector.len() as u64) * size; } } else { for (size, vector) in &self.files_with_identical_size { self.information.number_of_duplicated_files_by_size += vector.len() - 1; self.information.number_of_groups_by_size += 1; self.information.lost_space_by_size += (vector.len() as u64 - 1) * size; } } } #[fun_time(message = "filter_reference_folders_by_size", level = "debug")] fn filter_reference_folders_by_size(&mut self) { if self.common_data.use_reference_folders && self.get_params().check_method == CheckingMethod::Size { let vec = mem::take(&mut self.files_with_identical_size) .into_iter() .filter_map(|(_size, vec_file_entry)| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>(); for (fe, vec_fe) in vec { self.files_with_identical_size_referenced.insert(fe.size, (fe, vec_fe)); } } } #[fun_time(message = "prehash_load_cache_at_start", level = "debug")] fn prehash_load_cache_at_start(&mut self) -> (BTreeMap>, BTreeMap>, BTreeMap>) { // Cache algorithm // - Load data from cache // - Convert from BT> to BT // - Save to proper values let loaded_hash_map; let mut records_already_cached: BTreeMap> = Default::default(); let mut non_cached_files_to_check: BTreeMap> = Default::default(); if self.get_params().use_prehash_cache { let (messages, loaded_items) = load_cache_from_file_generalized_by_size::( &get_duplicate_cache_file(self.get_params().hash_type, true), self.get_delete_outdated_cache(), &self.files_with_identical_size, ); self.get_text_messages_mut().extend_with_another_messages(messages); loaded_hash_map = loaded_items.unwrap_or_default(); Self::diff_loaded_and_prechecked_files( "prehash_load_cache_at_start", mem::take(&mut self.files_with_identical_size), &loaded_hash_map, &mut records_already_cached, &mut non_cached_files_to_check, ); } else { loaded_hash_map = Default::default(); mem::swap(&mut self.files_with_identical_size, &mut non_cached_files_to_check); } (loaded_hash_map, records_already_cached, non_cached_files_to_check) } #[fun_time(message = "prehash_save_cache_at_exit", level = "debug")] fn prehash_save_cache_at_exit( &mut self, loaded_hash_map: BTreeMap>, pre_hash_results: Vec<(u64, BTreeMap>, Vec)>, ) { if self.get_params().use_prehash_cache { // All results = records already cached + computed results let mut save_cache_to_hashmap: BTreeMap = Default::default(); for (size, vec_file_entry) in loaded_hash_map { if size >= self.get_params().minimal_prehash_cache_file_size { for file_entry in vec_file_entry { save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().to_string(), file_entry); } } } for (size, hash_map, _errors) in pre_hash_results { if size >= self.get_params().minimal_prehash_cache_file_size { for vec_file_entry in hash_map.into_values() { for file_entry in vec_file_entry { save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().to_string(), file_entry); } } } } let messages = save_cache_to_file_generalized( &get_duplicate_cache_file(self.get_params().hash_type, true), &save_cache_to_hashmap, self.common_data.save_also_as_json, self.get_params().minimal_prehash_cache_file_size, ); self.get_text_messages_mut().extend_with_another_messages(messages); } } #[fun_time(message = "prehashing", level = "debug")] fn prehashing( &mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, pre_checked_map: &mut BTreeMap>, ) -> WorkContinueStatus { if self.files_with_identical_size.is_empty() { return WorkContinueStatus::Continue; } let check_type = self.get_params().hash_type; let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::DuplicatePreHashCacheLoading, 0, self.get_test_type(), 0); let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.prehash_load_cache_at_start(); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::DuplicatePreHashing, non_cached_files_to_check.values().map(Vec::len).sum(), self.get_test_type(), non_cached_files_to_check .iter() .map(|(size, items)| items.len() as u64 * PREHASHING_BUFFER_SIZE.min(*size)) .sum::(), ); // Convert to vector to be able to use with_max_len method from rayon let non_cached_files_to_check: Vec<(u64, Vec)> = non_cached_files_to_check.into_iter().collect(); debug!("Starting calculating prehash"); #[expect(clippy::type_complexity)] let pre_hash_results: Vec<(u64, BTreeMap>, Vec)> = non_cached_files_to_check .into_par_iter() .with_max_len(3) // Vectors and BTreeMaps for really big inputs, leave some jobs to 0 thread, to avoid that I minimized max tasks for each thread to 3, which improved performance .map(|(size, vec_file_entry)| { let mut hashmap_with_hash: BTreeMap> = Default::default(); let mut errors: Vec = Vec::new(); THREAD_BUFFER.with_borrow_mut(|buffer| { for mut file_entry in vec_file_entry { if check_if_stop_received(stop_flag) { return None; } match hash_calculation_limit(buffer, &file_entry, check_type, PREHASHING_BUFFER_SIZE, progress_handler.size_counter()) { Ok(hash_string) => { file_entry.hash = hash_string.clone(); hashmap_with_hash.entry(hash_string).or_default().push(file_entry); } Err(s) => errors.push(s), } progress_handler.increase_items(1); } Some(()) })?; Some((size, hashmap_with_hash, errors)) }) .while_some() .collect(); debug!("Completed calculating prehash"); progress_handler.join_thread(); // Saving into cache let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::DuplicatePreHashCacheSaving, 0, self.get_test_type(), 0); // Add data from cache for (size, mut vec_file_entry) in records_already_cached { pre_checked_map.entry(size).or_default().append(&mut vec_file_entry); } // Check results for (size, hash_map, errors) in &pre_hash_results { if !errors.is_empty() { self.common_data.text_messages.warnings.append(&mut errors.clone()); } for vec_file_entry in hash_map.values() { if vec_file_entry.len() > 1 { pre_checked_map.entry(*size).or_default().append(&mut vec_file_entry.clone()); } } } self.prehash_save_cache_at_exit(loaded_hash_map, pre_hash_results); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } fn diff_loaded_and_prechecked_files( function_name: &str, used_map: BTreeMap>, loaded_hash_map: &BTreeMap>, records_already_cached: &mut BTreeMap>, non_cached_files_to_check: &mut BTreeMap>, ) { debug!("{function_name} - started diff between loaded and prechecked files"); for (size, mut vec_file_entry) in used_map { if let Some(cached_vec_file_entry) = loaded_hash_map.get(&size) { // TODO maybe hashmap is not needed when using < 4 elements let mut cached_path_entries: IndexMap<&Path, DuplicateEntry> = IndexMap::new(); for file_entry in cached_vec_file_entry { cached_path_entries.insert(&file_entry.path, file_entry.clone()); } for file_entry in vec_file_entry { if let Some(cached_file_entry) = cached_path_entries.swap_remove(file_entry.path.as_path()) { records_already_cached.entry(size).or_default().push(cached_file_entry); } else { non_cached_files_to_check.entry(size).or_default().push(file_entry); } } } else { non_cached_files_to_check.entry(size).or_default().append(&mut vec_file_entry); } } debug!( "{function_name} - completed diff between loaded and prechecked files - {}({}) non cached, {}({}) already cached", non_cached_files_to_check.len(), format_size(non_cached_files_to_check.values().map(|v| v.iter().map(|e| e.size).sum::()).sum::(), BINARY), records_already_cached.len(), format_size(records_already_cached.values().map(|v| v.iter().map(|e| e.size).sum::()).sum::(), BINARY), ); } #[fun_time(message = "full_hashing_load_cache_at_start", level = "debug")] fn full_hashing_load_cache_at_start( &mut self, mut pre_checked_map: BTreeMap>, ) -> (BTreeMap>, BTreeMap>, BTreeMap>) { let loaded_hash_map; let mut records_already_cached: BTreeMap> = Default::default(); let mut non_cached_files_to_check: BTreeMap> = Default::default(); if self.common_data.use_cache { debug!("full_hashing_load_cache_at_start - using cache"); let (messages, loaded_items) = load_cache_from_file_generalized_by_size::( &get_duplicate_cache_file(self.get_params().hash_type, false), self.get_delete_outdated_cache(), &pre_checked_map, ); self.get_text_messages_mut().extend_with_another_messages(messages); loaded_hash_map = loaded_items.unwrap_or_default(); Self::diff_loaded_and_prechecked_files( "full_hashing_load_cache_at_start", pre_checked_map, &loaded_hash_map, &mut records_already_cached, &mut non_cached_files_to_check, ); } else { debug!("full_hashing_load_cache_at_start - not using cache"); loaded_hash_map = Default::default(); mem::swap(&mut pre_checked_map, &mut non_cached_files_to_check); } (loaded_hash_map, records_already_cached, non_cached_files_to_check) } #[fun_time(message = "full_hashing_save_cache_at_exit", level = "debug")] fn full_hashing_save_cache_at_exit( &mut self, records_already_cached: BTreeMap>, full_hash_results: &mut Vec<(u64, BTreeMap>, Vec)>, loaded_hash_map: BTreeMap>, ) { if !self.common_data.use_cache { return; } 'main: for (size, vec_file_entry) in records_already_cached { // Check if size already exists, if exists we must to change it outside because cannot have mut and non mut reference to full_hash_results for (full_size, full_hashmap, _errors) in &mut (*full_hash_results) { if size == *full_size { for file_entry in vec_file_entry { full_hashmap.entry(file_entry.hash.clone()).or_default().push(file_entry); } continue 'main; } } // Size doesn't exists add results to files let mut temp_hashmap: BTreeMap> = Default::default(); for file_entry in vec_file_entry { temp_hashmap.entry(file_entry.hash.clone()).or_default().push(file_entry); } full_hash_results.push((size, temp_hashmap, Vec::new())); } // Must save all results to file, old loaded from file with all currently counted results let mut all_results: BTreeMap = Default::default(); for (_size, vec_file_entry) in loaded_hash_map { for file_entry in vec_file_entry { all_results.insert(file_entry.path.to_string_lossy().to_string(), file_entry); } } for (_size, hashmap, _errors) in full_hash_results { for vec_file_entry in hashmap.values() { for file_entry in vec_file_entry { all_results.insert(file_entry.path.to_string_lossy().to_string(), file_entry.clone()); } } } let messages = save_cache_to_file_generalized( &get_duplicate_cache_file(self.get_params().hash_type, false), &all_results, self.common_data.save_also_as_json, self.get_params().minimal_cache_file_size, ); self.get_text_messages_mut().extend_with_another_messages(messages); } #[fun_time(message = "full_hashing", level = "debug")] fn full_hashing( &mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, pre_checked_map: BTreeMap>, ) -> WorkContinueStatus { if pre_checked_map.is_empty() { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::DuplicateCacheLoading, 0, self.get_test_type(), 0); let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.full_hashing_load_cache_at_start(pre_checked_map); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::DuplicateFullHashing, non_cached_files_to_check.values().map(Vec::len).sum(), self.get_test_type(), non_cached_files_to_check.iter().map(|(size, items)| (*size) * items.len() as u64).sum::(), ); let non_cached_files_to_check: Vec<(u64, Vec)> = non_cached_files_to_check.into_iter().collect(); let check_type = self.get_params().hash_type; debug!( "Starting full hashing of {} files", non_cached_files_to_check.iter().map(|(_size, v)| v.len() as u64).sum::() ); let mut full_hash_results: Vec<(u64, BTreeMap>, Vec)> = non_cached_files_to_check .into_par_iter() .with_max_len(3) .map(|(size, vec_file_entry)| { let mut hashmap_with_hash: BTreeMap> = Default::default(); let mut errors: Vec = Vec::new(); THREAD_BUFFER.with_borrow_mut(|buffer| { for mut file_entry in vec_file_entry { if check_if_stop_received(stop_flag) { return None; } match hash_calculation(buffer, &file_entry, check_type, progress_handler.size_counter(), stop_flag) { Ok(hash_string) => { if let Some(hash_string) = hash_string { file_entry.hash = hash_string.clone(); hashmap_with_hash.entry(hash_string).or_default().push(file_entry); } else { return None; } } Err(s) => errors.push(s), } progress_handler.increase_items(1); } Some(()) })?; Some((size, hashmap_with_hash, errors)) }) .while_some() .collect(); debug!("Finished full hashing"); // Even if clicked stop, save items to cache and show results progress_handler.join_thread(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::DuplicateCacheSaving, 0, self.get_test_type(), 0); self.full_hashing_save_cache_at_exit(records_already_cached, &mut full_hash_results, loaded_hash_map); progress_handler.join_thread(); for (size, hash_map, mut errors) in full_hash_results { self.common_data.text_messages.warnings.append(&mut errors); for (_hash, vec_file_entry) in hash_map { if vec_file_entry.len() > 1 { self.files_with_identical_hashes.entry(size).or_default().push(vec_file_entry); } } } WorkContinueStatus::Continue } #[fun_time(message = "hash_reference_folders", level = "debug")] fn hash_reference_folders(&mut self) { // Reference - only use in size, because later hash will be counted differently if self.common_data.use_reference_folders { let vec = mem::take(&mut self.files_with_identical_hashes) .into_iter() .filter_map(|(_size, vec_vec_file_entry)| { let mut all_results_with_same_size = Vec::new(); for vec_file_entry in vec_vec_file_entry { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { continue; } if let Some(file) = files_from_referenced_folders.pop() { all_results_with_same_size.push((file, normal_files)); } } if all_results_with_same_size.is_empty() { None } else { Some(all_results_with_same_size) } }) .collect::)>>>(); #[expect(clippy::indexing_slicing)] // Safe, because here, empty vectors cannot exist for vec_of_vec in vec { self.files_with_identical_hashes_referenced.insert(vec_of_vec[0].0.size, vec_of_vec); } } if self.common_data.use_reference_folders { for (size, vector_vectors) in &self.files_with_identical_hashes_referenced { for (_fe, vector) in vector_vectors { self.information.number_of_duplicated_files_by_hash += vector.len(); self.information.number_of_groups_by_hash += 1; self.information.lost_space_by_hash += (vector.len() as u64) * size; } } } else { for (size, vector_vectors) in &self.files_with_identical_hashes { for vector in vector_vectors { self.information.number_of_duplicated_files_by_hash += vector.len() - 1; self.information.number_of_groups_by_hash += 1; self.information.lost_space_by_hash += (vector.len() as u64 - 1) * size; } } } } #[fun_time(message = "check_files_hash", level = "debug")] pub(crate) fn check_files_hash(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { assert_eq!(self.get_params().check_method, CheckingMethod::Hash); let mut pre_checked_map: BTreeMap> = Default::default(); if self.prehashing(stop_flag, progress_sender, &mut pre_checked_map) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } if self.full_hashing(stop_flag, progress_sender, pre_checked_map) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } self.hash_reference_folders(); // Clean unused data let files_with_identical_size = mem::take(&mut self.files_with_identical_size); thread::spawn(move || drop(files_with_identical_size)); WorkContinueStatus::Continue } } pub fn get_duplicate_cache_file(type_of_hash: HashType, is_prehash: bool) -> String { let prehash_str = if is_prehash { "_prehash" } else { "" }; format!("cache_duplicates_{type_of_hash:?}{prehash_str}_{CACHE_DUPLICATE_VERSION}.bin") } czkawka_core-11.0.1/src/tools/duplicate/mod.rs000064400000000000000000000334271046102023000174060ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::cell::RefCell; use std::collections::BTreeMap; use std::fmt::Debug; #[cfg(target_family = "unix")] use std::fs; use std::fs::File; use std::hash::Hasher; use std::io::prelude::*; #[cfg(target_family = "unix")] use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use indexmap::IndexSet; use serde::{Deserialize, Serialize}; use static_assertions::const_assert; use xxhash_rust::xxh3::Xxh3; use crate::common::model::{CheckingMethod, FileEntry, HashType}; use crate::common::progress_stop_handler::check_if_stop_received; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; use crate::flc; pub const PREHASHING_BUFFER_SIZE: u64 = 4 * 1024; pub const THREAD_BUFFER_SIZE: usize = 2 * 1024 * 1024; thread_local! { static THREAD_BUFFER: RefCell> = RefCell::new(vec![0u8; THREAD_BUFFER_SIZE]); } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct DuplicateEntry { pub path: PathBuf, pub modified_date: u64, pub size: u64, pub hash: String, } impl ResultEntry for DuplicateEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_duplicate_entry(self) -> DuplicateEntry { DuplicateEntry { size: self.size, path: self.path, modified_date: self.modified_date, hash: String::new(), } } } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_groups_by_size: usize, pub number_of_duplicated_files_by_size: usize, pub number_of_groups_by_hash: usize, pub number_of_duplicated_files_by_hash: usize, pub number_of_groups_by_name: usize, pub number_of_duplicated_files_by_name: usize, pub number_of_groups_by_size_name: usize, pub number_of_duplicated_files_by_size_name: usize, pub lost_space_by_size: u64, pub lost_space_by_hash: u64, pub scanning_time: Duration, } #[derive(Clone)] pub struct DuplicateFinderParameters { pub check_method: CheckingMethod, pub hash_type: HashType, pub use_prehash_cache: bool, pub minimal_cache_file_size: u64, pub minimal_prehash_cache_file_size: u64, pub case_sensitive_name_comparison: bool, } impl DuplicateFinderParameters { pub fn new( check_method: CheckingMethod, hash_type: HashType, use_prehash_cache: bool, minimal_cache_file_size: u64, minimal_prehash_cache_file_size: u64, case_sensitive_name_comparison: bool, ) -> Self { Self { check_method, hash_type, use_prehash_cache, minimal_cache_file_size, minimal_prehash_cache_file_size, case_sensitive_name_comparison, } } } pub struct DuplicateFinder { common_data: CommonToolData, information: Info, // File Size, File Entry files_with_identical_names: BTreeMap>, // File (Size, Name), File Entry files_with_identical_size_names: BTreeMap<(u64, String), Vec>, // File Size, File Entry files_with_identical_size: BTreeMap>, // File Size, next grouped by file size, next grouped by hash files_with_identical_hashes: BTreeMap>>, // File Size, File Entry files_with_identical_names_referenced: BTreeMap)>, // File (Size, Name), File Entry files_with_identical_size_names_referenced: BTreeMap<(u64, String), (DuplicateEntry, Vec)>, // File Size, File Entry files_with_identical_size_referenced: BTreeMap)>, // File Size, next grouped by file size, next grouped by hash files_with_identical_hashes_referenced: BTreeMap)>>, params: DuplicateFinderParameters, } #[cfg(target_family = "windows")] fn filter_hard_links(vec_file_entry: Vec) -> Vec { let mut inodes: IndexSet = IndexSet::with_capacity(vec_file_entry.len()); let mut identical: Vec = Vec::with_capacity(vec_file_entry.len()); for f in vec_file_entry { if let Ok(meta) = file_id::get_high_res_file_id(&f.path) { if let file_id::FileId::HighRes { file_id, .. } = meta { if !inodes.insert(file_id) { continue; } } } identical.push(f); } identical } #[cfg(target_family = "unix")] fn filter_hard_links(vec_file_entry: Vec) -> Vec { let mut inodes: IndexSet = IndexSet::with_capacity(vec_file_entry.len()); let mut identical: Vec = Vec::with_capacity(vec_file_entry.len()); for f in vec_file_entry { if let Ok(meta) = fs::metadata(&f.path) && !inodes.insert(meta.ino()) { continue; } identical.push(f); } identical } pub trait MyHasher { fn update(&mut self, bytes: &[u8]); fn finalize(&self) -> String; } impl DuplicateFinder { pub fn get_params(&self) -> &DuplicateFinderParameters { &self.params } pub const fn get_files_sorted_by_names(&self) -> &BTreeMap> { &self.files_with_identical_names } pub const fn get_files_sorted_by_size(&self) -> &BTreeMap> { &self.files_with_identical_size } pub const fn get_files_sorted_by_size_name(&self) -> &BTreeMap<(u64, String), Vec> { &self.files_with_identical_size_names } pub const fn get_files_sorted_by_hash(&self) -> &BTreeMap>> { &self.files_with_identical_hashes } pub const fn get_information(&self) -> Info { self.information } pub fn set_dry_run(&mut self, dry_run: bool) { self.common_data.dry_run = dry_run; } pub fn get_use_reference(&self) -> bool { self.common_data.use_reference_folders } pub fn get_files_with_identical_hashes_referenced(&self) -> &BTreeMap)>> { &self.files_with_identical_hashes_referenced } pub fn get_files_with_identical_name_referenced(&self) -> &BTreeMap)> { &self.files_with_identical_names_referenced } pub fn get_files_with_identical_size_referenced(&self) -> &BTreeMap)> { &self.files_with_identical_size_referenced } pub fn get_files_with_identical_size_names_referenced(&self) -> &BTreeMap<(u64, String), (DuplicateEntry, Vec)> { &self.files_with_identical_size_names_referenced } } pub(crate) fn hash_calculation_limit(buffer: &mut [u8], file_entry: &DuplicateEntry, hash_type: HashType, limit: u64, size_counter: &Arc) -> Result { // This function is used only to calculate hash of file with limit // We must ensure that buffer is big enough to store all data // We don't need to check that each time const_assert!(PREHASHING_BUFFER_SIZE <= THREAD_BUFFER_SIZE as u64); let mut file_handler = match File::open(&file_entry.path) { Ok(t) => t, Err(e) => { size_counter.fetch_add(limit, Ordering::Relaxed); return Err(flc!( "core_unable_check_hash_of_file", file = file_entry.path.to_string_lossy().to_string(), reason = e.to_string() )); } }; let hasher = &mut *hash_type.hasher(); #[expect(clippy::indexing_slicing)] // Safe, because limit is always <= buffer size let n = match file_handler.read(&mut buffer[..limit as usize]) { Ok(t) => t, Err(e) => return Err(flc!("core_error_checking_hash_of_file", file = file_entry.path.to_string_lossy(), reason = e.to_string())), }; #[expect(clippy::indexing_slicing)] // Safe, because we read only n bytes, which is always <= limit <= buffer size hasher.update(&buffer[..n]); size_counter.fetch_add(n as u64, Ordering::Relaxed); Ok(hasher.finalize()) } pub fn hash_calculation( buffer: &mut [u8], file_entry: &DuplicateEntry, hash_type: HashType, size_counter: &Arc, stop_flag: &Arc, ) -> Result, String> { let mut file_handler = match File::open(&file_entry.path) { Ok(t) => t, Err(e) => { size_counter.fetch_add(file_entry.size, Ordering::Relaxed); return Err(flc!("core_unable_check_hash_of_file", file = file_entry.path.to_string_lossy(), reason = e.to_string())); } }; let hasher = &mut *hash_type.hasher(); loop { let n = match file_handler.read(buffer) { Ok(0) => break, Ok(t) => t, Err(e) => return Err(flc!("core_error_checking_hash_of_file", file = file_entry.path.to_string_lossy(), reason = e.to_string())), }; #[expect(clippy::indexing_slicing)] // Safe, because we read only n bytes, which is always <= buffer size hasher.update(&buffer[..n]); size_counter.fetch_add(n as u64, Ordering::Relaxed); if check_if_stop_received(stop_flag) { return Ok(None); } } Ok(Some(hasher.finalize())) } impl MyHasher for blake3::Hasher { fn update(&mut self, bytes: &[u8]) { self.update(bytes); } fn finalize(&self) -> String { self.finalize().to_hex().to_string() } } impl MyHasher for crc32fast::Hasher { fn update(&mut self, bytes: &[u8]) { self.write(bytes); } fn finalize(&self) -> String { self.finish().to_string() } } impl MyHasher for Xxh3 { fn update(&mut self, bytes: &[u8]) { self.write(bytes); } fn finalize(&self) -> String { self.finish().to_string() } } #[cfg(test)] mod tests2 { use std::fs::File; use std::io; use super::*; use crate::common::model::FileEntry; use crate::tools::duplicate::filter_hard_links; #[test] fn test_filter_hard_links_empty() { let expected: Vec = Default::default(); assert_eq!(expected, filter_hard_links(Vec::new())); } #[cfg(target_family = "unix")] #[test] fn test_filter_hard_links() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); File::create(&src)?; fs::hard_link(src.clone(), dst.clone())?; let e1 = FileEntry { path: src, ..Default::default() }; let e2 = FileEntry { path: dst, ..Default::default() }; let actual = filter_hard_links(vec![e1.clone(), e2]); assert_eq!(vec![e1], actual); Ok(()) } #[test] fn test_filter_hard_links_regular_files() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let (src, dst) = (dir.path().join("a"), dir.path().join("b")); File::create(&src)?; File::create(&dst)?; let e1 = FileEntry { path: src, ..Default::default() }; let e2 = FileEntry { path: dst, ..Default::default() }; let actual = filter_hard_links(vec![e1.clone(), e2.clone()]); assert_eq!(vec![e1, e2], actual); Ok(()) } #[test] fn test_hash_calculation() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let mut buf = [0u8; 1 << 10]; let src = dir.path().join("a"); let mut file = File::create(&src)?; file.write_all(b"aaAAAAAAAAAAAAAAFFFFFFFFFFFFFFFFFFFFGGGGGGGGG")?; let e = DuplicateEntry { path: src, ..Default::default() }; let size_counter = Arc::new(AtomicU64::new(0)); let r = hash_calculation(&mut buf, &e, HashType::Blake3, &size_counter, &Arc::default()) .expect("hash_calculation failed") .expect("hash_calculation returned None"); assert!(!r.is_empty()); assert_eq!(size_counter.load(Ordering::Relaxed), 45); Ok(()) } #[test] fn test_hash_calculation_limit() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let mut buf = [0u8; 1000]; let src = dir.path().join("a"); let mut file = File::create(&src)?; file.write_all(b"aa")?; let e = DuplicateEntry { path: src, ..Default::default() }; let size_counter_1 = Arc::new(AtomicU64::new(0)); let size_counter_2 = Arc::new(AtomicU64::new(0)); let size_counter_3 = Arc::new(AtomicU64::new(0)); let r1 = hash_calculation_limit(&mut buf, &e, HashType::Blake3, 1, &size_counter_1).expect("hash_calculation failed"); let r2 = hash_calculation_limit(&mut buf, &e, HashType::Blake3, 2, &size_counter_2).expect("hash_calculation failed"); let r3 = hash_calculation_limit(&mut buf, &e, HashType::Blake3, 1000, &size_counter_3).expect("hash_calculation failed"); assert_ne!(r1, r2); assert_eq!(r2, r3); assert_eq!(1, size_counter_1.load(Ordering::Relaxed)); assert_eq!(2, size_counter_2.load(Ordering::Relaxed)); assert_eq!(2, size_counter_3.load(Ordering::Relaxed)); Ok(()) } #[test] fn test_hash_calculation_invalid_file() -> io::Result<()> { let dir = tempfile::Builder::new().tempdir()?; let mut buf = [0u8; 1 << 10]; let src = dir.path().join("a"); let e = DuplicateEntry { path: src, ..Default::default() }; let r = hash_calculation(&mut buf, &e, HashType::Blake3, &Arc::default(), &Arc::default()).expect_err("hash_calculation succeeded"); assert!(!r.is_empty()); Ok(()) } } czkawka_core-11.0.1/src/tools/duplicate/tests.rs000064400000000000000000000122121046102023000177560ustar 00000000000000use std::fs; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::model::{CheckingMethod, HashType}; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::duplicate::{DuplicateFinder, DuplicateFinderParameters}; #[test] fn test_find_duplicates_by_hash() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create duplicate files with same content fs::write(path.join("file1.txt"), b"duplicate content").unwrap(); fs::write(path.join("file2.txt"), b"duplicate content").unwrap(); fs::write(path.join("unique.txt"), b"unique content").unwrap(); let params = DuplicateFinderParameters::new(CheckingMethod::Hash, HashType::Blake3, false, 0, 0, true); let mut finder = DuplicateFinder::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_minimal_file_size(0); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_groups_by_hash, 1, "Should find 1 group of duplicates"); assert_eq!(info.number_of_duplicated_files_by_hash, 1, "Should find 1 duplicate file"); } #[test] fn test_find_duplicates_by_size() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create files with same size fs::write(path.join("file1.txt"), b"12345").unwrap(); fs::write(path.join("file2.txt"), b"abcde").unwrap(); fs::write(path.join("unique.txt"), b"123").unwrap(); let params = DuplicateFinderParameters::new(CheckingMethod::Size, HashType::Blake3, false, 0, 0, true); let mut finder = DuplicateFinder::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); finder.set_minimal_file_size(0); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_groups_by_size, 1, "Should find 1 group by size"); assert_eq!(info.number_of_duplicated_files_by_size, 1, "Should find 1 duplicate by size"); } #[test] fn test_find_duplicates_by_name() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let dir1 = path.join("dir1"); let dir2 = path.join("dir2"); fs::create_dir(&dir1).unwrap(); fs::create_dir(&dir2).unwrap(); // Create files with same name in different directories fs::write(dir1.join("duplicate.txt"), b"content1").unwrap(); fs::write(dir2.join("duplicate.txt"), b"content2").unwrap(); fs::write(dir1.join("unique.txt"), b"unique").unwrap(); let params = DuplicateFinderParameters::new(CheckingMethod::Name, HashType::Blake3, false, 0, 0, true); let mut finder = DuplicateFinder::new(params); finder.set_recursive_search(true); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_minimal_file_size(0); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_groups_by_name, 1, "Should find 1 group by name"); assert_eq!(info.number_of_duplicated_files_by_name, 1, "Should find 1 duplicate by name"); } #[test] fn test_no_duplicates_found() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create unique files fs::write(path.join("file1.txt"), b"content1").unwrap(); fs::write(path.join("file2.txt"), b"content2").unwrap(); let params = DuplicateFinderParameters::new(CheckingMethod::Hash, HashType::Blake3, false, 0, 0, true); let mut finder = DuplicateFinder::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); finder.set_minimal_file_size(0); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_groups_by_hash, 0, "Should find no duplicate groups"); assert_eq!(info.lost_space_by_hash, 0, "Should have no lost space"); } #[test] fn test_lost_space_calculation() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create 3 files with 100 bytes each, all duplicates let content = vec![b'A'; 100]; fs::write(path.join("file1.txt"), &content).unwrap(); fs::write(path.join("file2.txt"), &content).unwrap(); fs::write(path.join("file3.txt"), &content).unwrap(); let params = DuplicateFinderParameters::new(CheckingMethod::Hash, HashType::Blake3, false, 0, 0, true); let mut finder = DuplicateFinder::new(params); finder.set_minimal_file_size(0); finder.set_use_cache(false); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.lost_space_by_hash, 200, "Should calculate 200 bytes lost space (2 duplicate files * 100 bytes)"); } czkawka_core-11.0.1/src/tools/duplicate/traits.rs000064400000000000000000000456731046102023000201430ustar 00000000000000use std::io::prelude::*; use std::io::{self}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use crate::common::model::{CheckingMethod, WorkContinueStatus}; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::duplicate::{DuplicateFinder, DuplicateFinderParameters, Info}; impl AllTraits for DuplicateFinder {} impl DeletingItems for DuplicateFinder { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.common_data.delete_method == DeleteMethod::None { return WorkContinueStatus::Continue; } let files_to_delete = match self.get_params().check_method { CheckingMethod::Name => self.files_with_identical_names.values().cloned().collect::>(), CheckingMethod::SizeName => self.files_with_identical_size_names.values().cloned().collect::>(), CheckingMethod::Hash => self.files_with_identical_hashes.values().flatten().cloned().collect::>(), CheckingMethod::Size => self.files_with_identical_size.values().cloned().collect::>(), _ => panic!(), }; self.delete_advanced_elements_and_add_to_messages(stop_flag, progress_sender, files_to_delete) } } impl Search for DuplicateFinder { #[fun_time(message = "find_duplicates", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } self.common_data.use_reference_folders = !self.common_data.directories.reference_directories.is_empty() || !self.common_data.directories.reference_files.is_empty(); match self.get_params().check_method { CheckingMethod::Name => { self.common_data.stopped_search = self.check_files_name(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { return; } } CheckingMethod::SizeName => { self.common_data.stopped_search = self.check_files_size_name(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { return; } } CheckingMethod::Size => { self.common_data.stopped_search = self.check_files_size(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { return; } } CheckingMethod::Hash => { self.common_data.stopped_search = self.check_files_size(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { return; } self.common_data.stopped_search = self.check_files_hash(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { return; } } _ => panic!(), } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); self.debug_print(); } } impl DebugPrint for DuplicateFinder { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); println!( "Number of duplicated files by size(in groups) - {} ({})", self.information.number_of_duplicated_files_by_size, self.information.number_of_groups_by_size ); println!( "Number of duplicated files by hash(in groups) - {} ({})", self.information.number_of_duplicated_files_by_hash, self.information.number_of_groups_by_hash ); println!( "Number of duplicated files by name(in groups) - {} ({})", self.information.number_of_duplicated_files_by_name, self.information.number_of_groups_by_name ); println!( "Lost space by size - {} ({} bytes)", format_size(self.information.lost_space_by_size, BINARY), self.information.lost_space_by_size ); println!( "Lost space by hash - {} ({} bytes)", format_size(self.information.lost_space_by_hash, BINARY), self.information.lost_space_by_hash ); println!("### Other"); println!("Files list size - {}", self.files_with_identical_size.len()); println!("Hashed files list size - {}", self.files_with_identical_hashes.len()); println!("Files with identical names - {}", self.files_with_identical_names.len()); println!("Files with identical size names - {}", self.files_with_identical_size_names.len()); println!("Files with identical names referenced - {}", self.files_with_identical_names_referenced.len()); println!("Files with identical size names referenced - {}", self.files_with_identical_size_names_referenced.len()); println!("Files with identical size referenced - {}", self.files_with_identical_size_referenced.len()); println!("Files with identical hashes referenced - {}", self.files_with_identical_hashes_referenced.len()); println!("Checking Method - {:?}", self.get_params().check_method); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for DuplicateFinder { fn write_results(&self, writer: &mut T) -> io::Result<()> { self.write_base_search_paths(writer)?; match self.get_params().check_method { CheckingMethod::Name => { if !self.files_with_identical_names.is_empty() { writeln!( writer, "-------------------------------------------------Files with same names-------------------------------------------------" )?; writeln!( writer, "Found {} files in {} groups with same name(may have different content)", self.information.number_of_duplicated_files_by_name, self.information.number_of_groups_by_name, )?; for (name, vector) in self.files_with_identical_names.iter().rev() { writeln!(writer, "Name - {} - {} files ", name, vector.len())?; for j in vector { writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; } writeln!(writer)?; } } else if !self.files_with_identical_names_referenced.is_empty() { writeln!( writer, "-------------------------------------------------Files with same names in referenced folders-------------------------------------------------" )?; writeln!( writer, "Found {} files in {} groups with same name(may have different content)", self.information.number_of_duplicated_files_by_name, self.information.number_of_groups_by_name, )?; for (name, (file_entry, vector)) in self.files_with_identical_names_referenced.iter().rev() { writeln!(writer, "Name - {} - {} files ", name, vector.len())?; writeln!(writer, "Reference file - \"{}\"", file_entry.path.to_string_lossy())?; for j in vector { writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; } writeln!(writer)?; } } else { write!(writer, "Not found any files with same names.")?; } } CheckingMethod::SizeName => { if !self.files_with_identical_names.is_empty() { writeln!( writer, "-------------------------------------------------Files with same size and names-------------------------------------------------" )?; writeln!( writer, "Found {} files in {} groups with same size and name(may have different content)", self.information.number_of_duplicated_files_by_size_name, self.information.number_of_groups_by_size_name, )?; for ((size, name), vector) in self.files_with_identical_size_names.iter().rev() { writeln!(writer, "Name - {}, {} - {} files ", name, format_size(*size, BINARY), vector.len())?; for j in vector { writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; } writeln!(writer)?; } } else if !self.files_with_identical_names_referenced.is_empty() { writeln!( writer, "-------------------------------------------------Files with same size and names in referenced folders-------------------------------------------------" )?; writeln!( writer, "Found {} files in {} groups with same size and name(may have different content)", self.information.number_of_duplicated_files_by_size_name, self.information.number_of_groups_by_size_name, )?; for ((size, name), (file_entry, vector)) in self.files_with_identical_size_names_referenced.iter().rev() { writeln!(writer, "Name - {}, {} - {} files ", name, format_size(*size, BINARY), vector.len())?; writeln!(writer, "Reference file - \"{}\"", file_entry.path.to_string_lossy())?; for j in vector { writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; } writeln!(writer)?; } } else { write!(writer, "Not found any files with same size and names.")?; } } CheckingMethod::Size => { if !self.files_with_identical_size.is_empty() { writeln!( writer, "-------------------------------------------------Files with same size-------------------------------------------------" )?; writeln!( writer, "Found {} duplicated files which in {} groups which takes {}.", self.information.number_of_duplicated_files_by_size, self.information.number_of_groups_by_size, format_size(self.information.lost_space_by_size, BINARY) )?; for (size, vector) in self.files_with_identical_size.iter().rev() { write!(writer, "\n---- Size {} ({}) - {} files \n", format_size(*size, BINARY), size, vector.len())?; for file_entry in vector { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } } } else if !self.files_with_identical_size_referenced.is_empty() { writeln!( writer, "-------------------------------------------------Files with same size in referenced folders-------------------------------------------------" )?; writeln!( writer, "Found {} duplicated files which in {} groups which takes {}.", self.information.number_of_duplicated_files_by_size, self.information.number_of_groups_by_size, format_size(self.information.lost_space_by_size, BINARY) )?; for (size, (file_entry, vector)) in self.files_with_identical_size_referenced.iter().rev() { writeln!(writer, "\n---- Size {} ({}) - {} files", format_size(*size, BINARY), size, vector.len())?; writeln!(writer, "Reference file - \"{}\"", file_entry.path.to_string_lossy())?; for file_entry in vector { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } } } else { write!(writer, "Not found any duplicates.")?; } } CheckingMethod::Hash => { if !self.files_with_identical_hashes.is_empty() { writeln!( writer, "-------------------------------------------------Files with same hashes-------------------------------------------------" )?; writeln!( writer, "Found {} duplicated files which in {} groups which takes {}.", self.information.number_of_duplicated_files_by_hash, self.information.number_of_groups_by_hash, format_size(self.information.lost_space_by_hash, BINARY) )?; for (size, vectors_vector) in self.files_with_identical_hashes.iter().rev() { for vector in vectors_vector { writeln!(writer, "\n---- Size {} ({}) - {} files", format_size(*size, BINARY), size, vector.len())?; for file_entry in vector { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } } } } else if !self.files_with_identical_hashes_referenced.is_empty() { writeln!( writer, "-------------------------------------------------Files with same hashes in referenced folders-------------------------------------------------" )?; writeln!( writer, "Found {} duplicated files which in {} groups which takes {}.", self.information.number_of_duplicated_files_by_hash, self.information.number_of_groups_by_hash, format_size(self.information.lost_space_by_hash, BINARY) )?; for (size, vectors_vector) in self.files_with_identical_hashes_referenced.iter().rev() { for (file_entry, vector) in vectors_vector { writeln!(writer, "\n---- Size {} ({}) - {} files", format_size(*size, BINARY), size, vector.len())?; writeln!(writer, "Reference file - \"{}\"", file_entry.path.to_string_lossy())?; for file_entry in vector { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } } } } else { write!(writer, "Not found any duplicates.")?; } } _ => panic!(), } Ok(()) } // TODO - check if is possible to save also data in header about size and name in SizeName mode - https://github.com/qarmin/czkawka/issues/1137 fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> io::Result<()> { if self.get_use_reference() { match self.get_params().check_method { CheckingMethod::Name => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_names_referenced, pretty_print), CheckingMethod::SizeName => { self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size_names_referenced.values().collect::>(), pretty_print) } CheckingMethod::Size => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size_referenced, pretty_print), CheckingMethod::Hash => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_hashes_referenced, pretty_print), _ => panic!(), } } else { match self.get_params().check_method { CheckingMethod::Name => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_names, pretty_print), CheckingMethod::SizeName => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size_names.values().collect::>(), pretty_print), CheckingMethod::Size => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size, pretty_print), CheckingMethod::Hash => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_hashes, pretty_print), _ => panic!(), } } } } impl CommonData for DuplicateFinder { type Info = Info; type Parameters = DuplicateFinderParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn get_check_method(&self) -> CheckingMethod { self.get_params().check_method } fn found_any_items(&self) -> bool { self.get_information().number_of_duplicated_files_by_hash > 0 || self.get_information().number_of_duplicated_files_by_name > 0 || self.get_information().number_of_duplicated_files_by_size > 0 || self.get_information().number_of_duplicated_files_by_size_name > 0 } } czkawka_core-11.0.1/src/tools/empty_files/core.rs000064400000000000000000000033131046102023000201140ustar 00000000000000use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use log::debug; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::ProgressData; use crate::common::tool_data::CommonToolData; use crate::tools::empty_files::{EmptyFiles, Info}; impl EmptyFiles { pub fn new() -> Self { Self { common_data: CommonToolData::new(ToolType::EmptyFiles), information: Info::default(), empty_files: Vec::new(), } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .minimal_file_size(0) .maximal_file_size(0) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.empty_files = grouped_file_entries.into_values().flatten().collect(); self.information.number_of_empty_files = self.empty_files.len(); self.common_data.text_messages.warnings.extend(warnings); debug!("Found {} empty files.", self.information.number_of_empty_files); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } } czkawka_core-11.0.1/src/tools/empty_files/mod.rs000064400000000000000000000012561046102023000177470ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::time::Duration; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_empty_files: usize, pub scanning_time: Duration, } pub struct EmptyFiles { common_data: CommonToolData, information: Info, empty_files: Vec, } impl Default for EmptyFiles { fn default() -> Self { Self::new() } } impl EmptyFiles { pub const fn get_empty_files(&self) -> &Vec { &self.empty_files } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/empty_files/tests.rs000064400000000000000000000044761046102023000203410ustar 00000000000000use std::fs; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::empty_files::EmptyFiles; #[test] fn test_find_empty_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create empty files fs::write(path.join("empty1.txt"), b"").unwrap(); fs::write(path.join("empty2.txt"), b"").unwrap(); fs::write(path.join("not_empty.txt"), b"content").unwrap(); let mut finder = EmptyFiles::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_empty_files, 2, "Should find 2 empty files"); assert_eq!(finder.get_empty_files().len(), 2, "Empty files list should contain 2 files"); } #[test] fn test_no_empty_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create only non-empty files fs::write(path.join("file1.txt"), b"content1").unwrap(); fs::write(path.join("file2.txt"), b"content2").unwrap(); let mut finder = EmptyFiles::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_empty_files, 0, "Should find no empty files"); assert!(finder.get_empty_files().is_empty(), "Empty files list should be empty"); } #[test] fn test_recursive_search_empty_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let subdir = path.join("subdir"); fs::create_dir(&subdir).unwrap(); // Create empty files in different directories fs::write(path.join("empty1.txt"), b"").unwrap(); fs::write(subdir.join("empty2.txt"), b"").unwrap(); let mut finder = EmptyFiles::new(); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_empty_files, 2, "Should find empty files in subdirectories"); } czkawka_core-11.0.1/src/tools/empty_files/traits.rs000064400000000000000000000070121046102023000204720ustar 00000000000000use std::io::prelude::*; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::empty_files::{EmptyFiles, Info}; impl AllTraits for EmptyFiles {} impl Search for EmptyFiles { #[fun_time(message = "find_empty_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for EmptyFiles { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); println!("Empty list size - {}", self.empty_files.len()); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for EmptyFiles { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.empty_files.is_empty() { writeln!(writer, "Found {} empty files.", self.information.number_of_empty_files)?; for file_entry in &self.empty_files { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } } else { write!(writer, "Not found any empty files.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.empty_files, pretty_print) } } impl CommonData for EmptyFiles { type Info = Info; type Parameters = (); fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_empty_files > 0 } } impl DeletingItems for EmptyFiles { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(self.empty_files.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } czkawka_core-11.0.1/src/tools/empty_folder/core.rs000064400000000000000000000235511046102023000202730ustar 00000000000000use std::fs::DirEntry; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use indexmap::IndexMap; use log::debug; use rayon::prelude::*; use crate::common::dir_traversal::{common_get_entry_data, common_get_metadata_dir, common_read_dir, get_modified_time}; use crate::common::directories::Directories; use crate::common::items::ExcludedItems; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::tools::empty_folder::{EmptyFolder, FolderEmptiness, FolderEntry, Info}; impl EmptyFolder { pub fn new() -> Self { Self { common_data: CommonToolData::new(ToolType::EmptyFolders), information: Default::default(), empty_folder_list: Default::default(), } } pub const fn get_empty_folder_list(&self) -> &IndexMap { &self.empty_folder_list } pub const fn get_information(&self) -> Info { self.information } pub(crate) fn optimize_folders(&mut self) { let mut new_directory_folders: IndexMap = Default::default(); for (name, folder_entry) in &self.empty_folder_list { match &folder_entry.parent_path { Some(t) => { if !self.empty_folder_list.contains_key(t) { new_directory_folders.insert(name.clone(), folder_entry.clone()); } } None => { new_directory_folders.insert(name.clone(), folder_entry.clone()); } } } self.empty_folder_list = new_directory_folders; self.information.number_of_empty_folders = self.empty_folder_list.len(); } #[fun_time(message = "check_for_empty_folders", level = "debug")] pub(crate) fn check_for_empty_folders(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let mut folders_to_check: Vec = self.common_data.directories.included_directories.clone(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::CollectingFiles, 0, self.get_test_type(), 0); let excluded_items = self.common_data.excluded_items.clone(); let directories = self.common_data.directories.clone(); let mut non_empty_folders: Vec = Vec::new(); let mut start_folder_entries = Vec::with_capacity(folders_to_check.len()); let mut new_folder_entries_list = Vec::new(); for dir in &folders_to_check { start_folder_entries.push(FolderEntry { path: dir.clone(), parent_path: None, is_empty: FolderEmptiness::Maybe, modified_date: 0, }); } while !folders_to_check.is_empty() { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } let segments: Vec<_> = folders_to_check .into_par_iter() .map(|current_folder| { let mut dir_result = Vec::new(); let mut warnings = Vec::new(); let mut non_empty_folder = None; let mut folder_entries_list = Vec::new(); let current_folder_as_string = current_folder.to_string_lossy().to_string(); let Some(read_dir) = common_read_dir(¤t_folder, &mut warnings) else { return (dir_result, warnings, Some(current_folder_as_string), folder_entries_list); }; let mut counter = 0; // Check every sub folder/file/link etc. for entry in read_dir { let Some(entry_data) = common_get_entry_data(&entry, &mut warnings, ¤t_folder) else { continue; }; let Ok(file_type) = entry_data.file_type() else { continue }; if file_type.is_dir() { counter += 1; Self::process_dir_in_dir_mode( ¤t_folder, ¤t_folder_as_string, entry_data, &directories, &mut dir_result, &mut warnings, &excluded_items, &mut non_empty_folder, &mut folder_entries_list, ); } else if non_empty_folder.is_none() { non_empty_folder = Some(current_folder_as_string.clone()); } } if counter > 0 { // Increase counter in batch, because usually it may be slow to add multiple times atomic value progress_handler.increase_items(counter); } (dir_result, warnings, non_empty_folder, folder_entries_list) }) .collect(); let required_size = segments.iter().map(|(segment, _, _, _)| segment.len()).sum::(); folders_to_check = Vec::with_capacity(required_size); // Process collected data for (segment, warnings, non_empty_folder, fe_list) in segments { folders_to_check.extend(segment); if !warnings.is_empty() { self.common_data.text_messages.warnings.extend(warnings); } if let Some(non_empty_folder) = non_empty_folder { non_empty_folders.push(non_empty_folder); } new_folder_entries_list.push(fe_list); } } let mut folder_entries: IndexMap = IndexMap::with_capacity(start_folder_entries.len() + new_folder_entries_list.iter().map(Vec::len).sum::()); for fe in start_folder_entries { folder_entries.insert(fe.path.to_string_lossy().to_string(), fe); } for fe_list in new_folder_entries_list { for fe in fe_list { folder_entries.insert(fe.path.to_string_lossy().to_string(), fe); } } for current_folder in non_empty_folders.into_iter().rev() { Self::set_as_not_empty_folder(&mut folder_entries, ¤t_folder); } for (name, folder_entry) in folder_entries { if folder_entry.is_empty != FolderEmptiness::No { self.empty_folder_list.insert(name, folder_entry); } } debug!("Found {} empty folders.", self.empty_folder_list.len()); progress_handler.join_thread(); WorkContinueStatus::Continue } pub(crate) fn set_as_not_empty_folder(folder_entries: &mut IndexMap, current_folder: &str) { let mut d = folder_entries .get_mut(current_folder) .unwrap_or_else(|| panic!("Folder {current_folder} not found in folder_entries (cannot panic, because we first added parent folders)")); if d.is_empty == FolderEmptiness::No { return; // Already set as non empty by one of its child } // Loop to recursively set as non empty this and all its parent folders loop { d.is_empty = FolderEmptiness::No; if let Some(parent_path) = &d.parent_path { let cf = parent_path.clone(); d = folder_entries .get_mut(&cf) .unwrap_or_else(|| panic!("Folder {cf} not found in folder_entries (cannot panic, because we first added parent folders)")); if d.is_empty == FolderEmptiness::No { break; // Already set as non empty, so one of child already set it to non empty } } else { break; } } } fn process_dir_in_dir_mode( current_folder: &Path, current_folder_as_str: &str, entry_data: &DirEntry, directories: &Directories, dir_result: &mut Vec, warnings: &mut Vec, excluded_items: &ExcludedItems, non_empty_folder: &mut Option, folder_entries_list: &mut Vec, ) { let next_folder = entry_data.path(); if excluded_items.is_excluded(&next_folder) || directories.is_excluded_dir(&next_folder) { if non_empty_folder.is_none() { *non_empty_folder = Some(current_folder_as_str.to_string()); } return; } #[cfg(target_family = "unix")] if directories.exclude_other_filesystems() { match directories.is_on_other_filesystems(&next_folder) { Ok(true) => return, Err(e) => warnings.push(e), _ => (), } } let Some(metadata) = common_get_metadata_dir(entry_data, warnings, &next_folder) else { if non_empty_folder.is_none() { *non_empty_folder = Some(current_folder_as_str.to_string()); } return; }; dir_result.push(next_folder.clone()); folder_entries_list.push(FolderEntry { path: next_folder, parent_path: Some(current_folder_as_str.to_string()), is_empty: FolderEmptiness::Maybe, modified_date: get_modified_time(&metadata, warnings, current_folder, true), }); } } czkawka_core-11.0.1/src/tools/empty_folder/mod.rs000064400000000000000000000024351046102023000201200ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::path::{Path, PathBuf}; use std::time::Duration; use indexmap::IndexMap; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; #[derive(Clone, Debug)] pub struct FolderEntry { pub path: PathBuf, pub(crate) parent_path: Option, // Usable only when finding pub(crate) is_empty: FolderEmptiness, pub modified_date: u64, } impl ResultEntry for FolderEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { 0 } } pub struct EmptyFolder { common_data: CommonToolData, information: Info, empty_folder_list: IndexMap, // Path, FolderEntry } /// Enum with values which show if folder is empty. /// In function "`optimize_folders`" automatically "Maybe" is changed to "Yes", so it is not necessary to put it here #[derive(Eq, PartialEq, Copy, Clone, Debug)] pub(crate) enum FolderEmptiness { No, Maybe, } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_empty_folders: usize, pub scanning_time: Duration, } impl Default for EmptyFolder { fn default() -> Self { Self::new() } } czkawka_core-11.0.1/src/tools/empty_folder/tests.rs000064400000000000000000000062101046102023000204760ustar 00000000000000use std::fs; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::empty_folder::EmptyFolder; #[test] fn test_find_empty_folders() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create empty directories fs::create_dir(path.join("empty1")).unwrap(); fs::create_dir(path.join("empty2")).unwrap(); // Create non-empty directory let non_empty = path.join("non_empty"); fs::create_dir(&non_empty).unwrap(); fs::write(non_empty.join("file.txt"), b"content").unwrap(); let mut finder = EmptyFolder::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_empty_folders, 2, "Should find 2 empty folders"); } #[test] fn test_nested_empty_folders() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create nested empty directories let parent = path.join("parent"); let child = parent.join("child"); fs::create_dir(&parent).unwrap(); fs::create_dir(&child).unwrap(); let mut finder = EmptyFolder::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); // After optimization, only the deepest empty folder should be counted let info = finder.get_information(); assert!(info.number_of_empty_folders > 0, "Should find empty folders"); } #[test] fn test_no_empty_folders() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create directory with file let dir = path.join("dir"); fs::create_dir(&dir).unwrap(); fs::write(dir.join("file.txt"), b"content").unwrap(); let mut finder = EmptyFolder::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_empty_folders, 0, "Should find no empty folders"); } #[test] fn test_folder_with_only_empty_subfolders() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); // Create parent with only empty subdirectories let parent = path.join("parent"); fs::create_dir(&parent).unwrap(); fs::create_dir(parent.join("empty_child1")).unwrap(); fs::create_dir(parent.join("empty_child2")).unwrap(); let mut finder = EmptyFolder::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); // Parent and children are all empty assert!( info.number_of_empty_folders == 1, "Should find 1 empty folder (the parent) - which contains only empty subfolders" ); } czkawka_core-11.0.1/src/tools/empty_folder/traits.rs000064400000000000000000000077241046102023000206550ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use rayon::prelude::*; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::empty_folder::{EmptyFolder, Info}; impl AllTraits for EmptyFolder {} impl Search for EmptyFolder { #[fun_time(message = "find_empty_folders", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.check_for_empty_folders(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } self.optimize_folders(); if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for EmptyFolder { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); println!("Number of empty folders - {}", self.information.number_of_empty_folders); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for EmptyFolder { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.empty_folder_list.is_empty() { writeln!(writer, "--------------------------Empty folder list--------------------------")?; writeln!(writer, "Found {} empty folders", self.information.number_of_empty_folders)?; let mut empty_folder_list = self.empty_folder_list.keys().collect::>(); empty_folder_list.par_sort_unstable(); for name in empty_folder_list { writeln!(writer, "{name}")?; } } else { write!(writer, "Not found any empty folders.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.empty_folder_list.keys().collect::>(), pretty_print) } } impl CommonData for EmptyFolder { type Info = Info; type Parameters = (); fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_empty_folders > 0 } } impl DeletingItems for EmptyFolder { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages( stop_flag, progress_sender, DeleteItemType::DeletingFolders(self.empty_folder_list.values().cloned().collect::>()), ), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } czkawka_core-11.0.1/src/tools/exif_remover/core.rs000064400000000000000000000350121046102023000202670ustar 00000000000000use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::{fs, mem, panic}; use crossbeam_channel::Sender; use fun_time::fun_time; use little_exif::filetype::FileExtension; use little_exif::ifd::ExifTagGroup; use little_exif::metadata::Metadata; use log::{debug, error}; use rayon::prelude::*; use crate::common::cache::{CACHE_VERSION, load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::flc; use crate::tools::exif_remover::{ExifEntry, ExifRemover, ExifRemoverParameters, ExifTagInfo, ExifTagsFixerParams, Info}; impl ExifRemover { pub fn new(params: ExifRemoverParameters) -> Self { let mut additional_excluded_tags = BTreeMap::new(); let tiff_disabled_tags = vec![ "ImageWidth", "ImageHeight", "BitsPerSample", "Compression", "PhotometricInterpretation", "StripOffsets", "SamplesPerPixel", "RowsPerStrip", "StripByteCounts", "PlanarConfiguration", ]; for i in ["tif", "tiff"] { additional_excluded_tags.insert(i, tiff_disabled_tags.clone()); } Self { common_data: CommonToolData::new(ToolType::ExifRemover), information: Info::default(), exif_files: Vec::new(), files_to_check: Default::default(), params, additional_excluded_tags, } } #[fun_time(message = "find_exif_files", level = "debug")] pub(crate) fn find_exif_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.files_to_check = grouped_file_entries .into_values() .flatten() .map(|fe| { let exif_entry = ExifEntry { path: fe.path.clone(), size: fe.size, modified_date: fe.modified_date, exif_tags: Vec::new(), error: None, }; (fe.path.to_string_lossy().to_string(), exif_entry) }) .collect(); self.common_data.text_messages.warnings.extend(warnings); debug!("find_exif_files - Found {} files to check.", self.files_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "load_cache", level = "debug")] fn load_cache( &mut self, _stop_flag: &Arc, progress_sender: Option<&Sender>, ) -> (BTreeMap, BTreeMap, BTreeMap) { let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::ExifRemoverCacheLoading, 0, self.get_test_type(), 0); let res = load_and_split_cache_generalized_by_path(&get_exif_remover_cache_file(), mem::take(&mut self.files_to_check), self); progress_handler.join_thread(); res } #[fun_time(message = "save_to_cache", level = "debug")] fn save_to_cache( &mut self, vec_file_entry: &[ExifEntry], loaded_hash_map: BTreeMap, _stop_flag: &Arc, progress_sender: Option<&Sender>, ) { let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::ExifRemoverCacheSaving, 0, self.get_test_type(), 0); save_and_connect_cache_generalized_by_path(&get_exif_remover_cache_file(), vec_file_entry, loaded_hash_map, self); progress_handler.join_thread(); } #[fun_time(message = "check_exif_in_files", level = "debug")] pub(crate) fn check_exif_in_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.files_to_check.is_empty() { return WorkContinueStatus::Continue; } let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_cache(stop_flag, progress_sender); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::ExifRemoverExtractingTags, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|item| item.size).sum::(), ); let non_cached_files_to_check = non_cached_files_to_check.into_iter().collect::>(); debug!("check_exif_in_files - started extracting EXIF data"); let mut vec_file_entry: Vec = non_cached_files_to_check .into_par_iter() .map(|(_, mut file_entry)| { if check_if_stop_received(stop_flag) { return None; } let size = file_entry.size; let res = extract_exif_tags(&file_entry.path); progress_handler.increase_items(1); progress_handler.increase_size(size); match res { Ok(tags) => { file_entry.exif_tags = tags.into_iter().map(|(name, code, group)| ExifTagInfo { name, code, group }).collect(); } Err(e) => { file_entry.error = Some(format!("Failed to extract Exif data for file \"{}\": {}", file_entry.path.to_string_lossy(), e)); } } Some(file_entry) }) .while_some() .collect(); debug!("check_exif_in_files - finished extracting EXIF data"); progress_handler.join_thread(); vec_file_entry.extend(records_already_cached.into_values()); self.save_to_cache(&vec_file_entry, loaded_hash_map, stop_flag, progress_sender); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } // After saving to cache, remove ignored tags - because in cache we need to store full info about tags for entry in &mut vec_file_entry { let extension = entry.path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase(); if let Some(additional_ignored_tags) = self.additional_excluded_tags.get(&extension.as_str()) { entry.exif_tags.retain(|tag_item| !additional_ignored_tags.contains(&tag_item.name.as_str())); } if self.params.ignored_tags.is_empty() { continue; } entry.exif_tags.retain(|tag_item| !self.params.ignored_tags.contains(&tag_item.name)); } self.exif_files = vec_file_entry.into_iter().filter(|f| f.error.is_none() && !f.exif_tags.is_empty()).collect(); self.exif_files.iter_mut().for_each(|file| file.exif_tags.sort_unstable_by(|a, b| a.name.cmp(&b.name))); self.information.number_of_files_with_exif = self.exif_files.len(); debug!("Found {} files with EXIF data.", self.information.number_of_files_with_exif); self.files_to_check = Default::default(); WorkContinueStatus::Continue } #[fun_time(message = "fix_files", level = "debug")] pub(crate) fn fix_files(&mut self, stop_flag: &Arc, _progress_sender: Option<&Sender>, fix_params: ExifTagsFixerParams) { let warnings: Vec<_> = mem::take(&mut self.exif_files) .into_par_iter() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } let exif_data_to_remove: Vec<(u16, String)> = entry.exif_tags.iter().map(|item_tag| (item_tag.code, item_tag.group.clone())).collect(); match clean_exif_tags(&entry.path.to_string_lossy(), &exif_data_to_remove, fix_params.override_file) { Ok(_number_removed_tags) => Some(None), Err(e) => Some(Some(format!("Failed to clean EXIF tags for file \"{}\": {}", entry.path.to_string_lossy(), e))), } }) .while_some() .flatten() .collect(); self.common_data.text_messages.warnings.extend(warnings); } } pub fn clean_exif_tags(file_path: &str, tags_to_remove: &[(u16, String)], override_file: bool) -> Result { panic::catch_unwind(|| { let file_path = Path::new(file_path); let mut file_data = fs::read(file_path).map_err(|e| e.to_string())?; let mut cursor = std::io::Cursor::new(&file_data); let ext = FileExtension::auto_detect(&mut cursor).ok_or_else(|| "Failed to detect file type".to_string())?; let metadata = Metadata::new_from_vec(&file_data, ext).map_err(|e| format!("Failed to read EXIF: {e}"))?; let mut new_metadata = metadata; let mut tags_removed: u32 = 0; for (tag_u16, tag_group) in tags_to_remove { let Ok(tag_group) = string_to_exif_tag_group(tag_group) else { error!("Unknown EXIF tag group string: {tag_group}, skipping tag removal."); continue; }; new_metadata.remove_tag_by_hex_group(*tag_u16, tag_group); tags_removed += 1; } new_metadata.write_to_vec(&mut file_data, ext).map_err(|e| e.to_string())?; if override_file { fs::write(file_path, file_data).map_err(|e| e.to_string())?; } else { let extension = file_path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); let new_file_path = file_path.with_extension(format!("czkawka_cleaned_exif.{extension}")); fs::write(new_file_path, file_data).map_err(|e| e.to_string())?; } Ok(tags_removed) }) .map_err(|e| format!("Panic occurred while reading EXIF: {e:?}"))? .map_err(|e: String| format!("Failed to remove EXIF from file {file_path} - {e}")) } fn extract_exif_tags(path: &Path) -> Result, String> { panic::catch_unwind(|| { let file_path = Path::new(path); let data = fs::read(file_path).map_err(|e| e.to_string())?; let mut cursor = std::io::Cursor::new(&data); let ext = FileExtension::auto_detect(&mut cursor).ok_or_else(|| "Failed to detect file type".to_string())?; let metadata = Metadata::new_from_vec(&data, ext).map_err(|e| format!("Failed to read EXIF: {e}"))?; let mut tags = Vec::new(); for tag in &metadata { let tag_name = format!("{tag:?}"); let tag_u16 = tag.as_u16(); let tag_group = exif_tag_group_to_string(tag.get_group()); if let Some(pos) = tag_name.find('(') { #[expect(clippy::string_slice)] // Safe, because pos is from find tags.push((tag_name[..pos].to_string(), tag_u16, tag_group)); } else { tags.push((tag_name, tag_u16, tag_group)); } } Ok(tags) }) .map_err(|e| format!("Panic occurred while reading \"{}\" - EXIF: {e:?}", path.to_string_lossy()))? } pub fn file_extension_to_string(extension: FileExtension) -> &'static str { match extension { FileExtension::PNG { .. } => "PNG", FileExtension::JPEG => "JPEG", FileExtension::TIFF => "TIFF", FileExtension::WEBP => "WEBP", FileExtension::NAKED_JXL => "NAKED_JXL", FileExtension::JXL => "JXL", FileExtension::HEIF => "HEIF", } } pub fn string_to_file_extension(s: &str) -> FileExtension { match s { "PNG" => FileExtension::PNG { as_zTXt_chunk: true }, "JPEG" => FileExtension::JPEG, "TIFF" => FileExtension::TIFF, "WEBP" => FileExtension::WEBP, "NAKED_JXL" => FileExtension::NAKED_JXL, "JXL" => FileExtension::JXL, "HEIF" => FileExtension::HEIF, _ => { error!("Unknown file extension string: {s}, defaulting to JPEG"); FileExtension::JPEG } // Default to JPEG } } // Nom-exif implementation // Probably will use this version in future // fn extract_exif_tags2(path: &Path) -> Result, String> { // let res = panic::catch_unwind(|| { // let mut parser = nom_exif::MediaParser::new(); // let ms = nom_exif::MediaSource::file_path(path).map_err(|e| format!("Failed to open file: {e}"))?; // let mut results = Vec::new(); // if !ms.has_exif() { // return Ok(results); // } // let exif_iter: nom_exif::ExifIter = parser.parse(ms).map_err(|e| format!("Failed to parse EXIF data: {e}"))?; // for exif_entry in exif_iter { // results.push(exif_entry.tag().map_or_else(|| "Unknown".to_string(), |t| format!("{t:?}"))); // } // // Ok(results) // }); // // res.unwrap_or_else(|_| { // let message = crate::common::create_crash_message("nom-exif", path.to_string_lossy().as_ref(), "https://github.com/mindeng/nom-exif"); // error!("{message}"); // Err("Panic in get_rotation_from_exif".to_string()) // }) // } pub fn string_to_exif_tag_group(tag: &str) -> Result { match tag { "EXIF" => Ok(ExifTagGroup::EXIF), "INTEROP" => Ok(ExifTagGroup::INTEROP), "GPS" => Ok(ExifTagGroup::GPS), "GENERIC" => Ok(ExifTagGroup::GENERIC), _ => Err(flc!("core_unknown_exif_tag_group", tag = tag)), } } pub fn exif_tag_group_to_string(tag_group: ExifTagGroup) -> String { match tag_group { ExifTagGroup::EXIF => "EXIF".to_string(), ExifTagGroup::INTEROP => "INTEROP".to_string(), ExifTagGroup::GPS => "GPS".to_string(), ExifTagGroup::GENERIC => "GENERIC".to_string(), } } pub fn get_exif_remover_cache_file() -> String { format!("cache_exif_remover_{CACHE_VERSION}.bin") } czkawka_core-11.0.1/src/tools/exif_remover/mod.rs000064400000000000000000000035221046102023000201170ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::collections::BTreeMap; use std::path::PathBuf; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; #[derive(Debug, Default, Clone, Copy)] pub struct Info { pub number_of_files_with_exif: usize, pub scanning_time: Duration, } #[derive(Clone, Default)] pub struct ExifRemoverParameters { pub ignored_tags: Vec, } impl ExifRemoverParameters { pub fn new(ignored_tags: Vec) -> Self { Self { ignored_tags } } } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ExifEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, pub exif_tags: Vec, pub error: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ExifTagInfo { pub name: String, pub code: u16, pub group: String, } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ExifTagsFixerParams { pub override_file: bool, } impl ResultEntry for ExifEntry { fn get_path(&self) -> &std::path::Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } pub struct ExifRemover { common_data: CommonToolData, information: Info, exif_files: Vec, files_to_check: BTreeMap, params: ExifRemoverParameters, additional_excluded_tags: BTreeMap<&'static str, Vec<&'static str>>, } impl ExifRemover { pub const fn get_exif_files(&self) -> &Vec { &self.exif_files } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/exif_remover/tests.rs000064400000000000000000000043231046102023000205020ustar 00000000000000use std::fs; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::exif_remover::{ExifRemover, ExifRemoverParameters}; fn get_test_resources_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_resources").join("images") } #[test] fn test_find_exif_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let source_image = get_test_resources_path().join("normal.jpg"); let dest_image = path.join("test.jpg"); fs::copy(&source_image, &dest_image).unwrap(); let mut finder = ExifRemover::new(ExifRemoverParameters::default()); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_files_with_exif, 1, "Should find at least one file with EXIF data"); let exif_files = finder.get_exif_files(); assert_eq!(exif_files.len(), 1, "Should find exactly one file with EXIF"); assert!(!exif_files[0].exif_tags.is_empty(), "EXIF tags should not be empty"); } #[test] fn test_empty_directory() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let mut finder = ExifRemover::new(ExifRemoverParameters::default()); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let exif_files = finder.get_exif_files(); assert_eq!(exif_files.len(), 0, "Should find no files with EXIF in empty directory"); } #[test] fn test_non_image_files() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); fs::write(path.join("test.txt"), b"This is not an image").unwrap(); let mut finder = ExifRemover::new(ExifRemoverParameters::default()); finder.set_included_paths(vec![path.to_path_buf()]); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let exif_files = finder.get_exif_files(); assert_eq!(exif_files.len(), 0, "Should not find EXIF in non-image files"); } czkawka_core-11.0.1/src/tools/exif_remover/traits.rs000064400000000000000000000114401046102023000206440ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::BINARY; use crate::common::consts::EXIF_FILES_EXTENSIONS; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, FixingItems, PrintResults, Search}; use crate::tools::exif_remover::{ExifEntry, ExifRemover, ExifRemoverParameters, ExifTagsFixerParams, Info}; impl AllTraits for ExifRemover {} impl DeletingItems for ExifRemover { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => { let files_to_delete: Vec = self.exif_files.clone(); self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(files_to_delete)) } DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } impl FixingItems for ExifRemover { type FixParams = ExifTagsFixerParams; #[fun_time(message = "fix_items", level = "debug")] fn fix_items(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, fix_params: Self::FixParams) { self.fix_files(stop_flag, progress_sender, fix_params); } } impl DebugPrint for ExifRemover { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("### INDIVIDUAL DEBUG PRINT ###"); println!("Info: {:?}", self.information); println!("Number of files with EXIF: {}", self.information.number_of_files_with_exif); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for ExifRemover { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if self.information.number_of_files_with_exif != 0 { writeln!(writer, "Found {} files with EXIF data.\n", self.information.number_of_files_with_exif)?; for exif_entry in &self.exif_files { writeln!( writer, "\nFile: \"{}\" - {} - {} - {:?}", exif_entry.path.to_string_lossy(), humansize::format_size(exif_entry.size, BINARY), exif_entry.modified_date, exif_entry.exif_tags.iter().map(|item_tag| item_tag.name.clone()).collect::>() )?; } } else { writeln!(writer, "Not found any files with EXIF data.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.exif_files, pretty_print) } } impl Search for ExifRemover { #[fun_time(message = "find_exif_data", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(Some(EXIF_FILES_EXTENSIONS)).is_err() { return; } if self.find_exif_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.check_exif_in_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl CommonData for ExifRemover { type Info = Info; type Parameters = ExifRemoverParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_files_with_exif > 0 } } czkawka_core-11.0.1/src/tools/invalid_symlinks/core.rs000064400000000000000000000070651046102023000211630ustar 00000000000000use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use log::debug; use crate::common::dir_traversal::{Collect, DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::ProgressData; use crate::common::tool_data::CommonToolData; use crate::tools::invalid_symlinks::{ErrorType, Info, InvalidSymlinks, MAX_NUMBER_OF_SYMLINK_JUMPS, SymlinkInfo}; impl InvalidSymlinks { pub fn new() -> Self { Self { common_data: CommonToolData::new(ToolType::InvalidSymlinks), information: Info::default(), invalid_symlinks: Vec::new(), } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .common_data(&self.common_data) .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .collect(Collect::InvalidSymlinks) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.invalid_symlinks = grouped_file_entries .into_values() .flatten() .filter_map(|e| { let (destination_path, type_of_error) = Self::check_invalid_symlinks(&e.path)?; Some(e.into_symlinks_entry(SymlinkInfo { destination_path, type_of_error })) }) .collect(); self.information.number_of_invalid_symlinks = self.invalid_symlinks.len(); self.common_data.text_messages.warnings.extend(warnings); debug!("Found {} invalid symlinks.", self.information.number_of_invalid_symlinks); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn check_invalid_symlinks(current_file_name: &Path) -> Option<(PathBuf, ErrorType)> { let mut destination_path = PathBuf::new(); let type_of_error; match current_file_name.read_link() { Ok(t) => { destination_path.push(t); let mut loop_count = 0; let mut current_path = current_file_name.to_path_buf(); loop { if loop_count == 0 && !current_path.exists() { type_of_error = ErrorType::NonExistentFile; break; } if loop_count == MAX_NUMBER_OF_SYMLINK_JUMPS { type_of_error = ErrorType::InfiniteRecursion; break; } current_path = match current_path.read_link() { Ok(t) => t, Err(_inspected) => { // Looks that some next symlinks are broken, but we do nothing with it - TODO why they are broken return None; } }; loop_count += 1; } } Err(_inspected) => { // Failed to load info about it type_of_error = ErrorType::NonExistentFile; } } Some((destination_path, type_of_error)) } } czkawka_core-11.0.1/src/tools/invalid_symlinks/mod.rs000064400000000000000000000047061046102023000210110ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; use crate::flc; #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_invalid_symlinks: usize, pub scanning_time: Duration, } const MAX_NUMBER_OF_SYMLINK_JUMPS: i32 = 20; #[derive(Clone, Debug, PartialEq, Eq, Copy, Deserialize, Serialize)] pub enum ErrorType { InfiniteRecursion, NonExistentFile, } impl ErrorType { pub fn translate(self) -> String { match self { Self::InfiniteRecursion => flc!("core_invalid_symlink_infinite_recursion"), Self::NonExistentFile => flc!("core_invalid_symlink_non_existent_destination"), } } } impl Display for ErrorType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InfiniteRecursion => write!(f, "Infinite recursion"), Self::NonExistentFile => write!(f, "Non existent file"), } } } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct SymlinkInfo { pub destination_path: PathBuf, pub type_of_error: ErrorType, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SymlinksFileEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, pub symlink_info: SymlinkInfo, } impl ResultEntry for SymlinksFileEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_symlinks_entry(self, symlink_info: SymlinkInfo) -> SymlinksFileEntry { SymlinksFileEntry { size: self.size, path: self.path, modified_date: self.modified_date, symlink_info, } } } pub struct InvalidSymlinks { common_data: CommonToolData, information: Info, invalid_symlinks: Vec, } impl Default for InvalidSymlinks { fn default() -> Self { Self::new() } } impl InvalidSymlinks { pub const fn get_invalid_symlinks(&self) -> &Vec { &self.invalid_symlinks } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/invalid_symlinks/tests.rs000064400000000000000000000053051046102023000213700ustar 00000000000000#[cfg(target_family = "unix")] use std::fs; #[cfg(target_family = "unix")] use std::sync::Arc; #[cfg(target_family = "unix")] use std::sync::atomic::AtomicBool; #[cfg(target_family = "unix")] use tempfile::TempDir; #[cfg(target_family = "unix")] use crate::common::tool_data::CommonData; #[cfg(target_family = "unix")] use crate::common::traits::Search; #[cfg(target_family = "unix")] use crate::tools::invalid_symlinks::InvalidSymlinks; #[test] #[cfg(target_family = "unix")] fn test_find_invalid_symlinks() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let valid_target = path.join("valid_target.txt"); fs::write(&valid_target, b"content").unwrap(); let valid_link = path.join("valid_link"); std::os::unix::fs::symlink(&valid_target, &valid_link).unwrap(); let invalid_link = path.join("invalid_link"); std::os::unix::fs::symlink(path.join("non_existent.txt"), &invalid_link).unwrap(); let mut finder = InvalidSymlinks::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_invalid_symlinks, 1, "Should find 1 invalid symlink"); } #[test] #[cfg(target_family = "unix")] fn test_no_invalid_symlinks() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let target = path.join("target.txt"); fs::write(&target, b"content").unwrap(); let link = path.join("link"); std::os::unix::fs::symlink(&target, &link).unwrap(); let mut finder = InvalidSymlinks::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_invalid_symlinks, 0, "Should find no invalid symlinks"); } #[test] #[cfg(target_family = "unix")] fn test_deleted_target_creates_invalid_symlink() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let target = path.join("target.txt"); fs::write(&target, b"content").unwrap(); let link = path.join("link"); std::os::unix::fs::symlink(&target, &link).unwrap(); fs::remove_file(&target).unwrap(); let mut finder = InvalidSymlinks::new(); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_invalid_symlinks, 1, "Should find the broken symlink"); } czkawka_core-11.0.1/src/tools/invalid_symlinks/traits.rs000064400000000000000000000100271046102023000215310ustar 00000000000000use std::io::prelude::*; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::invalid_symlinks::{ErrorType, Info, InvalidSymlinks}; impl AllTraits for InvalidSymlinks {} impl Search for InvalidSymlinks { #[fun_time(message = "find_invalid_links", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for InvalidSymlinks { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); println!("Invalid symlinks list size - {}", self.invalid_symlinks.len()); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for InvalidSymlinks { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.invalid_symlinks.is_empty() { writeln!(writer, "Found {} invalid symlinks.", self.information.number_of_invalid_symlinks)?; for file_entry in &self.invalid_symlinks { writeln!( writer, "\"{}\"\t\t\"{}\"\t\t{}", file_entry.path.to_string_lossy(), file_entry.symlink_info.destination_path.to_string_lossy(), match file_entry.symlink_info.type_of_error { ErrorType::InfiniteRecursion => "Infinite Recursion", ErrorType::NonExistentFile => "Non Existent File", } )?; } } else { write!(writer, "Not found any invalid symlinks.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.invalid_symlinks, pretty_print) } } impl CommonData for InvalidSymlinks { type Info = Info; type Parameters = (); fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_invalid_symlinks > 0 } } impl DeletingItems for InvalidSymlinks { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.common_data.delete_method { DeleteMethod::Delete => self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(self.invalid_symlinks.clone())), DeleteMethod::None => WorkContinueStatus::Continue, _ => unreachable!(), } } } czkawka_core-11.0.1/src/tools/mod.rs000064400000000000000000000004611046102023000154240ustar 00000000000000pub mod bad_extensions; pub mod bad_names; pub mod big_file; pub mod broken_files; pub mod duplicate; pub mod empty_files; pub mod empty_folder; pub mod exif_remover; pub mod invalid_symlinks; pub mod same_music; pub mod similar_images; pub mod similar_videos; pub mod temporary; pub mod video_optimizer; czkawka_core-11.0.1/src/tools/same_music/core.rs000064400000000000000000000774171046102023000177410ustar 00000000000000use std::collections::BTreeMap; use std::fs::File; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::{mem, panic}; use crossbeam_channel::Sender; use fun_time::fun_time; use indexmap::IndexSet; use lofty::file::{AudioFile, TaggedFileExt}; use lofty::prelude::*; use lofty::read_from; use log::{debug, error}; use rayon::prelude::*; use rusty_chromaprint::{Configuration, Fingerprinter, match_fingerprints}; use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions}; use symphonia::core::formats::FormatOptions; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use crate::common::cache::{CACHE_VERSION, load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::create_crash_message; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::traits::ResultEntry; use crate::flc; use crate::tools::same_music::{GroupedFilesToCheck, Info, MusicEntry, MusicSimilarity, SameMusic, SameMusicParameters}; impl SameMusic { pub fn new(params: SameMusicParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::SameMusic), information: Info::default(), music_entries: Vec::with_capacity(2048), duplicated_music_entries: Vec::new(), music_to_check: Default::default(), duplicated_music_entries_referenced: Vec::new(), hash_preset_config: Configuration::preset_test1(), // TODO allow to change this and move to parameters params, } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .checking_method(self.params.check_type) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.music_to_check = grouped_file_entries .into_values() .flatten() .map(|fe| (fe.path.to_string_lossy().to_string(), fe.into_music_entry())) .collect(); self.common_data.text_messages.warnings.extend(warnings); debug!("check_files - Found {} music files.", self.music_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "load_cache", level = "debug")] fn load_cache(&mut self, checking_tags: bool) -> (BTreeMap, BTreeMap, BTreeMap) { load_and_split_cache_generalized_by_path(&get_similar_music_cache_file(checking_tags), mem::take(&mut self.music_to_check), self) } #[fun_time(message = "save_cache", level = "debug")] fn save_cache(&mut self, vec_file_entry: &[MusicEntry], loaded_hash_map: BTreeMap, checking_tags: bool) { save_and_connect_cache_generalized_by_path(&get_similar_music_cache_file(checking_tags), vec_file_entry, loaded_hash_map, self); } #[fun_time(message = "calculate_fingerprint", level = "debug")] pub(crate) fn calculate_fingerprint(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.music_entries.is_empty() { return WorkContinueStatus::Continue; } // We only calculate fingerprints, for files with similar titles // This saves a lot of time, because we don't need to calculate and later compare fingerprints for files with different titles if self.params.compare_fingerprints_only_with_similar_titles { let grouped_by_title: BTreeMap> = Self::get_entries_grouped_by_title(mem::take(&mut self.music_entries)); self.music_to_check = grouped_by_title .into_iter() .filter_map(|(_title, entries)| if entries.len() >= 2 { Some(entries) } else { None }) .flatten() .map(|e| (e.path.to_string_lossy().to_string(), e)) .collect(); } else { self.music_to_check = mem::take(&mut self.music_entries).into_iter().map(|e| (e.path.to_string_lossy().to_string(), e)).collect(); } let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicCacheLoadingFingerprints, 0, self.get_test_type(), 0); let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_cache(false); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::SameMusicCalculatingFingerprints, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|e| e.size).sum::(), ); let configuration = &self.hash_preset_config; let non_cached_files_to_check = non_cached_files_to_check.into_iter().collect::>(); debug!("calculate_fingerprint - starting fingerprinting"); let mut vec_file_entry = non_cached_files_to_check .into_par_iter() .with_max_len(2) .map(|(path, mut music_entry)| { if check_if_stop_received(stop_flag) { return None; } let res = calc_fingerprint_helper(path, configuration); progress_handler.increase_size(music_entry.size); progress_handler.increase_items(1); let Ok(fingerprint) = res else { return Some(None); }; music_entry.fingerprint = fingerprint; Some(Some(music_entry)) }) .while_some() .flatten() .collect::>(); debug!("calculate_fingerprint - ended fingerprinting"); progress_handler.join_thread(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicCacheSavingFingerprints, 0, self.get_test_type(), 0); vec_file_entry.extend(records_already_cached.into_values()); self.save_cache(&vec_file_entry, loaded_hash_map, false); self.music_entries = vec_file_entry; progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "read_tags", level = "debug")] pub(crate) fn read_tags(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.music_to_check.is_empty() { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicCacheLoadingTags, 0, self.get_test_type(), 0); let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_cache(true); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::SameMusicReadingTags, non_cached_files_to_check.len(), self.get_test_type(), 0, ); debug!("read_tags - starting reading tags"); // Clean for duplicate files let mut vec_file_entry = non_cached_files_to_check .into_par_iter() .map(|(path, music_entry)| { if check_if_stop_received(stop_flag) { return None; } let res = read_single_file_tags(&path, music_entry); progress_handler.increase_items(1); Some(res) }) .while_some() .flatten() .collect::>(); debug!("read_tags - ended reading tags"); progress_handler.join_thread(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicCacheSavingTags, 0, self.get_test_type(), 0); vec_file_entry.extend(records_already_cached.into_values()); self.save_cache(&vec_file_entry, loaded_hash_map, true); self.music_entries = vec_file_entry; progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "check_for_duplicate_tags", level = "debug")] pub(crate) fn check_for_duplicate_tags(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.music_entries.is_empty() { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicComparingTags, self.music_entries.len(), self.get_test_type(), 0); let mut old_duplicates: Vec> = vec![self.music_entries.clone()]; let mut new_duplicates: Vec> = Vec::new(); if (self.params.music_similarity & MusicSimilarity::TRACK_TITLE) == MusicSimilarity::TRACK_TITLE { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } old_duplicates = self.check_music_item( old_duplicates, progress_handler.items_counter(), |fe| fe.track_title.clone(), self.params.approximate_comparison, ); } if (self.params.music_similarity & MusicSimilarity::TRACK_ARTIST) == MusicSimilarity::TRACK_ARTIST { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } old_duplicates = self.check_music_item( old_duplicates, progress_handler.items_counter(), |fe| fe.track_artist.clone(), self.params.approximate_comparison, ); } if (self.params.music_similarity & MusicSimilarity::YEAR) == MusicSimilarity::YEAR { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } old_duplicates = self.check_music_item(old_duplicates, progress_handler.items_counter(), |fe| fe.year.clone(), false); } if (self.params.music_similarity & MusicSimilarity::LENGTH) == MusicSimilarity::LENGTH { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } old_duplicates = self.check_music_item(old_duplicates, progress_handler.items_counter(), |fe| format_audio_duration(fe.length), false); } if (self.params.music_similarity & MusicSimilarity::GENRE) == MusicSimilarity::GENRE { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } old_duplicates = self.check_music_item(old_duplicates, progress_handler.items_counter(), |fe| fe.genre.clone(), false); } if (self.params.music_similarity & MusicSimilarity::BITRATE) == MusicSimilarity::BITRATE { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } let old_duplicates_len = old_duplicates.len(); for vec_file_entry in old_duplicates { let mut hash_map: BTreeMap> = Default::default(); for file_entry in vec_file_entry { if file_entry.bitrate != 0 { let thing = file_entry.bitrate.to_string(); hash_map.entry(thing).or_default().push(file_entry); } } for (_title, vec_file_entry) in hash_map { if vec_file_entry.len() > 1 { new_duplicates.push(vec_file_entry); } } } progress_handler.increase_items(old_duplicates_len); old_duplicates = new_duplicates; } progress_handler.join_thread(); self.duplicated_music_entries = old_duplicates; if self.common_data.use_reference_folders { self.duplicated_music_entries_referenced = self.common_data.directories.filter_reference_folders(mem::take(&mut self.duplicated_music_entries)); } if self.common_data.use_reference_folders { for (_fe, vector) in &self.duplicated_music_entries_referenced { self.information.number_of_duplicates += vector.len(); self.information.number_of_groups += 1; } } else { for vector in &self.duplicated_music_entries { self.information.number_of_duplicates += vector.len() - 1; self.information.number_of_groups += 1; } } // Clear unused data self.music_entries.clear(); WorkContinueStatus::Continue } fn split_fingerprints_to_base_and_files_to_compare(&self, music_data: Vec) -> (Vec, Vec) { if self.common_data.use_reference_folders { music_data.into_iter().partition(|f| self.common_data.directories.is_in_referenced_directory(f.get_path())) } else { (music_data.clone(), music_data) } } fn get_entries_grouped_by_title(music_data: Vec) -> BTreeMap> { let mut entries_grouped_by_title: BTreeMap> = BTreeMap::new(); for entry in music_data { let simplified_track_title = get_simplified_name(&entry.track_title); // TODO maybe add as option to check for empty titles? if simplified_track_title.is_empty() { continue; } entries_grouped_by_title.entry(simplified_track_title).or_default().push(entry); } entries_grouped_by_title } fn split_fingerprints_to_check(&mut self) -> Vec { if self.params.compare_fingerprints_only_with_similar_titles { let entries_grouped_by_title: BTreeMap> = Self::get_entries_grouped_by_title(mem::take(&mut self.music_entries)); entries_grouped_by_title .into_iter() .filter_map(|(_title, entries)| { let (base_files, files_to_compare) = self.split_fingerprints_to_base_and_files_to_compare(entries); // When there is 0 files in base files or files to compare there will be no comparison, so removing it from the list // Also when there is only one file in base files and files to compare and they are the same file, there will be no comparison #[expect(clippy::indexing_slicing)] // Validated that base_files/files_to_compare are not empty if base_files.is_empty() || files_to_compare.is_empty() || (base_files.len() == 1 && files_to_compare.len() == 1 && (base_files[0].path == files_to_compare[0].path)) { return None; } Some(GroupedFilesToCheck { base_files, files_to_compare }) }) .collect() } else { let entries = mem::take(&mut self.music_entries); let (base_files, files_to_compare) = self.split_fingerprints_to_base_and_files_to_compare(entries); vec![GroupedFilesToCheck { base_files, files_to_compare }] } } fn compare_fingerprints( &mut self, stop_flag: &Arc, items_counter: &Arc, base_files: Vec, files_to_compare: &[MusicEntry], ) -> Option>> { let mut used_paths: IndexSet = Default::default(); let configuration = &self.hash_preset_config; let minimum_segment_duration = self.params.minimum_segment_duration; let maximum_difference = self.params.maximum_difference; let mut duplicated_music_entries = Vec::new(); for f_entry in base_files { items_counter.fetch_add(1, Ordering::Relaxed); if check_if_stop_received(stop_flag) { return None; } let f_string = f_entry.path.to_string_lossy().to_string(); if used_paths.contains(&f_string) { continue; } let (mut collected_similar_items, errors): (Vec<_>, Vec<_>) = files_to_compare .par_iter() .map(|e_entry| { let e_string = e_entry.path.to_string_lossy().to_string(); if used_paths.contains(&e_string) || e_string == f_string { return None; } let mut segments = match match_fingerprints(&f_entry.fingerprint, &e_entry.fingerprint, configuration) { Ok(segments) => segments, Err(e) => return Some(Err(flc!("core_error_comparing_fingerprints", reason = e.to_string()))), }; segments.retain(|s| s.duration(configuration) > minimum_segment_duration && s.score < maximum_difference); if segments.is_empty() { None } else { Some(Ok((e_string, e_entry))) } }) .flatten() .partition_map(|res| match res { Ok(entry) => itertools::Either::Left(entry), Err(err) => itertools::Either::Right(err), }); self.common_data.text_messages.errors.extend(errors); collected_similar_items.retain(|(path, _entry)| !used_paths.contains(path)); if !collected_similar_items.is_empty() { let mut music_entries = Vec::new(); for (path, entry) in collected_similar_items { used_paths.insert(path); music_entries.push(entry.clone()); } used_paths.insert(f_string); music_entries.push(f_entry); duplicated_music_entries.push(music_entries); } } Some(duplicated_music_entries) } #[fun_time(message = "check_for_duplicate_fingerprints", level = "debug")] pub(crate) fn check_for_duplicate_fingerprints(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.music_entries.is_empty() { return WorkContinueStatus::Continue; } let grouped_files_to_check = self.split_fingerprints_to_check(); let base_files_number = grouped_files_to_check.iter().map(|g| g.base_files.len()).sum::(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SameMusicComparingFingerprints, base_files_number, self.get_test_type(), 0); let mut duplicated_music_entries = Vec::new(); for group in grouped_files_to_check { let GroupedFilesToCheck { base_files, files_to_compare } = group; let Some(temp_music_entries) = self.compare_fingerprints(stop_flag, progress_handler.items_counter(), base_files, &files_to_compare) else { progress_handler.join_thread(); return WorkContinueStatus::Stop; }; duplicated_music_entries.extend(temp_music_entries); } progress_handler.join_thread(); self.duplicated_music_entries = duplicated_music_entries; if self.common_data.use_reference_folders { self.duplicated_music_entries_referenced = self.common_data.directories.filter_reference_folders(mem::take(&mut self.duplicated_music_entries)); } if self.common_data.use_reference_folders { for (_fe, vector) in &self.duplicated_music_entries_referenced { self.information.number_of_duplicates += vector.len(); self.information.number_of_groups += 1; } } else { for vector in &self.duplicated_music_entries { self.information.number_of_duplicates += vector.len() - 1; self.information.number_of_groups += 1; } } // Clear unused data self.music_entries.clear(); WorkContinueStatus::Continue } #[fun_time(message = "check_music_item", level = "debug")] fn check_music_item( &self, old_duplicates: Vec>, items_counter: &Arc, get_item: fn(&MusicEntry) -> String, approximate_comparison: bool, ) -> Vec> { let mut new_duplicates: Vec<_> = Default::default(); let old_duplicates_len = old_duplicates.len(); for vec_file_entry in old_duplicates { let mut hash_map: BTreeMap> = Default::default(); for file_entry in vec_file_entry { let mut thing = get_item(&file_entry).trim().to_lowercase(); if approximate_comparison { thing = get_simplified_name(&thing); } if !thing.is_empty() { hash_map.entry(thing).or_default().push(file_entry); } } for (_title, vec_file_entry) in hash_map { if vec_file_entry.len() > 1 { new_duplicates.push(vec_file_entry); } } } items_counter.fetch_add(old_duplicates_len, Ordering::Relaxed); new_duplicates } } // TODO this should be taken from rusty-chromaprint repo, not reimplemented here fn calc_fingerprint_helper>(path: P, config: &Configuration) -> Result, String> { let path = path.as_ref().to_path_buf(); panic::catch_unwind(|| { let path = &path; let src = File::open(path).map_err(|_| "failed to open file".to_string())?; let mss = MediaSourceStream::new(Box::new(src), Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) { hint.with_extension(ext); } let meta_opts: MetadataOptions = Default::default(); let fmt_opts: FormatOptions = Default::default(); let probed = symphonia::default::get_probe() .format(&hint, mss, &fmt_opts, &meta_opts) .map_err(|_| "unsupported format".to_string())?; let mut format = probed.format; let track = format .tracks() .iter() .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) .ok_or_else(|| "no supported audio tracks".to_string())?; let dec_opts: DecoderOptions = Default::default(); let mut decoder = symphonia::default::get_codecs() .make(&track.codec_params, &dec_opts) .map_err(|_| "unsupported codec".to_string())?; let track_id = track.id; let mut printer = Fingerprinter::new(config); let sample_rate = track.codec_params.sample_rate.ok_or_else(|| "missing sample rate".to_string())?; let channels = track.codec_params.channels.ok_or_else(|| "missing audio channels".to_string())?.count() as u32; printer.start(sample_rate, channels).map_err(|_| "initializing fingerprinter".to_string())?; let mut sample_buf = None; loop { let Ok(packet) = format.next_packet() else { break; }; if packet.track_id() != track_id { continue; } match decoder.decode(&packet) { Ok(audio_buf) => { if sample_buf.is_none() { let spec = *audio_buf.spec(); let duration = audio_buf.capacity() as u64; sample_buf = Some(SampleBuffer::::new(duration, spec)); } if let Some(buf) = &mut sample_buf { buf.copy_interleaved_ref(audio_buf); printer.consume(buf.samples()); } } Err(symphonia::core::errors::Error::DecodeError(_)) => (), Err(_) => break, } } printer.finish(); Ok(printer.fingerprint().to_vec()) }) .unwrap_or_else(|_| { let message = create_crash_message("Symphonia", &path.to_string_lossy(), "https://github.com/pdeljanov/Symphonia"); error!("{message}"); Err(message) }) } fn read_single_file_tags(path: &str, mut music_entry: MusicEntry) -> Option { let Ok(mut file) = File::open(path) else { return None; }; let Ok(possible_tagged_file) = panic::catch_unwind(move || read_from(&mut file).ok()) else { let message = create_crash_message("Lofty", path, "https://github.com/Serial-ATA/lofty-rs"); error!("{message}"); return None; }; let Some(tagged_file) = possible_tagged_file else { return Some(music_entry) }; let properties = tagged_file.properties(); let mut track_title = String::new(); let mut track_artist = String::new(); let mut year = String::new(); let mut genre = String::new(); let bitrate = properties.audio_bitrate().unwrap_or(0); if let Some(tag) = tagged_file.primary_tag() { track_title = tag.get_string(ItemKey::TrackTitle).unwrap_or_default().to_string(); track_artist = tag.get_string(ItemKey::TrackArtist).unwrap_or_default().to_string(); year = tag.get_string(ItemKey::Year).unwrap_or_default().to_string(); genre = tag.get_string(ItemKey::Genre).unwrap_or_default().to_string(); } for tag in tagged_file.tags() { if track_title.is_empty() && let Some(tag_value) = tag.get_string(ItemKey::TrackTitle) { track_title = tag_value.to_string(); } if track_artist.is_empty() && let Some(tag_value) = tag.get_string(ItemKey::TrackArtist) { track_artist = tag_value.to_string(); } if year.is_empty() && let Some(tag_value) = tag.get_string(ItemKey::Year) { year = tag_value.to_string(); } if genre.is_empty() && let Some(tag_value) = tag.get_string(ItemKey::Genre) { genre = tag_value.to_string(); } } let length_milliseconds = properties.duration().as_millis(); let length_in_seconds = if length_milliseconds == 0 { 0 } else { let secs = properties.duration().as_secs() as u32; if secs == 0 { 1 } else { secs } }; music_entry.track_title = track_title; music_entry.track_artist = track_artist; music_entry.year = year; music_entry.length = length_in_seconds; music_entry.genre = genre; music_entry.bitrate = bitrate; Some(music_entry) } pub fn format_audio_duration(duration: u32) -> String { let hours = duration / 3600; let minutes = (duration % 3600) / 60; let seconds = duration % 60; if hours > 0 { format!("{hours}:{minutes:02}:{seconds:02}") } else { format!("{minutes}:{seconds:02}") } } fn get_simplified_name_internal(what: &str, ignore_numbers: bool) -> String { let mut new_what = String::with_capacity(what.len()); let mut tab_number = 0; let mut space_before = true; for character in what.chars().map(|e| if e.is_whitespace() { ' ' } else { e }) { match character { '(' | '[' => { tab_number += 1; } ')' | ']' => { if tab_number == 0 { // Nothing to do, not even save it to output } else { tab_number -= 1; } } ' ' => { if !space_before { new_what.push(' '); space_before = true; } } ch => { if tab_number == 0 { if ch.is_ascii_alphabetic() || (!ignore_numbers && ch.is_numeric()) { space_before = false; new_what.push(ch); } else { let new_items = deunicode::deunicode_char(character).map_or_else(|| vec![character; 1], |e| e.trim().to_string().chars().collect::>()); // If is equal, then we're trying to deunicode e.g. dot, comma etc. // We just ignore char, because it is mostly useless, but we add space instead it if it wasn't added already if new_items.first() == Some(&character) { if !space_before { new_what.push(' '); space_before = true; } } else { new_what.extend(new_items.into_iter()); space_before = false; } } } } } } if new_what.ends_with(' ') { new_what.pop(); } new_what } fn get_simplified_name(what: &str) -> String { let new_what = get_simplified_name_internal(what, true); if !new_what.is_empty() { return new_what; } let new_what = get_simplified_name_internal(what, false); if !new_what.is_empty() { return new_what; } let simplified_unicode = deunicode::deunicode(what).trim().to_string(); if !simplified_unicode.is_empty() { return simplified_unicode; } // If everything failed, we return original string // this is more useful than returning empty string, which is ignored by other functions what.trim().to_string() } pub fn get_similar_music_cache_file(checking_tags: bool) -> String { if checking_tags { format!("cache_same_music_tags_{CACHE_VERSION}.bin") } else { format!("cache_same_music_fingerprints_{CACHE_VERSION}.bin") } } #[cfg(test)] mod tests { use super::*; #[test] fn test_simplified_names() { let cases = [ ("roman ( ziemniak ) ", "roman"), (" HH) ", "HH"), (" fsf.f. ", "fsf f"), (" śśśśćććć ", "sssscccc"), ("rr\t", "rr"), ("Kekistan (feat. roman) [Mix on Mix]", "Kekistan"), ("23", "23"), ("23 (random)", "23"), ("(23)", "(23)"), ]; for (input, expected) in cases { let res = get_simplified_name(input); assert_eq!(res, expected, "Input: {input}, Expected: {expected}, Got: {res}"); } } } czkawka_core-11.0.1/src/tools/same_music/mod.rs000064400000000000000000000101021046102023000175420ustar 00000000000000use bitflags::bitflags; pub mod core; pub mod traits; #[cfg(test)] mod tests; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; use rusty_chromaprint::Configuration; use serde::{Deserialize, Serialize}; use crate::common::model::{CheckingMethod, FileEntry}; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; bitflags! { #[derive(PartialEq, Copy, Clone, Debug)] pub struct MusicSimilarity : u32 { const NONE = 0; const TRACK_TITLE = 0b1; const TRACK_ARTIST = 0b10; const YEAR = 0b100; const LENGTH = 0b1000; const GENRE = 0b10000; const BITRATE = 0b10_0000; } } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct MusicEntry { pub size: u64, pub path: PathBuf, pub modified_date: u64, pub fingerprint: Vec, pub track_title: String, pub track_artist: String, pub year: String, pub length: u32, pub genre: String, pub bitrate: u32, } impl ResultEntry for MusicEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_music_entry(self) -> MusicEntry { MusicEntry { size: self.size, path: self.path, modified_date: self.modified_date, fingerprint: Vec::new(), track_title: String::new(), track_artist: String::new(), year: String::new(), length: 0, genre: String::new(), bitrate: 0, } } } struct GroupedFilesToCheck { pub base_files: Vec, pub files_to_compare: Vec, } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_duplicates: usize, pub number_of_groups: usize, pub scanning_time: Duration, } #[derive(Clone)] pub struct SameMusicParameters { pub music_similarity: MusicSimilarity, pub approximate_comparison: bool, pub check_type: CheckingMethod, pub minimum_segment_duration: f32, pub maximum_difference: f64, pub compare_fingerprints_only_with_similar_titles: bool, } impl SameMusicParameters { pub fn new( music_similarity: MusicSimilarity, approximate_comparison: bool, check_type: CheckingMethod, minimum_segment_duration: f32, maximum_difference: f64, compare_fingerprints_only_with_similar_titles: bool, ) -> Self { assert!(!music_similarity.is_empty()); assert!([CheckingMethod::AudioTags, CheckingMethod::AudioContent].contains(&check_type)); Self { music_similarity, approximate_comparison, check_type, minimum_segment_duration, maximum_difference, compare_fingerprints_only_with_similar_titles, } } } pub struct SameMusic { common_data: CommonToolData, information: Info, music_to_check: BTreeMap, music_entries: Vec, duplicated_music_entries: Vec>, duplicated_music_entries_referenced: Vec<(MusicEntry, Vec)>, hash_preset_config: Configuration, params: SameMusicParameters, } impl SameMusic { pub const fn get_duplicated_music_entries(&self) -> &Vec> { &self.duplicated_music_entries } pub fn get_params(&self) -> &SameMusicParameters { &self.params } pub const fn get_information(&self) -> Info { self.information } pub fn get_similar_music_referenced(&self) -> &Vec<(MusicEntry, Vec)> { &self.duplicated_music_entries_referenced } pub fn get_number_of_base_duplicated_files(&self) -> usize { if self.common_data.use_reference_folders { self.duplicated_music_entries_referenced.len() } else { self.duplicated_music_entries.len() } } pub fn get_use_reference(&self) -> bool { self.common_data.use_reference_folders } } czkawka_core-11.0.1/src/tools/same_music/tests.rs000064400000000000000000000203501046102023000201330ustar 00000000000000use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crate::common::model::CheckingMethod; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::same_music::{MusicSimilarity, SameMusic, SameMusicParameters}; fn get_test_resources_path() -> PathBuf { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_resources").join("audio"); assert!(path.exists(), "Test resources not found at \"{}\"", path.to_string_lossy()); path } #[test] fn test_same_music_by_content_high_similarity() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::TRACK_TITLE, false, CheckingMethod::AudioContent, 10.0, 0.2, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 1); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates.iter().map(|e| e.len()).sum::(), 2); } #[test] fn test_same_music_by_content_medium_similarity() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::TRACK_TITLE, false, CheckingMethod::AudioContent, 10.0, 0.5, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 1); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates.iter().map(|e| e.len()).sum::(), 2); } #[test] fn test_same_music_by_content_low_similarity() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::TRACK_TITLE, false, CheckingMethod::AudioContent, 10.0, 0.8, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 3); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates.iter().map(|e| e.len()).sum::(), 4); } #[test] fn test_same_music_by_tags_title_artist() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new( MusicSimilarity::TRACK_TITLE | MusicSimilarity::TRACK_ARTIST, false, CheckingMethod::AudioTags, 10.0, 0.2, false, ); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 4); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates[0].len(), 5); } #[test] fn test_same_music_by_tags_year() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::YEAR, false, CheckingMethod::AudioTags, 10.0, 0.2, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 0); assert_eq!(info.number_of_groups, 0); assert_eq!(duplicates.len(), 0); } #[test] fn test_same_music_by_tags_genre() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::GENRE, false, CheckingMethod::AudioTags, 10.0, 0.2, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 4); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates[0].len(), 5); } #[test] fn test_same_music_by_tags_bitrate() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new(MusicSimilarity::BITRATE, false, CheckingMethod::AudioTags, 10.0, 0.2, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 2); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates.iter().map(|e| e.len()).sum::(), 3); } #[test] fn test_same_music_by_tags_all_criteria() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new( MusicSimilarity::TRACK_TITLE | MusicSimilarity::TRACK_ARTIST | MusicSimilarity::YEAR | MusicSimilarity::GENRE, false, CheckingMethod::AudioTags, 10.0, 0.2, false, ); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 0); assert_eq!(info.number_of_groups, 0); assert_eq!(duplicates.len(), 0); } #[test] fn test_same_music_approximate_comparison() { let test_path = get_test_resources_path(); let params = SameMusicParameters::new( MusicSimilarity::TRACK_TITLE | MusicSimilarity::TRACK_ARTIST, true, CheckingMethod::AudioTags, 10.0, 0.2, false, ); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 4); assert_eq!(info.number_of_groups, 1); assert_eq!(duplicates.len(), 1); assert_eq!(duplicates[0].len(), 5); } #[test] fn test_same_music_empty_directory() { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let params = SameMusicParameters::new(MusicSimilarity::TRACK_TITLE, false, CheckingMethod::AudioTags, 10.0, 0.2, false); let mut finder = SameMusic::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let duplicates = finder.get_duplicated_music_entries(); assert_eq!(info.number_of_duplicates, 0); assert_eq!(info.number_of_groups, 0); assert_eq!(duplicates.len(), 0); } czkawka_core-11.0.1/src/tools/same_music/traits.rs000064400000000000000000000162661046102023000203120ustar 00000000000000use std::io::prelude::*; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::consts::AUDIO_FILES_EXTENSIONS; use crate::common::model::{CheckingMethod, WorkContinueStatus}; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::flc; use crate::tools::same_music::core::format_audio_duration; use crate::tools::same_music::{Info, MusicEntry, MusicSimilarity, SameMusic, SameMusicParameters}; impl AllTraits for SameMusic {} impl Search for SameMusic { #[fun_time(message = "find_same_music", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(Some(AUDIO_FILES_EXTENSIONS)).is_err() { return; } self.common_data.use_reference_folders = !self.common_data.directories.reference_directories.is_empty() || !self.common_data.directories.reference_files.is_empty(); if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } match self.params.check_type { CheckingMethod::AudioTags => { if self.params.music_similarity == MusicSimilarity::NONE { self.common_data.text_messages.critical = flc!("core_no_similarity_method_selected").into(); return; } if self.read_tags(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.check_for_duplicate_tags(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } } CheckingMethod::AudioContent => { if self.read_tags(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.calculate_fingerprint(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.check_for_duplicate_fingerprints(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } } _ => panic!(), } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for SameMusic { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); println!("Found files music - {}", self.music_entries.len()); println!("Found duplicated files music - {}", self.duplicated_music_entries.len()); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for SameMusic { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.duplicated_music_entries.is_empty() { writeln!(writer, "{} music files which have similar friends\n\n.", self.duplicated_music_entries.len())?; for vec_file_entry in &self.duplicated_music_entries { writeln!(writer, "Found {} music files which have similar friends", vec_file_entry.len())?; for file_entry in vec_file_entry { write_music_entry(writer, file_entry)?; } writeln!(writer)?; } } else if !self.duplicated_music_entries_referenced.is_empty() { writeln!(writer, "{} music files which have similar friends\n\n.", self.duplicated_music_entries_referenced.len())?; for (file_entry, vec_file_entry) in &self.duplicated_music_entries_referenced { writeln!(writer, "Found {} music files which have similar friends", vec_file_entry.len())?; writeln!(writer)?; write_music_entry(writer, file_entry)?; for file_entry in vec_file_entry { write_music_entry(writer, file_entry)?; } writeln!(writer)?; } } else { write!(writer, "Not found any similar music files.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { if self.get_use_reference() { self.save_results_to_file_as_json_internal(file_name, &self.duplicated_music_entries_referenced, pretty_print) } else { self.save_results_to_file_as_json_internal(file_name, &self.duplicated_music_entries, pretty_print) } } } fn write_music_entry(writer: &mut T, file_entry: &MusicEntry) -> std::io::Result<()> { writeln!( writer, "TT: {} - TA: {} - Y: {} - L: {} - G: {} - B: {} - P: \"{}\"", file_entry.track_title, file_entry.track_artist, file_entry.year, format_audio_duration(file_entry.length), file_entry.genre, file_entry.bitrate, file_entry.path.to_string_lossy() ) } impl CommonData for SameMusic { type Info = Info; type Parameters = SameMusicParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn get_check_method(&self) -> CheckingMethod { self.get_params().check_type } fn found_any_items(&self) -> bool { self.information.number_of_duplicates > 0 } } impl DeletingItems for SameMusic { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.get_cd().delete_method == DeleteMethod::None { return WorkContinueStatus::Continue; } let files_to_delete = self.duplicated_music_entries.clone(); self.delete_advanced_elements_and_add_to_messages(stop_flag, progress_sender, files_to_delete) } } czkawka_core-11.0.1/src/tools/similar_images/core.rs000064400000000000000000001475401046102023000205740ustar 00000000000000use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::{mem, panic}; use bk_tree::BKTree; use crossbeam_channel::Sender; use fun_time::fun_time; use image::GenericImageView; use image_hasher::{FilterType, HashAlg, HasherConfig}; use indexmap::{IndexMap, IndexSet}; use log::{debug, error}; use rayon::prelude::*; use crate::common::cache::{CACHE_IMAGE_VERSION, load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult, inode, take_1_per_inode}; use crate::common::image::get_dynamic_image_from_path; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::traits::ResultEntry; use crate::flc; use crate::tools::similar_images::{Hamming, ImHash, ImagesEntry, SIMILAR_VALUES, SimilarImages, SimilarImagesParameters, SimilarityPreset}; impl SimilarImages { pub fn new(params: SimilarImagesParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::SimilarImages), information: Default::default(), bktree: BKTree::new(Hamming), similar_vectors: Vec::new(), similar_referenced_vectors: Vec::new(), params, images_to_check: Default::default(), image_hashes: Default::default(), } } #[fun_time(message = "check_for_similar_images", level = "debug")] pub(crate) fn check_for_similar_images(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(inode) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.images_to_check = grouped_file_entries .into_par_iter() .flat_map(if self.get_hide_hard_links() { |(_, fes)| fes } else { take_1_per_inode }) .map(|fe| { let fe_str = fe.path.to_string_lossy().to_string(); let image_entry = fe.into_images_entry(); (fe_str, image_entry) }) .collect(); self.information.initial_found_files = self.images_to_check.len(); self.common_data.text_messages.warnings.extend(warnings); debug!("check_files - Found {} image files.", self.images_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "hash_images_load_cache", level = "debug")] fn hash_images_load_cache(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { load_and_split_cache_generalized_by_path( &get_similar_images_cache_file(self.get_params().hash_size, self.get_params().hash_alg, self.get_params().image_filter), mem::take(&mut self.images_to_check), self, ) } #[fun_time(message = "save_to_cache", level = "debug")] fn save_to_cache(&mut self, vec_file_entry: &[ImagesEntry], loaded_hash_map: BTreeMap) { save_and_connect_cache_generalized_by_path( &get_similar_images_cache_file(self.get_params().hash_size, self.get_params().hash_alg, self.get_params().image_filter), vec_file_entry, loaded_hash_map, self, ); } #[fun_time(message = "hash_images", level = "debug")] pub(crate) fn hash_images(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.images_to_check.is_empty() { return WorkContinueStatus::Continue; } let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.hash_images_load_cache(); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::SimilarImagesCalculatingHashes, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|entry| entry.size).sum(), ); debug!("hash_images - start hashing images"); let (mut vec_file_entry, errors): (Vec, Vec) = non_cached_files_to_check .into_par_iter() .map(|(_s, file_entry)| { if check_if_stop_received(stop_flag) { return None; } let size = file_entry.size; let res = self.collect_image_file_entry(file_entry); progress_handler.increase_items(1); progress_handler.increase_size(size); Some(res) }) .while_some() .partition_map(|res| match res { Ok(entry) => itertools::Either::Left(entry), Err(err) => itertools::Either::Right(err), }); self.common_data.text_messages.errors.extend(errors); debug!("hash_images - end hashing {} images", vec_file_entry.len()); progress_handler.join_thread(); vec_file_entry.extend(records_already_cached.into_values()); self.save_to_cache(&vec_file_entry, loaded_hash_map); // All valid entries are used to create bktree used to check for hash similarity for file_entry in vec_file_entry { // Only use to comparing, non broken hashes(all 0 or 255 hashes means that algorithm fails to decode them because e.g. contains a lot of alpha channel) if !(file_entry.hash.is_empty() || file_entry.hash.iter().all(|e| *e == 0) || file_entry.hash.iter().all(|e| *e == 255)) { self.image_hashes.entry(file_entry.hash.clone()).or_default().push(file_entry); } } // Break if stop was clicked after saving to cache if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } fn collect_image_file_entry(&self, mut file_entry: ImagesEntry) -> Result { let img = get_dynamic_image_from_path(&file_entry.path.to_string_lossy())?; let dimensions = img.dimensions(); file_entry.width = dimensions.0; file_entry.height = dimensions.1; let hasher_config = HasherConfig::new() .hash_size(self.get_params().hash_size as u32, self.get_params().hash_size as u32) .hash_alg(self.get_params().hash_alg) .resize_filter(self.get_params().image_filter); let hasher = hasher_config.to_hasher(); let hash = hasher.hash_image(&img); file_entry.hash = hash.as_bytes().to_vec(); Ok(file_entry) } // Split hashes at 2 parts, base hashes and hashes to compare, 3 argument is set of hashes with multiple images #[fun_time(message = "split_hashes", level = "debug")] fn split_hashes(&mut self, all_hashed_images: &IndexMap>) -> (Vec, IndexSet) { let hashes_with_multiple_images: IndexSet = all_hashed_images .iter() .filter_map(|(hash, vec_file_entry)| { if vec_file_entry.len() >= 2 { return Some(hash.clone()); } None }) .collect(); let mut base_hashes = Vec::new(); // Initial hashes if self.common_data.use_reference_folders { let mut files_from_referenced_folders: IndexMap> = IndexMap::new(); let mut normal_files: IndexMap> = IndexMap::new(); all_hashed_images.clone().into_iter().for_each(|(hash, vec_file_entry)| { for file_entry in vec_file_entry { if is_in_reference_folder(&self.common_data.directories.reference_directories, &file_entry.path) { files_from_referenced_folders.entry(hash.clone()).or_default().push(file_entry); } else { normal_files.entry(hash.clone()).or_default().push(file_entry); } } }); for hash in normal_files.into_keys() { self.bktree.add(hash); } for hash in files_from_referenced_folders.into_keys() { base_hashes.push(hash); } } else { for original_hash in all_hashed_images.keys() { self.bktree.add(original_hash.clone()); } base_hashes = all_hashed_images.keys().cloned().collect::>(); } (base_hashes, hashes_with_multiple_images) } #[fun_time(message = "collect_hash_compare_result", level = "debug")] fn collect_hash_compare_result( &self, hashes_parents: IndexMap, hashes_with_multiple_images: &IndexSet, all_hashed_images: &IndexMap>, collected_similar_images: &mut IndexMap>, hashes_similarity: IndexMap, ) { // Collecting results to vector for (parent_hash, child_number) in hashes_parents { // If hash contains other hasher OR multiple images are available for checked hash if child_number > 0 || hashes_with_multiple_images.contains(&parent_hash) { let vec_fe = all_hashed_images[&parent_hash].clone(); collected_similar_images.insert(parent_hash.clone(), vec_fe); } } for (child_hash, (parent_hash, similarity)) in hashes_similarity { let mut vec_fe = all_hashed_images[&child_hash].clone(); for fe in &mut vec_fe { fe.difference = similarity; } collected_similar_images .get_mut(&parent_hash) .expect("Cannot find parent hash - this should be added in previous step") .append(&mut vec_fe); } } #[fun_time(message = "compare_hashes_with_non_zero_tolerance", level = "debug")] fn compare_hashes_with_non_zero_tolerance( &mut self, all_hashed_images: &IndexMap>, collected_similar_images: &mut IndexMap>, progress_sender: Option<&Sender>, stop_flag: &Arc, tolerance: u32, ) -> WorkContinueStatus { // Don't use hashes with multiple images in bktree, because they will always be master of group and cannot be find by other hashes let (base_hashes, hashes_with_multiple_images) = self.split_hashes(all_hashed_images); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::SimilarImagesComparingHashes, base_hashes.len(), self.get_test_type(), 0); let mut hashes_parents: IndexMap = Default::default(); // Hashes used as parent (hash, children_number_of_hash) let mut hashes_similarity: IndexMap = Default::default(); // Hashes used as child, (parent_hash, similarity) // Check them in chunks, to decrease number of used memory // Without chunks, every single hash would be compared to every other hash and generate really big amount of results // With chunks we can save results to variables and later use such variables, to skip ones with too big difference // Not really helpful, when not finding almost any duplicates, but with bigger amount of them, this should help a lot let base_hashes_chunks = base_hashes.chunks(1000); for chunk in base_hashes_chunks { let partial_results = chunk .into_par_iter() .map(|hash_to_check| { progress_handler.increase_items(1); if check_if_stop_received(stop_flag) { return None; } let mut found_items = self .bktree .find(hash_to_check, tolerance) .filter(|(similarity, compared_hash)| { *similarity != 0 && !hashes_parents.contains_key(*compared_hash) && !hashes_with_multiple_images.contains(*compared_hash) }) .filter(|(similarity, compared_hash)| { if let Some((_, other_similarity_with_parent)) = hashes_similarity.get(*compared_hash) { // If current hash is more similar to other hash than to current parent hash, then skip check earlier // Because there is no way to be more similar to other hash than to current parent hash if *similarity >= *other_similarity_with_parent { return false; } } true }) .collect::>(); // Sort by tolerance found_items.sort_unstable_by_key(|f| f.0); Some((hash_to_check, found_items)) }) .while_some() // TODO - this filter move to into_par_iter above .filter(|(original_hash, vec_similar_hashes)| !vec_similar_hashes.is_empty() || hashes_with_multiple_images.contains(*original_hash)) .collect::>(); if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } SimilarImages::connect_results_simplified(partial_results, &mut hashes_parents, &mut hashes_similarity, &hashes_with_multiple_images); } // To avoid situations in simplified connector we don't add such hashes to results for multiple_image_hash in &hashes_with_multiple_images { if !hashes_parents.contains_key(multiple_image_hash) { hashes_parents.insert(multiple_image_hash.clone(), 0); } } progress_handler.join_thread(); debug_check_for_duplicated_things(self.common_data.use_reference_folders, &hashes_parents, &hashes_similarity, all_hashed_images, "LATTER"); self.collect_hash_compare_result(hashes_parents, &hashes_with_multiple_images, all_hashed_images, collected_similar_images, hashes_similarity); WorkContinueStatus::Continue } fn connect_results_simplified<'a>( partial_results: Vec<(&'a ImHash, Vec<(u32, &'a ImHash)>)>, hashes_parents: &mut IndexMap, hashes_similarity: &mut IndexMap, hashes_with_multiple_images: &IndexSet, ) { // To simplify later logic, we sort all results by similarity // To be able to do this, we need to flatten structure, which will increase memory usage a bit, but should improve a little logic(algorithm is a little broken and works better with sorted data) // There can be hashes with multiple similar images, without any similar hashes, so we need to keep them too and add to final results without even checking for parents etc. let mut flattened_partial_results: Vec<(&'a ImHash, (u32, &'a ImHash))> = partial_results .into_iter() .filter_map(|(parent, similar)| { if similar.is_empty() { assert!(hashes_with_multiple_images.contains(parent)); // We expect, that only hashes with multiple images can have no similar hashes assert!(!hashes_parents.contains_key(parent)); // We expect, that this hash is not already in parents list - this would be strange, because it have no similar hashes None } else { Some(similar.into_iter().map(move |sim| (parent, sim))) } }) .flatten() .collect::>(); flattened_partial_results.sort_by_key(|(_parent, (similarity, _compared_hash))| *similarity); // Original hash means, that we check this hash and we can easily find this hash a new parent // Compared hash cannot be changed if it is already parent to different hash, because it would be too complex to handle this properly for (original_hash, (similarity, compared_hash)) in flattened_partial_results { // If compared hash already is parent to different hash, skip it // This may be not optimal, because we may miss better parent for such hash, but I have no idea how to properly reparent it // This would be hard, because we would need to track all similar hashes for reparented childrens, to find them better parents if hashes_parents.contains_key(compared_hash) { continue; } let compared_hash_parent = if let Some((other_parent_hash, other_similarity)) = hashes_similarity.get(compared_hash) { if *other_similarity > similarity { Some(other_parent_hash.clone()) } else { // Have parent, but with lower similarity, so skipping this one continue; } } else { None }; // If current checked hash, have parent, first we must check if similarity between them is lower than checked item if let Some((current_parent_hash, current_similarity_with_parent)) = hashes_similarity.get(original_hash) { if *current_similarity_with_parent <= similarity { // Have more similar parent, so skip this one continue; } let children_count = hashes_parents.get_mut(current_parent_hash).expect("Cannot find parent hash"); *children_count -= 1; let left_any_children = *children_count != 0; // We can remove entirely previous parent from hashes_parents if it will not have any other children // Of course, only if hash applies to single image, because hashes with multiple images must stay in parents list if !left_any_children && !hashes_with_multiple_images.contains(current_parent_hash) { hashes_parents.swap_remove(current_parent_hash); } hashes_similarity .swap_remove(original_hash) .expect("This should never fail, because we are iterating over this hash"); let parent = hashes_parents.insert((*original_hash).clone(), 1); assert!(parent.is_none(), "Parent hash should not exist here"); } else { *hashes_parents.entry(original_hash.clone()).or_insert(0) += 1; } // This overwrites parent hash if there was any // or just adds new record if there was no parent hashes_similarity.insert(compared_hash.clone(), (original_hash.clone(), similarity)); if let Some(compared_hash_parent) = compared_hash_parent { *hashes_parents.get_mut(&compared_hash_parent).expect("Cannot find parent hash") -= 1; } } } #[fun_time(message = "find_similar_hashes", level = "debug")] pub(crate) fn find_similar_hashes(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.image_hashes.is_empty() { return WorkContinueStatus::Continue; } let tolerance = self.get_params().max_difference; // Results let mut collected_similar_images: IndexMap> = Default::default(); let all_hashed_images = mem::take(&mut self.image_hashes); // Checking entries with tolerance 0 is really easy and fast, because only entries with same hashes needs to be checked if tolerance == 0 { for (hash, vec_file_entry) in all_hashed_images { if vec_file_entry.len() >= 2 { collected_similar_images.insert(hash, vec_file_entry); } } } else if self.compare_hashes_with_non_zero_tolerance(&all_hashed_images, &mut collected_similar_images, progress_sender, stop_flag, tolerance) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } Self::verify_duplicated_items(&collected_similar_images); // Info about hashes is not needed anymore, so we drop this info self.similar_vectors = collected_similar_images.into_values().collect(); self.exclude_items_with_same_size(); self.remove_multiple_records_from_reference_folders(); if self.common_data.use_reference_folders { for (_fe, vector) in &self.similar_referenced_vectors { self.information.number_of_duplicates += vector.len(); self.information.number_of_groups += 1; } } else { for vector in &self.similar_vectors { self.information.number_of_duplicates += vector.len() - 1; self.information.number_of_groups += 1; } } // Clean unused data to save ram self.image_hashes = Default::default(); self.images_to_check = Default::default(); self.bktree = BKTree::new(Hamming); WorkContinueStatus::Continue } #[fun_time(message = "exclude_items_with_same_size", level = "debug")] fn exclude_items_with_same_size(&mut self) { if self.get_params().exclude_images_with_same_size { for vec_file_entry in mem::take(&mut self.similar_vectors) { let mut bt_sizes: BTreeSet = Default::default(); let mut vec_values = Vec::new(); for file_entry in vec_file_entry { if bt_sizes.insert(file_entry.size) { vec_values.push(file_entry); } } if vec_values.len() > 1 { self.similar_vectors.push(vec_values); } } } } #[fun_time(message = "remove_multiple_records_from_reference_folders", level = "debug")] fn remove_multiple_records_from_reference_folders(&mut self) { if self.common_data.use_reference_folders { self.similar_referenced_vectors = mem::take(&mut self.similar_vectors) .into_iter() .filter_map(|vec_file_entry| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>(); } } // TODO this probably not works good when reference folders are used pub(crate) fn verify_duplicated_items(collected_similar_images: &IndexMap>) { if !cfg!(debug_assertions) { return; } // Validating if group contains duplicated results let mut result_hashset: IndexSet = Default::default(); let mut found = false; for vec_file_entry in collected_similar_images.values() { if vec_file_entry.is_empty() { error!("Found empty group"); found = true; continue; } if vec_file_entry.len() == 1 { error!("Found simple element {vec_file_entry:?}"); found = true; continue; } for file_entry in vec_file_entry { let st = file_entry.path.to_string_lossy().to_string(); if result_hashset.contains(&st) { found = true; error!("Duplicated Element {st}"); } else { result_hashset.insert(st); } } } assert!(!found, "Found Invalid entries, verify errors before"); } } fn is_in_reference_folder(reference_directories: &[PathBuf], path: &Path) -> bool { reference_directories.iter().any(|e| path.starts_with(e)) } #[expect(clippy::indexing_slicing)] // Because hash size is validated before pub fn get_string_from_similarity(similarity: u32, hash_size: u8) -> String { let index_preset = match hash_size { 8 => 0, 16 => 1, 32 => 2, 64 => 3, _ => panic!("Invalid hash size {hash_size} (caller is responsible for validating this)"), }; if similarity == 0 { flc!("core_similarity_original") } else if similarity <= SIMILAR_VALUES[index_preset][0] { flc!("core_similarity_very_high") } else if similarity <= SIMILAR_VALUES[index_preset][1] { flc!("core_similarity_high") } else if similarity <= SIMILAR_VALUES[index_preset][2] { flc!("core_similarity_medium") } else if similarity <= SIMILAR_VALUES[index_preset][3] { flc!("core_similarity_small") } else if similarity <= SIMILAR_VALUES[index_preset][4] { flc!("core_similarity_very_small") } else if similarity <= SIMILAR_VALUES[index_preset][5] { flc!("core_similarity_minimal") } else { panic!("Invalid similarity value {similarity} for hash size {hash_size} (index {index_preset}) (caller is responsible for validating this)"); } } #[expect(clippy::indexing_slicing)] // Because hash size is validated before pub fn return_similarity_from_similarity_preset(similarity_preset: SimilarityPreset, hash_size: u8) -> u32 { let index_preset = match hash_size { 8 => 0, 16 => 1, 32 => 2, 64 => 3, _ => panic!("Invalid hash size {hash_size} (caller is responsible for validating this)"), }; match similarity_preset { SimilarityPreset::Original => 0, SimilarityPreset::VeryHigh => SIMILAR_VALUES[index_preset][0], SimilarityPreset::High => SIMILAR_VALUES[index_preset][1], SimilarityPreset::Medium => SIMILAR_VALUES[index_preset][2], SimilarityPreset::Small => SIMILAR_VALUES[index_preset][3], SimilarityPreset::VerySmall => SIMILAR_VALUES[index_preset][4], SimilarityPreset::Minimal => SIMILAR_VALUES[index_preset][5], SimilarityPreset::None => panic!("Invalid similarity preset None (caller is responsible for validating this)"), } } pub(crate) fn convert_filters_to_string(image_filter: FilterType) -> String { match image_filter { FilterType::Lanczos3 => "Lanczos3", FilterType::Nearest => "Nearest", FilterType::Triangle => "Triangle", FilterType::Gaussian => "Gaussian", FilterType::CatmullRom => "CatmullRom", } .to_string() } pub(crate) fn convert_algorithm_to_string(hash_alg: HashAlg) -> String { match hash_alg { HashAlg::Mean => "Mean", HashAlg::Gradient => "Gradient", HashAlg::Blockhash => "Blockhash", HashAlg::VertGradient => "VertGradient", HashAlg::DoubleGradient => "DoubleGradient", HashAlg::Median => "Median", } .to_string() } #[allow(clippy::allow_attributes)] #[allow(unfulfilled_lint_expectations)] // Happens only on release build #[expect(dead_code)] #[expect(unreachable_code)] #[expect(unused_variables)] // Function to validate if after first check there are any duplicated entries // E.g. /a.jpg is used also as master and similar image which is forbidden, because may // cause accidentally delete more pictures that user wanted fn debug_check_for_duplicated_things( use_reference_folders: bool, hashes_parents: &IndexMap, hashes_similarity: &IndexMap, all_hashed_images: &IndexMap>, numm: &str, ) { if !cfg!(debug_assertions) { return; } if use_reference_folders { return; } let mut found_broken_thing = false; let mut hashmap_hashes: IndexSet<_> = Default::default(); let mut hashmap_names: IndexSet<_> = Default::default(); for (hash, number_of_children) in hashes_parents { if *number_of_children > 0 { if hashmap_hashes.contains(hash) { debug!("------1--HASH--{} {:?}", numm, all_hashed_images[hash]); found_broken_thing = true; } hashmap_hashes.insert((*hash).clone()); for i in &all_hashed_images[hash] { let name = i.path.to_string_lossy().to_string(); if hashmap_names.contains(&name) { debug!("------1--NAME--{numm} {name:?}"); found_broken_thing = true; } hashmap_names.insert(name); } } } for hash in hashes_similarity.keys() { if hashmap_hashes.contains(hash) { debug!("------2--HASH--{} {:?}", numm, all_hashed_images[hash]); found_broken_thing = true; } hashmap_hashes.insert((*hash).clone()); for i in &all_hashed_images[hash] { let name = i.path.to_string_lossy().to_string(); if hashmap_names.contains(&name) { debug!("------2--NAME--{numm} {name:?}"); found_broken_thing = true; } hashmap_names.insert(name); } } assert!(!found_broken_thing); } pub fn get_similar_images_cache_file(hash_size: u8, hash_alg: HashAlg, image_filter: FilterType) -> String { format!( "cache_similar_images_{hash_size}_{}_{}_{CACHE_IMAGE_VERSION}.bin", convert_algorithm_to_string(hash_alg), convert_filters_to_string(image_filter), ) } #[cfg(test)] mod tests { use std::path::PathBuf; use bk_tree::BKTree; use image::imageops::FilterType; use image_hasher::HashAlg; use indexmap::IndexMap; use super::*; use crate::common::tool_data::CommonData; use crate::tools::similar_images::{Hamming, ImHash, ImagesEntry, SimilarImages, SimilarImagesParameters}; fn get_default_parameters() -> SimilarImagesParameters { SimilarImagesParameters { hash_alg: HashAlg::Gradient, hash_size: 8, max_difference: 0, image_filter: FilterType::Lanczos3, exclude_images_with_same_size: false, } } // Just to debug changes to algorithms // #[test] // fn test_fuzzer() { // for _ in 0..100 { // let mut parameters = get_default_parameters(); // parameters.similarity = rand::random::() % 40; // let mut similar_images = SimilarImages::new(parameters); // // for i in 0..(rand::random::() % 2000) { // let mut entry = vec![1u8; 8]; // entry[1] = rand::random::(); // if rand::random::() { // entry[2] = rand::random::(); // } // if rand::random::() { // entry[3] = rand::random::(); // } // if rand::random::() { // entry[4] = rand::random::(); // } // let fe = create_random_file_entry(entry, &format!("file_{i}.txt")); // add_hashes(&mut similar_images.image_hashes, vec![fe]); // } // // similar_images.find_similar_hashes(&Arc::default(), None); // } // } #[test] fn test_compare_no_images() { use crate::common::traits::Search; for _ in 0..100 { let mut similar_images = SimilarImages::new(get_default_parameters()); similar_images.search(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 0); } } #[test] fn test_compare_tolerance_0_normal_mode() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 0; let mut similar_images = SimilarImages::new(parameters); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 2], "cde.txt"); let fe4 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 2], "rrt.txt"); let fe5 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 2], "bld.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1.clone(), fe2.clone(), fe3.clone(), fe4.clone(), fe5.clone()]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 2); let first_group = similar_images.get_similar_images()[0].iter().map(|e| &e.path).collect::>(); let second_group = similar_images.get_similar_images()[1].iter().map(|e| &e.path).collect::>(); // Initial order is not guaranteed, so we need to check both options if similar_images.get_similar_images()[0][0].hash == fe1.hash { assert_eq!(first_group, vec![&fe1.path, &fe2.path]); assert_eq!(second_group, vec![&fe3.path, &fe4.path, &fe5.path]); } else { assert_eq!(first_group, vec![&fe3.path, &fe4.path, &fe5.path]); assert_eq!(second_group, vec![&fe1.path, &fe2.path]); } } } #[test] fn test_simple_normal_one_group() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 1); } } #[test] fn test_2000_hashes() { let mut parameters = get_default_parameters(); parameters.max_difference = 10; let mut similar_images = SimilarImages::new(parameters); for i in 0..2000 { let mut entry = vec![1u8; 8]; entry[7] = (i as u32 % 256) as u8; entry[6] = (i as u32 / 256 % 256) as u8; entry[5] = (i as u32 / 256 / 256 % 256) as u8; let fe = create_random_file_entry(entry, &format!("file_{i}.txt")); add_hashes(&mut similar_images.image_hashes, vec![fe]); } similar_images.find_similar_hashes(&Arc::default(), None); assert!(!similar_images.get_similar_images().is_empty()); } #[test] fn test_simple_normal_one_group_extended() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 2; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(false); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 2], "rrd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 1); assert_eq!(similar_images.get_similar_images()[0].len(), 3); } } #[test] fn test_simple_normal_one_group_extended2() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 222222; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(false); let fe1 = create_random_file_entry(vec![59, 41, 53, 27, 19, 143, 228, 228], "abc.txt"); let fe2 = create_random_file_entry(vec![57, 41, 60, 155, 51, 173, 204, 228], "bcd.txt"); let fe3 = create_random_file_entry(vec![28, 222, 206, 192, 203, 157, 25, 24], "rrd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 1); assert_eq!(similar_images.get_similar_images()[0].len(), 3); } } #[test] fn test_simple_referenced_same_group() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 0; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images().len(), 0); } } #[test] fn test_simple_referenced_group_extended() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 0; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/kk/bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); assert_eq!(similar_images.get_similar_images_referenced().len(), 1); assert_eq!(similar_images.get_similar_images_referenced()[0].1.len(), 1); } } #[test] fn test_simple_referenced_group_extended2() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 0; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/abc2.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/kk/bcd.txt"); let fe4 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/kk/bcd2.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3, fe4]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 1); assert_eq!(res[0].1.len(), 2); assert!(res[0].1.iter().all(|e| e.path.starts_with("/home/kk/"))); } } #[test] fn test_simple_normal_too_small_similarity() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(false); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b00001], "abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b00100], "bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b10000], "rrd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images(); assert!(res.is_empty()); } } #[test] fn test_simple_normal_union_of_similarity() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 4; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(false); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0000_0001], "abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0000_1111], "bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0111_1111], "rrd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images(); assert_eq!(res.len(), 1); let mut path = res[0].iter().map(|e| e.path.to_string_lossy().to_string()).collect::>(); path.sort(); if res[0].len() == 3 { assert_eq!(path, vec!["abc.txt".to_string(), "bcd.txt".to_string(), "rrd.txt".to_string()]); } else if res[0].len() == 2 { assert!(path == vec!["abc.txt".to_string(), "bcd.txt".to_string()] || path == vec!["bcd.txt".to_string(), "rrd.txt".to_string()]); } else { panic!("Invalid number of items"); } } } #[test] fn test_reference_similarity_only_one() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0001], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0011], "/home/kk/bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 1); assert_eq!(res[0].1.len(), 1); assert_eq!(res[0].0.path, PathBuf::from("/home/rr/abc.txt")); assert_eq!(res[0].1[0].path, PathBuf::from("/home/kk/bcd.txt")); } } #[test] fn test_reference_too_small_similarity() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0001], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0010], "/home/kk/bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 0); } } #[test] fn test_reference_minimal() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0001], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0011], "/home/kk/bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0100], "/home/kk/bcd2.txt"); let fe4 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b1100], "/home/rr/krkr.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2, fe3, fe4]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 2); assert_eq!(res[0].1.len(), 1); assert_eq!(res[1].1.len(), 1); #[allow(clippy::allow_attributes)] #[allow(clippy::cmp_owned)] // TODO Bug in nightly if res[0].1[0].path == PathBuf::from("/home/kk/bcd.txt") { assert_eq!(res[0].0.path, PathBuf::from("/home/rr/abc.txt")); assert_eq!(res[1].0.path, PathBuf::from("/home/rr/krkr.txt")); } else if res[0].1[0].path == PathBuf::from("/home/kk/bcd2.txt") { assert_eq!(res[0].0.path, PathBuf::from("/home/rr/krkr.txt")); assert_eq!(res[1].0.path, PathBuf::from("/home/rr/abc.txt")); } } } #[test] fn test_reference_same() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 1; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 1], "/home/kk/bcd.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe1, fe2]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 1); assert_eq!(res[0].1.len(), 1); } } #[test] fn test_reference_union() { for _ in 0..100 { let mut parameters = get_default_parameters(); parameters.max_difference = 10; let mut similar_images = SimilarImages::new(parameters); similar_images.set_use_reference_folders(true); // Not using special method, because it validates if path exists similar_images.common_data.directories.reference_directories = vec![PathBuf::from("/home/rr/")]; let fe0 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b1000], "/home/rr/abc2.txt"); let fe1 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0001], "/home/rr/abc.txt"); let fe2 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b1110], "/home/kk/bcd.txt"); let fe3 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b0100], "/home/kk/bcd2.txt"); let fe4 = create_random_file_entry(vec![1, 1, 1, 1, 1, 1, 1, 0b1100], "/home/rr/krkr.txt"); add_hashes(&mut similar_images.image_hashes, vec![fe0, fe1, fe2, fe3, fe4]); similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images_referenced(); assert_eq!(res.len(), 1); assert_eq!(res[0].1.len(), 2); assert_eq!(res[0].0.path, PathBuf::from("/home/rr/krkr.txt")); } } #[test] fn test_tolerance() { // This test not really tests anything, but shows that current hamming distance works // in bits instead of bytes // I tried to make it work in bytes, but it was terrible, so Hamming should be really Ok let fe1 = vec![1, 1, 1, 1, 1, 1, 1, 1]; let fe2 = vec![1, 1, 1, 1, 1, 1, 1, 2]; let mut bktree = BKTree::new(Hamming); bktree.add(fe1); let (similarity, _hash) = bktree.find(&fe2, 100).next().expect("No similar images found"); assert_eq!(similarity, 2); let fe1 = vec![1, 1, 1, 1, 1, 1, 1, 1]; let fe2 = vec![1, 1, 1, 1, 1, 1, 1, 3]; let mut bktree = BKTree::new(Hamming); bktree.add(fe1); let (similarity, _hash) = bktree.find(&fe2, 100).next().expect("No similar images found"); assert_eq!(similarity, 1); let fe1 = vec![1, 1, 1, 1, 1, 1, 1, 0b0000_0000]; let fe2 = vec![1, 1, 1, 1, 1, 1, 1, 0b0000_1000]; let mut bktree = BKTree::new(Hamming); bktree.add(fe1); let (similarity, _hash) = bktree.find(&fe2, 100).next().expect("No similar images found"); assert_eq!(similarity, 1); } fn add_hashes(hashmap: &mut IndexMap>, file_entries: Vec) { for fe in file_entries { hashmap.entry(fe.hash.clone()).or_default().push(fe); } } fn create_random_file_entry(hash: Vec, name: &str) -> ImagesEntry { ImagesEntry { path: PathBuf::from(name.to_string()), size: 0, width: 100, height: 100, modified_date: 0, hash, difference: 0, } } } #[cfg(test)] mod connect_results_tests { use image_hasher::{FilterType, HashAlg}; use indexmap::{IndexMap, IndexSet}; use super::*; #[test] fn test_connect_results_real_case() { let params = SimilarImagesParameters::new(10, 8, HashAlg::Gradient, FilterType::Lanczos3, false); let _finder = SimilarImages::new(params); let hash1: ImHash = vec![59, 41, 53, 27, 19, 143, 228, 228]; let hash2: ImHash = vec![57, 41, 60, 155, 51, 173, 204, 228]; let hash3: ImHash = vec![28, 222, 206, 192, 203, 157, 25, 24]; let partial_results = vec![ (&hash1, vec![(9, &hash2), (43, &hash3)]), (&hash2, vec![(9, &hash1), (38, &hash3)]), (&hash3, vec![(38, &hash2), (43, &hash1)]), ]; let mut hashes_parents: IndexMap = IndexMap::new(); let mut hashes_similarity: IndexMap = IndexMap::new(); let hashes_with_multiple_images: IndexSet = IndexSet::new(); assert_eq!(hashes_parents.len(), 0); assert_eq!(hashes_similarity.len(), 0); SimilarImages::connect_results_simplified(partial_results, &mut hashes_parents, &mut hashes_similarity, &hashes_with_multiple_images); assert_eq!(hashes_parents.len(), 1); assert_eq!(hashes_similarity.len(), 2); } } czkawka_core-11.0.1/src/tools/similar_images/mod.rs000064400000000000000000000073161046102023000204170ustar 00000000000000pub mod core; pub mod traits; #[cfg(test)] mod tests; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; use bk_tree::BKTree; use hamming_bitwise_fast::hamming_bitwise_fast; use image_hasher::{FilterType, HashAlg}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; type ImHash = Vec; // 40 is a little useless in 8 similarity - but this value is kept to simplify harder Krokiet max value calculations pub const SIMILAR_VALUES: [[u32; 6]; 4] = [ [1, 2, 5, 7, 14, 40], // 8 [2, 5, 15, 30, 40, 40], // 16 [4, 10, 20, 40, 40, 40], // 32 [6, 20, 40, 40, 40, 40], // 64 ]; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ImagesEntry { pub path: PathBuf, pub size: u64, pub width: u32, pub height: u32, pub modified_date: u64, pub hash: ImHash, pub difference: u32, } impl ResultEntry for ImagesEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_images_entry(self) -> ImagesEntry { ImagesEntry { size: self.size, path: self.path, modified_date: self.modified_date, width: 0, height: 0, hash: Vec::new(), difference: 0, } } } #[derive(Clone, Debug, Copy)] pub enum SimilarityPreset { Original, VeryHigh, High, Medium, Small, VerySmall, Minimal, None, } struct Hamming; impl bk_tree::Metric for Hamming { fn distance(&self, a: &ImHash, b: &ImHash) -> u32 { hamming_bitwise_fast(a, b) } fn threshold_distance(&self, a: &ImHash, b: &ImHash, _threshold: u32) -> Option { Some(self.distance(a, b)) } } #[derive(Clone)] pub struct SimilarImagesParameters { pub max_difference: u32, pub hash_size: u8, pub hash_alg: HashAlg, pub image_filter: FilterType, pub exclude_images_with_same_size: bool, } impl SimilarImagesParameters { pub fn new(max_difference: u32, hash_size: u8, hash_alg: HashAlg, image_filter: FilterType, exclude_images_with_same_size: bool) -> Self { assert!([8, 16, 32, 64].contains(&hash_size)); Self { max_difference, hash_size, hash_alg, image_filter, exclude_images_with_same_size, } } } pub struct SimilarImages { common_data: CommonToolData, information: Info, bktree: BKTree, similar_vectors: Vec>, similar_referenced_vectors: Vec<(ImagesEntry, Vec)>, // Hashmap with image hashes and Vector with names of files image_hashes: IndexMap>, images_to_check: BTreeMap, params: SimilarImagesParameters, } #[derive(Default, Clone, Copy)] pub struct Info { pub initial_found_files: usize, pub number_of_duplicates: usize, pub number_of_groups: usize, pub scanning_time: Duration, } impl SimilarImages { pub fn get_params(&self) -> &SimilarImagesParameters { &self.params } pub const fn get_similar_images(&self) -> &Vec> { &self.similar_vectors } pub fn get_similar_images_referenced(&self) -> &Vec<(ImagesEntry, Vec)> { &self.similar_referenced_vectors } pub fn get_use_reference(&self) -> bool { self.common_data.use_reference_folders } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/similar_images/tests.rs000064400000000000000000000110231046102023000207700ustar 00000000000000use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use image_hasher::{FilterType, HashAlg}; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::similar_images::{SimilarImages, SimilarImagesParameters}; fn get_test_resources_path() -> PathBuf { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_resources").join("images"); assert!(path.exists(), "Test resources not found at \"{}\"", path.to_string_lossy()); path } #[test] fn test_similar_images() { let test_path = get_test_resources_path(); let algo_filter_hash_sim_found = [ (HashAlg::Gradient, FilterType::Lanczos3, 8, 222240, 2, 1, 3), (HashAlg::Gradient, FilterType::Lanczos3, 8, 15, 1, 1, 2), (HashAlg::Gradient, FilterType::Lanczos3, 8, 8, 0, 0, 0), (HashAlg::Blockhash, FilterType::Lanczos3, 8, 40, 2, 1, 3), (HashAlg::Blockhash, FilterType::Lanczos3, 8, 15, 1, 1, 2), (HashAlg::Blockhash, FilterType::Lanczos3, 8, 2, 0, 0, 0), (HashAlg::Mean, FilterType::Lanczos3, 8, 40, 2, 1, 3), (HashAlg::Mean, FilterType::Lanczos3, 8, 15, 1, 1, 2), (HashAlg::Mean, FilterType::Lanczos3, 8, 2, 0, 0, 0), (HashAlg::DoubleGradient, FilterType::Lanczos3, 8, 40, 2, 1, 3), (HashAlg::DoubleGradient, FilterType::Lanczos3, 8, 15, 1, 1, 2), (HashAlg::DoubleGradient, FilterType::Lanczos3, 8, 2, 0, 0, 0), (HashAlg::VertGradient, FilterType::Lanczos3, 8, 40, 2, 1, 3), (HashAlg::VertGradient, FilterType::Lanczos3, 8, 15, 1, 1, 2), (HashAlg::VertGradient, FilterType::Lanczos3, 8, 2, 0, 0, 0), (HashAlg::Gradient, FilterType::Gaussian, 16, 15, 0, 0, 0), (HashAlg::Gradient, FilterType::Gaussian, 16, 32, 1, 1, 2), (HashAlg::VertGradient, FilterType::Nearest, 16, 32, 1, 1, 2), ]; for (idx, (hash_alg, filter_type, hash_size, similarity, duplicates, groups, all_in_similar)) in algo_filter_hash_sim_found.into_iter().enumerate() { let params = SimilarImagesParameters::new(similarity, hash_size, hash_alg, filter_type, false); let mut finder = SimilarImages::new(params); finder.set_included_paths(vec![test_path.clone()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let similar_images = finder.get_similar_images(); let msg = format!("Failed for algo/filter/hash/similarity set {idx}: {hash_alg:?}/{filter_type:?}/{hash_size}/{similarity}"); assert_eq!(info.initial_found_files, 3, "{msg}"); assert_eq!(info.number_of_duplicates, duplicates, "{msg}"); assert_eq!(info.number_of_groups, groups, "{msg}"); assert_eq!(similar_images.len(), groups, "{msg}"); assert_eq!(similar_images.iter().map(|e| e.len()).sum::(), all_in_similar, "{msg}"); } } #[test] fn test_similar_images_exclude_same_size() { let test_path = get_test_resources_path(); let params = SimilarImagesParameters::new(10, 8, HashAlg::Gradient, FilterType::Lanczos3, true); let mut finder = SimilarImages::new(params); finder.set_included_paths(vec![test_path]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let similar_images = finder.get_similar_images(); let info = finder.get_information(); assert!(info.number_of_groups > 0); for group in similar_images { if group.len() > 1 { let first_size = group[0].size; let all_same_size = group.iter().all(|img| img.size == first_size); assert!(!all_same_size); } } } #[test] fn test_similar_images_empty_directory() { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let params = SimilarImagesParameters::new(10, 8, HashAlg::Gradient, FilterType::Lanczos3, false); let mut finder = SimilarImages::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); let similar_images = finder.get_similar_images(); assert_eq!(info.number_of_duplicates, 0); assert_eq!(info.number_of_groups, 0); assert_eq!(similar_images.len(), 0); } czkawka_core-11.0.1/src/tools/similar_images/traits.rs000064400000000000000000000152201046102023000211370ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use crate::common::consts::{HEIC_EXTENSIONS, IMAGE_RS_SIMILAR_IMAGES_EXTENSIONS, RAW_IMAGE_EXTENSIONS}; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::similar_images::core::get_string_from_similarity; use crate::tools::similar_images::{Info, SimilarImages, SimilarImagesParameters}; impl AllTraits for SimilarImages {} impl Search for SimilarImages { #[fun_time(message = "find_similar_images", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { let extensions = if cfg!(feature = "heif") { [IMAGE_RS_SIMILAR_IMAGES_EXTENSIONS, RAW_IMAGE_EXTENSIONS, HEIC_EXTENSIONS].concat() } else { [IMAGE_RS_SIMILAR_IMAGES_EXTENSIONS, RAW_IMAGE_EXTENSIONS].concat() }; if self.prepare_items(Some(&extensions)).is_err() { return; } self.common_data.use_reference_folders = !self.common_data.directories.reference_directories.is_empty() || !self.common_data.directories.reference_files.is_empty(); if self.check_for_similar_images(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.hash_images(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.find_similar_hashes(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DebugPrint for SimilarImages { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for SimilarImages { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; if !self.similar_vectors.is_empty() { write!(writer, "{} images which have similar friends\n\n", self.similar_vectors.len())?; for struct_similar in &self.similar_vectors { writeln!(writer, "Found {} images which have similar friends", struct_similar.len())?; for file_entry in struct_similar { writeln!( writer, "\"{}\" - {}x{} - {} - {}", file_entry.path.to_string_lossy(), file_entry.width, file_entry.height, format_size(file_entry.size, BINARY), get_string_from_similarity(file_entry.difference, self.get_params().hash_size) )?; } writeln!(writer)?; } } else if !self.similar_referenced_vectors.is_empty() { writeln!(writer, "{} images which have similar friends\n\n", self.similar_referenced_vectors.len())?; for (file_entry, vec_file_entry) in &self.similar_referenced_vectors { writeln!(writer, "Found {} images which have similar friends", vec_file_entry.len())?; writeln!(writer)?; writeln!( writer, "\"{}\" - {}x{} - {} - {}", file_entry.path.to_string_lossy(), file_entry.width, file_entry.height, format_size(file_entry.size, BINARY), get_string_from_similarity(file_entry.difference, self.get_params().hash_size) )?; for file_entry in vec_file_entry { writeln!( writer, "\"{}\" - {}x{} - {} - {}", file_entry.path.to_string_lossy(), file_entry.width, file_entry.height, format_size(file_entry.size, BINARY), get_string_from_similarity(file_entry.difference, self.get_params().hash_size) )?; } writeln!(writer)?; } } else { write!(writer, "Not found any similar images.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { if self.get_use_reference() { self.save_results_to_file_as_json_internal(file_name, &self.similar_referenced_vectors, pretty_print) } else { self.save_results_to_file_as_json_internal(file_name, &self.similar_vectors, pretty_print) } } } impl CommonData for SimilarImages { type Info = Info; type Parameters = SimilarImagesParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_duplicates > 0 } } impl DeletingItems for SimilarImages { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.get_cd().delete_method == DeleteMethod::None { return WorkContinueStatus::Continue; } let files_to_delete = self.similar_vectors.clone(); self.delete_advanced_elements_and_add_to_messages(stop_flag, progress_sender, files_to_delete) } } czkawka_core-11.0.1/src/tools/similar_videos/core.rs000064400000000000000000000356161046102023000206200ustar 00000000000000use std::collections::{BTreeMap, BTreeSet}; use std::mem; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use indexmap::IndexMap; use log::debug; use rayon::prelude::*; use vid_dup_finder_lib::{CreationOptions, Cropdetect, VideoHash, VideoHashBuilder}; use crate::common::cache::{CACHE_VIDEO_VERSION, load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::config_cache_path::get_config_cache_path; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult, inode, take_1_per_inode}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::traits::ResultEntry; use crate::common::video_utils::{VIDEO_THUMBNAILS_SUBFOLDER, VideoMetadata, generate_thumbnail}; use crate::tools::similar_videos::{SimilarVideos, SimilarVideosParameters, VideosEntry}; impl SimilarVideos { pub fn new(params: SimilarVideosParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::SimilarVideos), information: Default::default(), similar_vectors: Vec::new(), videos_hashes: Default::default(), videos_to_check: Default::default(), similar_referenced_vectors: Vec::new(), params, } } #[fun_time(message = "check_for_similar_videos", level = "debug")] pub(crate) fn check_for_similar_videos(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(inode) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { self.videos_to_check = grouped_file_entries .into_par_iter() .flat_map(if self.get_hide_hard_links() { |(_, fes)| fes } else { take_1_per_inode }) .map(|fe| (fe.path.to_string_lossy().to_string(), fe.into_videos_entry())) .collect(); self.common_data.text_messages.warnings.extend(warnings); debug!("check_files - Found {} video files.", self.videos_to_check.len()); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } fn check_video_file_entry(&self, mut file_entry: VideosEntry) -> VideosEntry { let creation_options = CreationOptions { skip_forward_amount: self.params.skip_forward_amount as f64, duration: self.params.duration as f64, cropdetect: self.params.crop_detect, }; let vhash = match VideoHashBuilder::from_options(creation_options).hash(file_entry.path.clone()) { Ok(t) => t, Err(e) => { let path = file_entry.path.to_string_lossy(); file_entry.error = format!("Failed to hash file \"{path}\": reason {e}"); return file_entry; } }; file_entry.vhash = vhash; file_entry } fn read_video_properties(mut file_entry: VideosEntry) -> VideosEntry { match VideoMetadata::from_path(&file_entry.path) { Ok(metadata) => { file_entry.fps = metadata.fps; file_entry.codec = metadata.codec; file_entry.bitrate = metadata.bitrate; file_entry.width = metadata.width; file_entry.height = metadata.height; file_entry.duration = metadata.duration; } Err(e) => { let path = file_entry.path.to_string_lossy(); file_entry.error = format!("Failed to read properties for file \"{path}\": reason {e}"); } } file_entry } #[fun_time(message = "sort_videos", level = "debug")] pub(crate) fn sort_videos(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.videos_to_check.is_empty() { return WorkContinueStatus::Continue; } let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_cache_at_start(); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::SimilarVideosCalculatingHashes, non_cached_files_to_check.len(), self.get_test_type(), 0, // non_cached_files_to_check.values().map(|e| e.size).sum(), // Looks, that at least for now, there is no big difference between checking big and small files, so at least for now, only tracking number of files is enough ); let non_cached_files_to_check: Vec<_> = non_cached_files_to_check.into_iter().map(|f| f.1).collect(); let mut vec_file_entry: Vec = non_cached_files_to_check .into_par_iter() .with_max_len(2) .map(|file_entry| { if check_if_stop_received(stop_flag) { return None; } // Currently size is not too much relevant // let size = file_entry.size; let res = self.check_video_file_entry(file_entry); let res = Self::read_video_properties(res); progress_handler.increase_items(1); // progress_handler.increase_size(size); Some(res) }) .while_some() .collect::>(); progress_handler.join_thread(); // Just connect loaded results with already calculated hashes vec_file_entry.extend(records_already_cached.into_values()); self.save_cache(&vec_file_entry, loaded_hash_map); let mut hashmap_with_file_entries: IndexMap = Default::default(); let mut vector_of_hashes: Vec = Vec::new(); for file_entry in vec_file_entry { if file_entry.error.is_empty() { vector_of_hashes.push(file_entry.vhash.clone()); hashmap_with_file_entries.insert(file_entry.vhash.src_path().to_string_lossy().to_string(), file_entry); } else { self.common_data.text_messages.warnings.push(file_entry.error); } } // Break if stop was clicked after saving to cache if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } self.match_groups_of_videos(vector_of_hashes, &hashmap_with_file_entries); if self.create_thumbnails(progress_sender, stop_flag) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } self.remove_from_reference_folders(); if self.common_data.use_reference_folders { for (_fe, vector) in &self.similar_referenced_vectors { self.information.number_of_duplicates += vector.len(); self.information.number_of_groups += 1; } } else { for vector in &self.similar_vectors { self.information.number_of_duplicates += vector.len() - 1; self.information.number_of_groups += 1; } } // Clean unused data self.videos_hashes = Default::default(); self.videos_to_check = Default::default(); WorkContinueStatus::Continue } #[fun_time(message = "create_thumbnails", level = "debug")] fn create_thumbnails(&mut self, progress_sender: Option<&Sender>, stop_flag: &Arc) -> WorkContinueStatus { if !self.params.generate_thumbnails { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::SimilarVideosCreatingThumbnails, self.similar_vectors.iter().map(|e| e.len()).sum::(), self.get_test_type(), 0, ); let Some(config_cache_path) = get_config_cache_path() else { return WorkContinueStatus::Continue; }; let thumbnails_dir = config_cache_path.cache_folder.join(VIDEO_THUMBNAILS_SUBFOLDER); if let Err(e) = std::fs::create_dir_all(&thumbnails_dir) { debug!("Failed to create thumbnails directory: {e}"); return WorkContinueStatus::Continue; } let thumbnail_video_percentage_from_start = self.params.thumbnail_video_percentage_from_start; let generate_grid_instead_of_single = self.params.generate_thumbnail_grid_instead_of_single; let thumbnail_grid_tiles_per_side = self.params.thumbnail_grid_tiles_per_side; let errors = self .similar_vectors .par_iter_mut() .with_max_len(2) .map(|vec_file_entry| { let mut errs = Vec::new(); for file_entry in vec_file_entry { if check_if_stop_received(stop_flag) { return errs; } match generate_thumbnail( stop_flag, &file_entry.path, file_entry.size, file_entry.modified_date, file_entry.duration, &thumbnails_dir, thumbnail_video_percentage_from_start, generate_grid_instead_of_single, thumbnail_grid_tiles_per_side, ) { Ok(thumbnail_path) => { file_entry.thumbnail_path = Some(thumbnail_path); } Err(e) => errs.push(e), } progress_handler.increase_items(1); } errs }) .flatten() .collect::>(); self.common_data.text_messages.warnings.extend(errors); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "save_cache", level = "debug")] fn save_cache(&mut self, vec_file_entry: &[VideosEntry], loaded_hash_map: BTreeMap) { save_and_connect_cache_generalized_by_path( &get_similar_videos_cache_file(self.params.skip_forward_amount, self.params.duration, self.params.crop_detect), vec_file_entry, loaded_hash_map, self, ); } #[fun_time(message = "load_cache_at_start", level = "debug")] fn load_cache_at_start(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { load_and_split_cache_generalized_by_path( &get_similar_videos_cache_file(self.params.skip_forward_amount, self.params.duration, self.params.crop_detect), mem::take(&mut self.videos_to_check), self, ) } #[fun_time(message = "match_groups_of_videos", level = "debug")] fn match_groups_of_videos(&mut self, vector_of_hashes: Vec, hashmap_with_file_entries: &IndexMap) { // Tolerance in library is a value between 0 and 1 // Tolerance in this app is a value between 0 and 20 // Default tolerance in library is 0.30 // We need to allow to set value in range 0 - 0.5 let match_group = vid_dup_finder_lib::search(vector_of_hashes, self.get_params().tolerance as f64 / 40.0f64); let mut collected_similar_videos: Vec> = Default::default(); for i in match_group { let mut temp_vector: Vec = Vec::new(); let mut bt_size: BTreeSet = Default::default(); for j in i.duplicates() { let file_entry = &hashmap_with_file_entries[&j.to_string_lossy().to_string()]; if self.get_params().exclude_videos_with_same_size { if bt_size.insert(file_entry.size) { temp_vector.push(file_entry.clone()); } } else { temp_vector.push(file_entry.clone()); } } if temp_vector.len() > 1 { collected_similar_videos.push(temp_vector); } } self.similar_vectors = collected_similar_videos; } #[fun_time(message = "remove_from_reference_folders", level = "debug")] fn remove_from_reference_folders(&mut self) { if self.common_data.use_reference_folders { self.similar_referenced_vectors = mem::take(&mut self.similar_vectors) .into_iter() .filter_map(|vec_file_entry| { let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry .into_iter() .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); if normal_files.is_empty() { None } else { files_from_referenced_folders.pop().map(|file| (file, normal_files)) } }) .collect::)>>(); } } } pub fn get_similar_videos_cache_file(skip_forward_amount: u32, duration: u32, crop_detect: Cropdetect) -> String { let crop_detect_str = match crop_detect { Cropdetect::None => "none", Cropdetect::Letterbox => "letterbox", Cropdetect::Motion => "motion", }; format!("cache_similar_videos_{CACHE_VIDEO_VERSION}__skip_{skip_forward_amount}__dur_{duration}__cd_{crop_detect_str}.bin") } pub fn format_bitrate_opt(bitrate: Option) -> String { match bitrate { Some(b) => { if b >= 1_000_000 { format!("{:.1} Mbps", b as f64 / 1_000_000.0) } else if b >= 1000 { format!("{:.0} kbps", b as f64 / 1000.0) } else { format!("{b} bps") } } None => String::from(""), } } pub fn format_duration_opt(duration: Option) -> String { duration .map(|d| { let hours = (d / 3600.0) as u32; let minutes = ((d % 3600.0) / 60.0) as u32; let seconds = (d % 60.0) as u32; if hours > 0 { format!("{hours:02}:{minutes:02}:{seconds:02}") } else { format!("{minutes:02}:{seconds:02}") } }) .unwrap_or_default() } czkawka_core-11.0.1/src/tools/similar_videos/mod.rs000064400000000000000000000115511046102023000204370ustar 00000000000000pub mod core; pub mod traits; #[cfg(test)] mod tests; use std::collections::BTreeMap; use std::ops::RangeInclusive; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use vid_dup_finder_lib::{Cropdetect, VideoHash}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; pub const MAX_TOLERANCE: i32 = 20; pub const DEFAULT_CROP_DETECT: Cropdetect = Cropdetect::Letterbox; pub const ALLOWED_SKIP_FORWARD_AMOUNT: RangeInclusive = 0..=300; pub const DEFAULT_SKIP_FORWARD_AMOUNT: u32 = 15; pub const ALLOWED_VID_HASH_DURATION: RangeInclusive = 2..=60; pub const DEFAULT_VID_HASH_DURATION: u32 = 10; pub const DEFAULT_VIDEO_PERCENTAGE_FOR_THUMBNAIL: u8 = 10; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VideosEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, pub vhash: VideoHash, pub error: String, // Properties extracted from video pub fps: Option, pub codec: Option, pub bitrate: Option, pub width: Option, pub height: Option, pub duration: Option, #[serde(skip)] // Saving it to cache is bad idea, because cache can be moved to another locations pub thumbnail_path: Option, } impl ResultEntry for VideosEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_videos_entry(self) -> VideosEntry { VideosEntry { size: self.size, path: self.path, modified_date: self.modified_date, vhash: Default::default(), error: String::new(), fps: None, codec: None, bitrate: None, width: None, height: None, duration: None, thumbnail_path: None, } } } #[derive(Clone, Debug)] pub struct SimilarVideosParameters { pub tolerance: i32, pub exclude_videos_with_same_size: bool, pub skip_forward_amount: u32, pub duration: u32, pub crop_detect: Cropdetect, pub generate_thumbnails: bool, pub thumbnail_video_percentage_from_start: u8, pub generate_thumbnail_grid_instead_of_single: bool, pub thumbnail_grid_tiles_per_side: u8, } pub fn crop_detect_from_str_opt(s: &str) -> Option { match s.to_lowercase().as_str() { "none" => Some(Cropdetect::None), "letterbox" => Some(Cropdetect::Letterbox), "motion" => Some(Cropdetect::Motion), _ => None, } } impl SimilarVideosParameters { pub fn new( tolerance: i32, exclude_videos_with_same_size: bool, skip_forward_amount: u32, duration: u32, crop_detect: Cropdetect, generate_thumbnails: bool, thumbnail_video_percentage_from_start: u8, generate_thumbnail_grid_instead_of_single: bool, thumbnail_grid_tiles_per_side: u8, ) -> Self { assert!((0..=MAX_TOLERANCE).contains(&tolerance)); assert!(ALLOWED_SKIP_FORWARD_AMOUNT.contains(&skip_forward_amount)); assert!(ALLOWED_VID_HASH_DURATION.contains(&duration)); Self { tolerance, exclude_videos_with_same_size, skip_forward_amount, duration, crop_detect, generate_thumbnails, thumbnail_video_percentage_from_start, generate_thumbnail_grid_instead_of_single, thumbnail_grid_tiles_per_side, } } } pub struct SimilarVideos { common_data: CommonToolData, information: Info, similar_vectors: Vec>, similar_referenced_vectors: Vec<(VideosEntry, Vec)>, videos_hashes: BTreeMap, Vec>, videos_to_check: BTreeMap, params: SimilarVideosParameters, } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_duplicates: usize, pub number_of_groups: usize, pub scanning_time: Duration, } impl SimilarVideos { pub fn get_params(&self) -> &SimilarVideosParameters { &self.params } pub const fn get_similar_videos(&self) -> &Vec> { &self.similar_vectors } pub const fn get_information(&self) -> Info { self.information } pub fn get_similar_videos_referenced(&self) -> &Vec<(VideosEntry, Vec)> { &self.similar_referenced_vectors } pub fn get_number_of_base_duplicated_files(&self) -> usize { if self.common_data.use_reference_folders { self.similar_referenced_vectors.len() } else { self.similar_vectors.len() } } pub fn get_use_reference(&self) -> bool { self.common_data.use_reference_folders } } czkawka_core-11.0.1/src/tools/similar_videos/tests.rs000064400000000000000000000022601046102023000210170ustar 00000000000000use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::TempDir; use vid_dup_finder_lib::Cropdetect; use crate::common::tool_data::CommonData; use crate::common::traits::Search; use crate::tools::similar_videos::{SimilarVideos, SimilarVideosParameters}; // Tests are quite limited here, due to the needing of external ffmpeg libraries and video files. // Just tested is that searching in an empty directory works as expected - no found similar videos #[test] fn test_similar_videos_empty_directory() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path(); let params = SimilarVideosParameters::new(10, false, 15, 10, Cropdetect::Letterbox, false, 0, false, 2); let mut finder = SimilarVideos::new(params); finder.set_included_paths(vec![path.to_path_buf()]); finder.set_recursive_search(true); finder.set_use_cache(false); let stop_flag = Arc::new(AtomicBool::new(false)); finder.search(&stop_flag, None); let info = finder.get_information(); assert_eq!(info.number_of_duplicates, 0, "Should find no duplicates in empty directory"); assert_eq!(info.number_of_groups, 0, "Should find no groups in empty directory"); } czkawka_core-11.0.1/src/tools/similar_videos/traits.rs000064400000000000000000000152201046102023000211630ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use crate::common::consts::VIDEO_FILES_EXTENSIONS; use crate::common::ffmpeg_utils::check_if_ffprobe_ffmpeg_exists; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::flc; use crate::tools::similar_videos::core::{format_bitrate_opt, format_duration_opt}; use crate::tools::similar_videos::{Info, SimilarVideos, SimilarVideosParameters}; impl AllTraits for SimilarVideos {} impl Search for SimilarVideos { #[fun_time(message = "find_similar_videos", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if !check_if_ffprobe_ffmpeg_exists() { self.common_data.text_messages.critical = Some(flc!("core_ffmpeg_not_found")); #[cfg(target_os = "windows")] self.common_data.text_messages.errors.push(flc!("core_ffmpeg_not_found_windows")); return; } if self.prepare_items(Some(VIDEO_FILES_EXTENSIONS)).is_err() { return; } self.common_data.use_reference_folders = !self.common_data.directories.reference_directories.is_empty() || !self.common_data.directories.reference_files.is_empty(); if self.check_for_similar_videos(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.sort_videos(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DeletingItems for SimilarVideos { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.get_cd().delete_method == DeleteMethod::None { return WorkContinueStatus::Continue; } let files_to_delete = self.similar_vectors.clone(); self.delete_advanced_elements_and_add_to_messages(stop_flag, progress_sender, files_to_delete) } } impl DebugPrint for SimilarVideos { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("---------------DEBUG PRINT---------------"); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for SimilarVideos { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; fn write_video_entry(writer: &mut T, file_entry: &crate::tools::similar_videos::VideosEntry) -> std::io::Result<()> { let bitrate = format_bitrate_opt(file_entry.bitrate); let fps = file_entry.fps.map(|e| format!("{e:.2}")).unwrap_or_default(); let codec = file_entry.codec.clone().unwrap_or_default(); let dimensions = if let (Some(w), Some(h)) = (file_entry.width, file_entry.height) { format!("{w}x{h}") } else { "".to_string() }; let duration = format_duration_opt(file_entry.duration); writeln!( writer, "\"{}\" - {} - {} - {} - {} - {} - {}", file_entry.path.to_string_lossy(), format_size(file_entry.size, BINARY), bitrate, fps, codec, dimensions, duration ) } if !self.similar_vectors.is_empty() { write!(writer, "{} videos which have similar friends\n\n", self.similar_vectors.len())?; for struct_similar in &self.similar_vectors { writeln!( writer, "Found {} videos which have similar friends (path, size, bitrate, fps, codec, dimensions, duration)", struct_similar.len() )?; for file_entry in struct_similar { write_video_entry(writer, file_entry)?; } writeln!(writer)?; } } else if !self.similar_referenced_vectors.is_empty() { write!( writer, "{} videos which have similar friends (path, size, bitrate, fps, codec, dimensions, duration)\n\n", self.similar_referenced_vectors.len() )?; for (fe, struct_similar) in &self.similar_referenced_vectors { writeln!(writer, "Found {} videos which have similar friends", struct_similar.len())?; writeln!(writer)?; write_video_entry(writer, fe)?; for file_entry in struct_similar { write_video_entry(writer, file_entry)?; } writeln!(writer)?; } } else { write!(writer, "Not found any similar videos.")?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { if self.get_use_reference() { self.save_results_to_file_as_json_internal(file_name, &self.similar_referenced_vectors, pretty_print) } else { self.save_results_to_file_as_json_internal(file_name, &self.similar_vectors, pretty_print) } } } impl CommonData for SimilarVideos { type Info = Info; type Parameters = SimilarVideosParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_duplicates > 0 } } czkawka_core-11.0.1/src/tools/temporary/core.rs000064400000000000000000000134541046102023000176250ustar 00000000000000use std::fs::DirEntry; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use crossbeam_channel::Sender; use fun_time::fun_time; use rayon::prelude::*; use crate::common::dir_traversal::{common_read_dir, get_modified_time}; use crate::common::directories::Directories; use crate::common::items::ExcludedItems; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::tools::temporary::{Info, TEMP_EXTENSIONS, Temporary, TemporaryFileEntry}; impl Temporary { pub fn new() -> Self { Self { common_data: CommonToolData::new(ToolType::TemporaryFiles), information: Info::default(), temporary_files: Vec::new(), } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let mut folders_to_check: Vec = self.common_data.directories.included_directories.clone(); let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::CollectingFiles, 0, self.get_test_type(), 0); while !folders_to_check.is_empty() { if check_if_stop_received(stop_flag) { progress_handler.join_thread(); return WorkContinueStatus::Stop; } let segments: Vec<_> = folders_to_check .into_par_iter() .map(|current_folder| { let mut dir_result = Vec::new(); let mut warnings = Vec::new(); let mut fe_result = Vec::new(); let Some(read_dir) = common_read_dir(¤t_folder, &mut warnings) else { return (dir_result, warnings, fe_result); }; // Check every sub folder/file/link etc. for entry in read_dir { let Ok(entry_data) = entry else { continue; }; let Ok(file_type) = entry_data.file_type() else { continue; }; if file_type.is_dir() { check_folder_children( &mut dir_result, &mut warnings, &entry_data, self.common_data.recursive_search, &self.common_data.directories, &self.common_data.excluded_items, ); } else if file_type.is_file() && let Some(file_entry) = self.get_file_entry(progress_handler.items_counter(), &entry_data, &mut warnings) { fe_result.push(file_entry); } } (dir_result, warnings, fe_result) }) .collect(); let required_size = segments.iter().map(|(segment, _, _)| segment.len()).sum::(); folders_to_check = Vec::with_capacity(required_size); // Process collected data for (segment, warnings, fe_result) in segments { folders_to_check.extend(segment); self.common_data.text_messages.warnings.extend(warnings); for fe in fe_result { self.temporary_files.push(fe); } } } progress_handler.join_thread(); self.information.number_of_temporary_files = self.temporary_files.len(); WorkContinueStatus::Continue } pub(crate) fn get_file_entry(&self, items_counter: &Arc, entry_data: &DirEntry, warnings: &mut Vec) -> Option { items_counter.fetch_add(1, Ordering::Relaxed); let current_file_name = entry_data.path(); if self.common_data.excluded_items.is_excluded(¤t_file_name) { return None; } let file_name = entry_data.file_name(); let file_name_ascii_lowercase = file_name.to_ascii_lowercase(); let file_name_lowercase = file_name_ascii_lowercase.to_string_lossy(); if !TEMP_EXTENSIONS.iter().any(|f| file_name_lowercase.ends_with(f)) { return None; } let Ok(metadata) = entry_data.metadata() else { return None; }; // Creating new file entry Some(TemporaryFileEntry { modified_date: get_modified_time(&metadata, warnings, ¤t_file_name, false), size: metadata.len(), path: current_file_name, }) } } pub(crate) fn check_folder_children( dir_result: &mut Vec, warnings: &mut Vec, entry_data: &DirEntry, recursive_search: bool, directories: &Directories, excluded_items: &ExcludedItems, ) { if !recursive_search { return; } let next_item = entry_data.path(); if directories.is_excluded_dir(&next_item) { return; } if excluded_items.is_excluded(&next_item) { return; } #[cfg(target_family = "unix")] if directories.exclude_other_filesystems() { match directories.is_on_other_filesystems(&next_item) { Ok(true) => return, Err(e) => warnings.push(e), _ => (), } } #[cfg(target_family = "windows")] let _ = warnings; // Silence unused variable warning on Windows dir_result.push(next_item); } czkawka_core-11.0.1/src/tools/temporary/mod.rs000064400000000000000000000025011046102023000174430ustar 00000000000000pub mod core; pub mod traits; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::Serialize; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; const TEMP_EXTENSIONS: &[&str] = &[ "#", "thumbs.db", ".bak", "~", ".tmp", ".temp", ".ds_store", ".crdownload", ".part", ".cache", ".dmp", ".download", ".partial", ]; #[derive(Clone, Serialize, Debug)] pub struct TemporaryFileEntry { pub path: PathBuf, pub modified_date: u64, pub size: u64, } impl ResultEntry for TemporaryFileEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } #[derive(Default, Clone, Copy)] pub struct Info { pub number_of_temporary_files: usize, pub scanning_time: Duration, } pub struct Temporary { common_data: CommonToolData, information: Info, temporary_files: Vec, } impl Default for Temporary { fn default() -> Self { Self::new() } } impl Temporary { pub const fn get_temporary_files(&self) -> &Vec { &self.temporary_files } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/temporary/traits.rs000064400000000000000000000064641046102023000202060ustar 00000000000000use std::io::prelude::*; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData, DeleteItemType, DeleteMethod}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, PrintResults, Search}; use crate::tools::temporary::{Info, Temporary}; impl AllTraits for Temporary {} impl Search for Temporary { #[fun_time(message = "find_temporary_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if self.prepare_items(None).is_err() { return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.delete_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl DeletingItems for Temporary { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.get_cd().delete_method == DeleteMethod::None { return WorkContinueStatus::Continue; } let files_to_delete = self.temporary_files.clone(); self.delete_simple_elements_and_add_to_messages(stop_flag, progress_sender, DeleteItemType::DeletingFiles(files_to_delete)) } } impl PrintResults for Temporary { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; writeln!(writer, "Found {} temporary files.\n", self.information.number_of_temporary_files)?; for file_entry in &self.temporary_files { writeln!(writer, "\"{}\"", file_entry.path.to_string_lossy())?; } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { self.save_results_to_file_as_json_internal(file_name, &self.temporary_files, pretty_print) } } impl CommonData for Temporary { type Info = Info; type Parameters = (); fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters {} fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { self.information.number_of_temporary_files > 0 } } impl DebugPrint for Temporary { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("### Information's"); println!("Temporary list size - {}", self.temporary_files.len()); self.debug_print_common(); } } czkawka_core-11.0.1/src/tools/video_optimizer/core/video_converter.rs000064400000000000000000000105021046102023000241770ustar 00000000000000use std::fs; use std::path::Path; use std::process::Command; use std::sync::Arc; use std::sync::atomic::AtomicBool; use log::error; use crate::common::process_utils::run_command_interruptible; use crate::common::video_utils::VideoMetadata; use crate::flc; use crate::tools::video_optimizer::{VideoTranscodeEntry, VideoTranscodeFixParams}; pub fn check_video(mut entry: VideoTranscodeEntry) -> VideoTranscodeEntry { let metadata = match VideoMetadata::from_path(&entry.path) { Ok(metadata) => metadata, Err(e) => { entry.error = Some(flc!("core_failed_to_get_video_metadata", file = entry.path.to_string_lossy(), reason = e)); return entry; } }; let Some(current_codec) = metadata.codec.clone() else { entry.error = Some(flc!("core_failed_to_get_video_codec", file = entry.path.to_string_lossy())); return entry; }; let Some(duration) = metadata.duration else { entry.error = Some(flc!("core_failed_to_get_video_duration", file = entry.path.to_string_lossy())); return entry; }; entry.codec = current_codec; entry.duration = duration; match (metadata.width, metadata.height) { (Some(width), Some(height)) => { entry.width = width; entry.height = height; } _ => { entry.error = Some(flc!("core_failed_to_get_video_dimensions", file = entry.path.to_string_lossy())); return entry; } } entry } pub fn process_video(stop_flag: &Arc, video_path: &str, original_size: u64, params: VideoTranscodeFixParams) -> Result<(), String> { let temp_output = Path::new(video_path).with_extension("czkawka_optimized.mp4"); let mut command = Command::new("ffmpeg"); command .arg("-i") .arg(video_path) .arg("-nostdin") .arg("-c:v") .arg(params.codec.as_str()) .arg("-crf") .arg(params.quality.to_string()); if params.limit_video_size { let scale_filter = format!("scale='min({},iw):min({},ih):force_original_aspect_ratio=decrease'", params.max_width, params.max_height); command.arg("-vf").arg(scale_filter); } command.arg("-c:a").arg("copy").arg("-y").arg(&temp_output); match run_command_interruptible(command, stop_flag) { None => { let _ = fs::remove_file(&temp_output); return Err(flc!("core_video_processing_stopped_by_user")); } Some(Err(e)) => { let _ = fs::remove_file(&temp_output); return Err(flc!("core_failed_to_process_video", file = video_path, reason = e)); } Some(Ok(output)) => { if !output.status.success() { let connected = format!("{} - {}", output.stdout, output.stderr); if connected.to_lowercase().contains("unknown encoder") { return Err(flc!("core_ffmpeg_unknown_encoder", file = video_path, encoder = params.codec.as_ffprobe_codec_name())); } error!( "FFmpeg failed to transcode video \"{}\" with status {}. Stdout: {}, Stderr: {}", video_path, output.status, output.stdout, output.stderr ); return Err(flc!("core_ffmpeg_error", file = video_path, code = output.status.to_string(), reason = output.stderr)); } } } let metadata = fs::metadata(&temp_output).map_err(|e| { let _ = fs::remove_file(&temp_output); flc!( "core_failed_to_get_metadata_of_optimized_file", file = temp_output.to_string_lossy(), reason = e.to_string() ) })?; let new_size = metadata.len(); if params.fail_if_not_smaller && new_size >= original_size { let _ = fs::remove_file(&temp_output); return Err(flc!( "core_optimized_file_larger", optimized = temp_output.to_string_lossy(), new_size = new_size, original = video_path, original_size = original_size )); } if params.overwrite_original { fs::rename(&temp_output, video_path).map_err(|e| { let _ = fs::remove_file(&temp_output); flc!("core_failed_to_replace_with_optimized", file = video_path, reason = e.to_string()) })?; return Ok(()); } Ok(()) } czkawka_core-11.0.1/src/tools/video_optimizer/core/video_cropper.rs000064400000000000000000000660001046102023000236460ustar 00000000000000use std::path::Path; use std::process::Command; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use image::RgbImage; use log::error; use crate::common::consts::VIDEO_RESOLUTION_LIMIT; use crate::common::process_utils::run_command_interruptible; use crate::common::video_utils::{VideoMetadata, extract_frame_ffmpeg}; use crate::flc; use crate::tools::video_optimizer::{VideoCropEntry, VideoCropParams, VideoCropSingleFixParams, VideoCroppingMechanism}; const MIN_SAMPLES: usize = 3; const MIN_SAMPLE_INTERVAL: f32 = 0.1; #[derive(Debug, Clone, Copy, PartialEq)] struct Rectangle { top: u32, bottom: u32, left: u32, right: u32, } impl Rectangle { fn new(top: u32, bottom: u32, left: u32, right: u32) -> Self { let s = Self { top, bottom, left, right }; s.validate(); s } fn union(&self, other: &Self) -> Self { let s = Self { top: self.top.min(other.top), bottom: self.bottom.max(other.bottom), left: self.left.min(other.left), right: self.right.max(other.right), }; s.validate(); s } fn validate(&self) { assert!( self.left <= self.right && self.top <= self.bottom, "Invalid rectangle coordinates: top={}, bottom={}, left={}, right={}. Expected: left <= right && top <= bottom (critical algorithm error, please report an issue)", self.top, self.bottom, self.left, self.right ); } fn validate_image_size(&self, width: u32, height: u32) { assert!( self.right <= width && self.bottom <= height, "Rectangle exceeds image dimensions: image_width={}, image_height={}, rectangle_right={}, rectangle_bottom={}. Expected: right <= image_width && bottom <= image_height (critical algorithm error, please report an issue)", width, height, self.right, self.bottom ); } fn is_cropping_needed(&self, width: u32, height: u32, min_crop_size: u32) -> bool { let right_margin = width - self.right; let bottom_margin = height - self.bottom; self.left > min_crop_size || right_margin > min_crop_size || self.top > min_crop_size || bottom_margin > min_crop_size } } fn is_pixel_black(img: &image::RgbImage, x: u32, y: u32, black_pixel_threshold: u8) -> bool { let pixel = img.get_pixel(x, y); pixel.0.iter().all(|&channel| channel <= black_pixel_threshold) } #[derive(Debug)] enum BlackBarResult { NoBlackBars, BlackBarsDetected(Rectangle), FullBlackImage, } fn detect_black_bars(rgb_img: &RgbImage, params: &VideoCropParams) -> BlackBarResult { let (width, height) = rgb_img.dimensions(); let min_percentage = params.black_bar_min_percentage as f32 / 100.0; let mut left_crop = 0u32; for x in 0..width { let black_pixels = (0..height).filter(|&y| is_pixel_black(rgb_img, x, y, params.black_pixel_threshold)).count(); if (black_pixels as f32 / height as f32) < min_percentage { break; } left_crop = x + 1; } let mut right_pos = width; for x in (0..width).rev() { let black_pixels = (0..height).filter(|&y| is_pixel_black(rgb_img, x, y, params.black_pixel_threshold)).count(); if (black_pixels as f32 / height as f32) < min_percentage { right_pos = x + 1; break; } } if left_crop >= right_pos { return BlackBarResult::FullBlackImage; } let mut top_crop = 0u32; for y in 0..height { let black_pixels = (0..width).filter(|&x| is_pixel_black(rgb_img, x, y, params.black_pixel_threshold)).count(); if (black_pixels as f32 / width as f32) < min_percentage { break; } top_crop = y + 1; } let mut bottom_pos = height; for y in (0..height).rev() { let black_pixels = (0..width).filter(|&x| is_pixel_black(rgb_img, x, y, params.black_pixel_threshold)).count(); if (black_pixels as f32 / width as f32) < min_percentage { bottom_pos = y + 1; break; } } if top_crop >= bottom_pos { return BlackBarResult::FullBlackImage; } let rect = Rectangle::new(top_crop, bottom_pos, left_crop, right_pos); if rect.is_cropping_needed(width, height, params.min_crop_size) { BlackBarResult::BlackBarsDetected(rect) } else { BlackBarResult::NoBlackBars } } fn analyze_black_bars( duration: f32, get_frame: &F, stop_flag: &Arc, first_frame: &RgbImage, params: &VideoCropParams, path: &Path, ) -> Option, String>> where F: Fn(f32) -> Result, { if stop_flag.load(Ordering::Relaxed) { return None; } let mut rectangle = match detect_black_bars(first_frame, params) { BlackBarResult::BlackBarsDetected(rect) => Some(rect), BlackBarResult::NoBlackBars => { return Some(Ok(None)); } BlackBarResult::FullBlackImage => None, }; let num_samples = ((duration / MIN_SAMPLE_INTERVAL).floor() as usize).clamp(MIN_SAMPLES, params.max_samples); for i in 1..num_samples { if stop_flag.load(Ordering::Relaxed) { return None; } let timestamp = (i as f32 / num_samples as f32) * duration; let tmp_frame = match get_frame(timestamp) { Ok(frame) => frame, Err(e) => { return Some(Err(flc!( "core_failed_get_frame_at_timestamp", file = path.to_string_lossy().to_string(), timestamp = timestamp, reason = e ))); } }; if tmp_frame.dimensions() != first_frame.dimensions() { return Some(Err(flc!( "core_frame_dimensions_mismatch", timestamp = timestamp, first_w = first_frame.width(), first_h = first_frame.height() ))); } match detect_black_bars(&tmp_frame, params) { BlackBarResult::BlackBarsDetected(tmp_rect) => { rectangle = match rectangle { Some(current_rect) => Some(current_rect.union(&tmp_rect)), None => Some(tmp_rect), }; } BlackBarResult::NoBlackBars => { return Some(Ok(None)); } BlackBarResult::FullBlackImage => { // Do nothing - leave the current rectangle as is } } } if let Some(rectangle) = rectangle { rectangle.validate(); // Rectangle may extend step by step to full image size, so that is why previous checks are not enough if !rectangle.is_cropping_needed(first_frame.width(), first_frame.height(), params.min_crop_size) { return Some(Ok(None)); } Some(Ok(Some(rectangle))) } else { Some(Ok(None)) // All frames were fully black } } fn diff_between_dynamic_images(img_original: &RgbImage, mut consumed_temp_img: RgbImage) -> RgbImage { assert_eq!( img_original.dimensions(), consumed_temp_img.dimensions(), "Image dimensions do not match for diffing (critical algorithm error, please report an issue)" ); img_original.pixels().zip(consumed_temp_img.pixels_mut()).for_each(|(img_original_pixel, consumed_pixel)| { consumed_pixel .0 .iter_mut() .zip(img_original_pixel.0.iter()) .for_each(|(consumed_channel, &original_channel)| { *consumed_channel = original_channel.abs_diff(*consumed_channel); }); }); consumed_temp_img } fn analyze_static_image_parts( duration: f32, get_frame: &F, stop_flag: &Arc, first_frame: &RgbImage, params: &VideoCropParams, path: &Path, ) -> Option, String>> where F: Fn(f32) -> Result, { if stop_flag.load(Ordering::Relaxed) { return None; } // Initial rectangle is empty, because with only one frame we cannot determine static parts let mut rectangle: Option = None; let num_samples = ((duration / MIN_SAMPLE_INTERVAL).floor() as usize).clamp(MIN_SAMPLES, params.max_samples); for i in 1..num_samples { if stop_flag.load(Ordering::Relaxed) { return None; } let timestamp = (i as f32 / num_samples as f32) * duration; let tmp_frame = match get_frame(timestamp) { Ok(frame) => frame, Err(e) => { return Some(Err(flc!( "core_failed_get_frame_from_file", file = path.to_string_lossy().to_string(), timestamp = timestamp, reason = e ))); } }; if tmp_frame.dimensions() != first_frame.dimensions() { return Some(Err(flc!( "core_frame_dimensions_mismatch", timestamp = timestamp, first_w = first_frame.width(), first_h = first_frame.height() ))); } let dynamic_image_diff: RgbImage = diff_between_dynamic_images(first_frame, tmp_frame); match detect_black_bars(&dynamic_image_diff, params) { BlackBarResult::FullBlackImage => { // Do nothing - leave the current rectangle as is } BlackBarResult::NoBlackBars => { return Some(Ok(None)); } BlackBarResult::BlackBarsDetected(tmp_rect) => { rectangle = match rectangle { Some(current_rect) => Some(current_rect.union(&tmp_rect)), None => Some(tmp_rect), }; } } } if let Some(rectangle) = rectangle { rectangle.validate(); // Rectangle may extend step by step to full image size, so that is why previous checks are not enough if !rectangle.is_cropping_needed(first_frame.width(), first_frame.height(), params.min_crop_size) { return Some(Ok(None)); } Some(Ok(Some(rectangle))) } else { Some(Ok(None)) // All frames were fully static } } fn extract_video_metadata_for_crop(entry: &mut VideoCropEntry) -> Result<(u32, u32, f64, f64), ()> { let metadata = match VideoMetadata::from_path(&entry.path) { Ok(metadata) => metadata, Err(e) => { entry.error = Some(format!("Failed to get video metadata for file \"{}\": {}", entry.path.to_string_lossy(), e)); return Err(()); } }; let Some(current_codec) = metadata.codec.clone() else { entry.error = Some(format!("Failed to get video codec from metadata for file \"{}\"", entry.path.to_string_lossy())); return Err(()); }; entry.codec = current_codec; let (width, height) = match (metadata.width, metadata.height) { (Some(width), Some(height)) => { entry.width = width; entry.height = height; (width, height) } _ => { entry.error = Some(format!("Failed to get video dimensions from metadata for file \"{}\"", entry.path.to_string_lossy())); return Err(()); } }; let Some(duration) = metadata.duration else { entry.error = Some(format!("Failed to get video duration from metadata, for file \"{}\"", entry.path.to_string_lossy())); return Err(()); }; entry.duration = duration; let fps = metadata.fps.unwrap_or(25.0); Ok((width, height, duration, fps)) } pub fn check_video_crop(mut entry: VideoCropEntry, params: &VideoCropParams, stop_flag: &Arc) -> Option { let Ok((_width, _height, duration, _fps)) = extract_video_metadata_for_crop(&mut entry) else { return Some(entry); }; let video_path = entry.path.clone(); let get_frame = |timestamp: f32| -> Result { extract_frame_ffmpeg(&video_path, timestamp, None) }; // TODO - metadata are broken? Not proper? // Metadata shows different dimensions than actual frames extracted - quite strange, probably rotated data - let first_frame = match get_frame(0.0) { Ok(frame) => frame, Err(e) => { entry.error = Some(format!("Failed to extract first frame for video \"{}\": {}", entry.path.to_string_lossy(), e)); return Some(entry); } }; let (width, height) = first_frame.dimensions(); entry.height = height; entry.width = width; if entry.width > VIDEO_RESOLUTION_LIMIT || entry.height > VIDEO_RESOLUTION_LIMIT { entry.error = Some(format!( "Image dimensions for video \"{}\" exceed the limit: {}x{} > {}x{}", entry.path.to_string_lossy(), entry.width, entry.height, VIDEO_RESOLUTION_LIMIT, VIDEO_RESOLUTION_LIMIT )); return Some(entry); } match params.crop_detect { VideoCroppingMechanism::BlackBars => match analyze_black_bars(duration as f32, &get_frame, stop_flag, &first_frame, params, &entry.path) { Some(Ok(Some(rectangle))) => { rectangle.validate_image_size(width, height); entry.new_image_dimensions = (rectangle.left, rectangle.top, rectangle.right, rectangle.bottom); } Some(Ok(None)) => { // No black bars } Some(Err(e)) => { entry.error = Some(e); return Some(entry); } None => return None, }, VideoCroppingMechanism::StaticContent => match analyze_static_image_parts(duration as f32, &get_frame, stop_flag, &first_frame, params, &entry.path) { Some(Ok(Some(rectangle))) => { rectangle.validate_image_size(width, height); entry.new_image_dimensions = (rectangle.left, rectangle.top, rectangle.right, rectangle.bottom); } Some(Ok(None)) => {} Some(Err(e)) => { entry.error = Some(e); return Some(entry); } None => return None, }, } Some(entry) } pub fn fix_video_crop(video_path: &Path, params: &VideoCropSingleFixParams, stop_flag: &Arc, current_codec: &str) -> Result<(), String> { if stop_flag.load(Ordering::Relaxed) { return Err("Video processing was stopped by user".to_string()); } let (left, top, right, bottom) = params.crop_rectangle; if left >= right || top >= bottom { return Err(flc!("core_invalid_crop_rectangle", left = left, top = top, right = right, bottom = bottom)); } let crop_width = right - left; let crop_height = bottom - top; let crop_type_suffix = match params.crop_mechanism { VideoCroppingMechanism::BlackBars => "blackbars", VideoCroppingMechanism::StaticContent => "staticcontent", }; let extension = video_path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); let temp_output = video_path.with_extension(format!("czkawka_cropped_{crop_type_suffix}.{extension}")); let mut command = Command::new("ffmpeg"); command.arg("-i").arg(video_path).arg("-vf").arg(format!("crop={crop_width}:{crop_height}:{left}:{top}")); match (params.target_codec, params.quality) { (None, None) => { // Do nothing, do not convert video to different codec } (Some(target_codec), Some(quality)) => { command.arg("-c:v").arg(target_codec.as_str()).arg("-crf").arg(quality.to_string()); } _ => { return Err("Both target_codec and quality must be specified together".to_string()); } } command.arg("-c:a").arg("copy"); command.arg("-y").arg(&temp_output); match run_command_interruptible(command, stop_flag) { None => { let _ = std::fs::remove_file(&temp_output); return Err(String::from("Video cropping was stopped by user")); } Some(Err(e)) => { let _ = std::fs::remove_file(&temp_output); return Err(flc!("core_failed_to_crop_video_file", file = video_path.to_string_lossy(), reason = e)); } Some(Ok(output)) => { if !output.status.success() { let connected = format!("{} - {}", output.stdout, output.stderr); if connected.to_lowercase().contains("unknown encoder") { let missing_codec = match params.target_codec { Some(target_codec) => target_codec.as_ffprobe_codec_name(), None => current_codec, }; return Err(flc!("core_ffmpeg_unknown_encoder", file = video_path.to_string_lossy(), encoder = missing_codec)); } error!( "FFmpeg failed to crop video \"{}\" with status {}. Stdout: {}, Stderr: {}", video_path.to_string_lossy(), output.status, output.stdout, output.stderr ); return Err(flc!( "core_ffmpeg_error", file = video_path.to_string_lossy(), code = output.status.to_string(), reason = output.stderr )); } } } if !temp_output.exists() { error!("Cropped video file was not created: {temp_output:?}"); return Err(flc!("core_cropped_video_not_created", temp = format!("{:?}", temp_output))); } if params.overwrite_original { std::fs::rename(&temp_output, video_path).map_err(|e| format!("Failed to replace original file: {e}"))?; } Ok(()) } #[cfg(test)] mod tests { use std::sync::Arc; use std::sync::atomic::AtomicBool; use image::RgbImage; use super::*; fn default_test_params() -> VideoCropParams { VideoCropParams { crop_detect: VideoCroppingMechanism::BlackBars, black_pixel_threshold: 20, black_bar_min_percentage: 90, max_samples: 60, min_crop_size: 5, generate_thumbnails: false, thumbnail_video_percentage_from_start: 0, generate_thumbnail_grid_instead_of_single: false, thumbnail_grid_tiles_per_side: 2, } } fn create_colored_frame(width: u32, height: u32, r: u8, g: u8, b: u8) -> RgbImage { let mut img = RgbImage::new(width, height); for pixel in img.pixels_mut() { *pixel = image::Rgb([r, g, b]); } img } fn create_frame_with_black_bars(width: u32, height: u32, bar_size: u32) -> RgbImage { let mut img = RgbImage::new(width, height); for (x, y, pixel) in img.enumerate_pixels_mut() { if x < bar_size || x >= width - bar_size || y < bar_size || y >= height - bar_size { *pixel = image::Rgb([0, 0, 0]); } else { *pixel = image::Rgb([100, 150, 200]); } } img } #[test] fn test_is_pixel_black() { let params = default_test_params(); let black_img = RgbImage::from_pixel(10, 10, image::Rgb([0, 0, 0])); assert!(is_pixel_black(&black_img, 5, 5, params.black_pixel_threshold)); let light_gray_img = RgbImage::from_pixel(10, 10, image::Rgb([20, 20, 20])); assert!(is_pixel_black(&light_gray_img, 5, 5, params.black_pixel_threshold)); let dark_gray_img = RgbImage::from_pixel(10, 10, image::Rgb([21, 21, 21])); assert!(!is_pixel_black(&dark_gray_img, 5, 5, params.black_pixel_threshold)); let white_img = RgbImage::from_pixel(10, 10, image::Rgb([255, 255, 255])); assert!(!is_pixel_black(&white_img, 5, 5, params.black_pixel_threshold)); } #[test] fn test_detect_black_bars_no_bars() { let params = default_test_params(); let img = create_colored_frame(100, 100, 100, 150, 200); let result = detect_black_bars(&img, ¶ms); assert!(matches!(result, BlackBarResult::NoBlackBars)); } #[test] fn test_detect_black_bars_with_bars() { let params = default_test_params(); let img = create_frame_with_black_bars(200, 200, 20); let result = detect_black_bars(&img, ¶ms); if let BlackBarResult::BlackBarsDetected(rect) = result { assert!(rect.left >= 15 && rect.left <= 25, "Left crop: {}", rect.left); assert!(rect.top >= 15 && rect.top <= 25, "Top crop: {}", rect.top); assert!(rect.right >= 175 && rect.right <= 185, "Right position: {}", rect.right); assert!(rect.bottom >= 175 && rect.bottom <= 185, "Bottom position: {}", rect.bottom); } else { panic!("Expected BlackBarsDetected, got {result:?}"); } } #[test] fn test_detect_black_bars_small_bars() { let params = default_test_params(); let img = create_frame_with_black_bars(200, 200, 3); let result = detect_black_bars(&img, ¶ms); assert!(matches!(result, BlackBarResult::NoBlackBars)); } #[test] fn test_rectangle_union() { let rect1 = Rectangle::new(10, 10, 10, 10); let rect2 = Rectangle::new(5, 15, 8, 12); let union = rect1.union(&rect2); assert_eq!(union.top, 5); assert_eq!(union.bottom, 15); assert_eq!(union.left, 8); assert_eq!(union.right, 12); } #[test] fn test_rectangle_is_cropping_needed() { let params = default_test_params(); // Image 100x100, cropped to (10, 10) -> (90, 90), so 10px margin on each side let cropping_needed = Rectangle::new(10, 90, 10, 90); assert!(cropping_needed.is_cropping_needed(100, 100, params.min_crop_size)); // Image 100x100, no cropping: (0, 0) -> (100, 100) let no_cropping_needed = Rectangle::new(0, 100, 0, 100); assert!(!no_cropping_needed.is_cropping_needed(100, 100, params.min_crop_size)); // Image 100x100, small crop (3px on each side) - below threshold let small_crop = Rectangle::new(3, 97, 3, 97); assert!(!small_crop.is_cropping_needed(100, 100, params.min_crop_size)); } #[test] fn test_analyze_black_bars_consistent_bars() { let params = default_test_params(); let stop_flag = Arc::new(AtomicBool::new(false)); let duration = 10.0; let get_frame = |_timestamp: f32| -> Result { Ok(create_frame_with_black_bars(200, 200, 20)) }; let result = analyze_black_bars( duration, &get_frame, &stop_flag, &create_frame_with_black_bars(200, 200, 20), ¶ms, Path::new("text.txt"), ); assert!(result.expect("Expected Result").unwrap().is_some()); } #[test] fn test_analyze_black_bars_no_bars() { let params = default_test_params(); let stop_flag = Arc::new(AtomicBool::new(false)); let duration = 10.0; let get_frame = |_timestamp: f32| -> Result { Ok(create_colored_frame(200, 200, 100, 150, 200)) }; let result = analyze_black_bars( duration, &get_frame, &stop_flag, &create_colored_frame(200, 200, 100, 150, 200), ¶ms, Path::new("text.txt"), ); assert!(result.expect("Expected Result").unwrap().is_none()); } #[test] fn test_analyze_black_bars_inconsistent_bars() { let params = default_test_params(); let stop_flag = Arc::new(AtomicBool::new(false)); let duration = 10.0; let get_frame = |timestamp: f32| -> Result { if timestamp < 5.0 { Ok(create_frame_with_black_bars(200, 200, 20)) } else { Ok(create_colored_frame(200, 200, 100, 150, 200)) } }; let result = analyze_black_bars( duration, &get_frame, &stop_flag, &create_frame_with_black_bars(200, 200, 20), ¶ms, Path::new("text.txt"), ); assert!(result.expect("Expected Result").unwrap().is_none()); } #[test] fn test_analyze_black_bars_variable_rectangles() { let params = default_test_params(); let stop_flag = Arc::new(AtomicBool::new(false)); let duration = 10.0; let get_frame = |timestamp: f32| -> Result { if timestamp < 3.0 { Ok(create_frame_with_black_bars(200, 200, 20)) } else if timestamp < 7.0 { Ok(create_frame_with_black_bars(200, 200, 18)) } else { Ok(create_frame_with_black_bars(200, 200, 22)) } }; let result = analyze_black_bars( duration, &get_frame, &stop_flag, &create_frame_with_black_bars(200, 200, 20), ¶ms, Path::new("text.txt"), ); let rect = result.expect("Expected Result").unwrap().unwrap(); assert_eq!(rect.left, 18); assert_eq!(rect.top, 18); assert_eq!(rect.right, 200 - 18); assert_eq!(rect.bottom, 200 - 18); } #[test] fn test_detect_black_bars_fuzzer() { let params = default_test_params(); let test_cases = vec![ (1, 1, "1x1 image"), (1, 100, "1 pixel wide"), (100, 1, "1 pixel tall"), (2, 2, "2x2 minimum"), (10, 10, "10x10 small"), (100, 100, "100x100 medium"), (1920, 1080, "1920x1080 Full HD"), (3840, 2160, "3840x2160 4K"), ]; for (width, height, desc) in test_cases { // Test 1: All black image let mut all_black = RgbImage::new(width, height); for pixel in all_black.pixels_mut() { *pixel = image::Rgb([0, 0, 0]); } let result = detect_black_bars(&all_black, ¶ms); assert!(matches!(result, BlackBarResult::FullBlackImage), "All black image should return FullBlackImage for {desc}"); // Test 2: All white image let mut all_white = RgbImage::new(width, height); for pixel in all_white.pixels_mut() { *pixel = image::Rgb([255, 255, 255]); } let result = detect_black_bars(&all_white, ¶ms); assert!(matches!(result, BlackBarResult::NoBlackBars), "All white image should return NoBlackBars for {desc}"); // Test 4: Checkerboard pattern (no black bars) if width > 4 && height > 4 { let mut checkerboard = RgbImage::new(width, height); for (x, y, pixel) in checkerboard.enumerate_pixels_mut() { let color = if (x + y) % 2 == 0 { 255 } else { 0 }; *pixel = image::Rgb([color, color, color]); } let result = detect_black_bars(&checkerboard, ¶ms); assert!(matches!(result, BlackBarResult::NoBlackBars), "Checkerboard should return NoBlackBars for {desc}"); } } } } czkawka_core-11.0.1/src/tools/video_optimizer/core.rs000064400000000000000000000453021046102023000210100ustar 00000000000000use std::collections::BTreeMap; use std::mem; use std::sync::Arc; use std::sync::atomic::AtomicBool; use crossbeam_channel::Sender; use fun_time::fun_time; use log::{debug, info}; use rayon::prelude::*; use crate::common::cache::{load_and_split_cache_generalized_by_path, save_and_connect_cache_generalized_by_path}; use crate::common::config_cache_path::get_config_cache_path; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; use crate::common::model::{ToolType, WorkContinueStatus}; use crate::common::progress_data::{CurrentStage, ProgressData}; use crate::common::progress_stop_handler::{check_if_stop_received, prepare_thread_handler_common}; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::video_utils::{VIDEO_THUMBNAILS_SUBFOLDER, generate_thumbnail}; use crate::tools::video_optimizer::{ Info, VideoCropEntry, VideoCropParams, VideoCropSingleFixParams, VideoOptimizer, VideoOptimizerFixParams, VideoOptimizerParameters, VideoTranscodeEntry, VideoTranscodeParams, }; mod video_converter; mod video_cropper; pub use video_converter::process_video; pub use video_cropper::fix_video_crop; use crate::common::cache::CACHE_VIDEO_OPTIMIZE_VERSION; use crate::common::traits::ResultEntry; use crate::flc; impl VideoOptimizer { pub fn new(params: VideoOptimizerParameters) -> Self { Self { common_data: CommonToolData::new(ToolType::VideoOptimizer), information: Info::default(), video_transcode_test_entries: Default::default(), video_crop_test_entries: Default::default(), video_transcode_result_entries: Vec::new(), video_crop_result_entries: Vec::new(), params, } } #[fun_time(message = "scan_files", level = "debug")] pub(crate) fn scan_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { let result = DirTraversalBuilder::new() .group_by(|_fe| ()) .stop_flag(stop_flag) .progress_sender(progress_sender) .common_data(&self.common_data) .build() .run(); match result { DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { match &self.params { VideoOptimizerParameters::VideoTranscode(_) => { self.video_transcode_test_entries = grouped_file_entries .into_values() .flatten() .map(|fe| (fe.get_path().to_string_lossy().to_string(), fe.into_video_transcode_entry())) .collect(); info!("Found {} files to check", self.video_transcode_test_entries.len()); } VideoOptimizerParameters::VideoCrop(_) => { self.video_crop_test_entries = grouped_file_entries .into_values() .flatten() .map(|fe| (fe.get_path().to_string_lossy().to_string(), fe.into_video_crop_entry())) .collect(); info!("Found {} files to check", self.video_crop_test_entries.len()); } } self.common_data.text_messages.warnings.extend(warnings); WorkContinueStatus::Continue } DirTraversalResult::Stopped => WorkContinueStatus::Stop, } } #[fun_time(message = "check_files", level = "debug")] pub(crate) fn check_files(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { match self.params.clone() { VideoOptimizerParameters::VideoTranscode(params) => self.process_video_transcode(stop_flag, progress_sender, params), VideoOptimizerParameters::VideoCrop(_) => self.process_video_crop(stop_flag, progress_sender), } } #[fun_time(message = "process_video_transcode", level = "debug")] fn process_video_transcode(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, params: VideoTranscodeParams) -> WorkContinueStatus { if self.video_transcode_test_entries.is_empty() { return WorkContinueStatus::Continue; } let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_video_transcode_cache(); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::VideoOptimizerProcessingVideos, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|entry| entry.size).sum(), ); let mut entries: Vec = non_cached_files_to_check .into_par_iter() .map(|(_path, entry)| { if check_if_stop_received(stop_flag) { return None; } let size = entry.size; let res = video_converter::check_video(entry); progress_handler.increase_items(1); progress_handler.increase_size(size); Some(res) }) .while_some() .collect(); self.common_data.text_messages.warnings.extend(entries.iter().filter_map(|e| e.error.as_ref()).cloned()); entries.extend(records_already_cached.into_values()); progress_handler.join_thread(); self.save_video_transcode_cache(&entries, loaded_hash_map); entries.retain(|e| e.error.is_none() && !params.excluded_codecs.contains(&e.codec)); self.video_transcode_result_entries = entries; self.information.number_of_videos_to_transcode = self.video_transcode_result_entries.len(); if self.create_transcode_thumbnails(progress_sender, stop_flag, ¶ms) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "process_video_crop", level = "debug")] fn process_video_crop(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { if self.video_crop_test_entries.is_empty() { return WorkContinueStatus::Continue; } let VideoOptimizerParameters::VideoCrop(params) = self.params.clone() else { unreachable!("process_video_crop called with non VideoCrop parameters, caller is responsible for that"); }; let (loaded_hash_map, records_already_cached, non_cached_files_to_check) = self.load_video_crop_cache(¶ms); let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::VideoOptimizerProcessingVideos, non_cached_files_to_check.len(), self.get_test_type(), non_cached_files_to_check.values().map(|entry| entry.size).sum(), ); let mut vec_file_entry: Vec = non_cached_files_to_check .into_par_iter() .map(|(_path, entry)| { if check_if_stop_received(stop_flag) { return None; } let size = entry.size; let res = video_cropper::check_video_crop(entry, ¶ms, stop_flag); progress_handler.increase_items(1); progress_handler.increase_size(size); res }) .while_some() .collect(); self.common_data .text_messages .warnings .extend(vec_file_entry.iter().filter_map(|e| e.error.as_ref()).cloned()); vec_file_entry.extend(records_already_cached.into_values()); progress_handler.join_thread(); self.save_video_crop_cache(&vec_file_entry, ¶ms, loaded_hash_map); vec_file_entry.retain(|e| e.error.is_none() && e.new_image_dimensions != (0, 0, 0, 0)); self.video_crop_result_entries = vec_file_entry; self.information.number_of_videos_to_crop = self.video_crop_result_entries.len(); if self.create_crop_thumbnails(progress_sender, stop_flag, ¶ms) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "create_transcode_thumbnails", level = "debug")] fn create_transcode_thumbnails(&mut self, progress_sender: Option<&Sender>, stop_flag: &Arc, params: &VideoTranscodeParams) -> WorkContinueStatus { if !params.generate_thumbnails { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::VideoOptimizerCreatingThumbnails, self.video_transcode_result_entries.len(), self.get_test_type(), 0, ); let Some(config_cache_path) = get_config_cache_path() else { return WorkContinueStatus::Continue; }; let thumbnails_dir = config_cache_path.cache_folder.join(VIDEO_THUMBNAILS_SUBFOLDER); if let Err(e) = std::fs::create_dir_all(&thumbnails_dir) { debug!("Failed to create thumbnails directory: {e}"); return WorkContinueStatus::Continue; } let thumbnail_video_percentage_from_start = params.thumbnail_video_percentage_from_start; let generate_grid_instead_of_single = params.generate_thumbnail_grid_instead_of_single; let thumbnail_grid_tiles_per_side = params.thumbnail_grid_tiles_per_side; let errors = self .video_transcode_result_entries .par_iter_mut() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } match generate_thumbnail( stop_flag, &entry.path, entry.size, entry.modified_date, Some(entry.duration), &thumbnails_dir, thumbnail_video_percentage_from_start, generate_grid_instead_of_single, thumbnail_grid_tiles_per_side, ) { Ok(thumbnail_path) => { entry.thumbnail_path = Some(thumbnail_path); progress_handler.increase_items(1); Some(None) } Err(e) => { progress_handler.increase_items(1); Some(Some(e)) } } }) .while_some() .flatten() .collect::>(); self.common_data.text_messages.warnings.extend(errors); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "create_crop_thumbnails", level = "debug")] fn create_crop_thumbnails(&mut self, progress_sender: Option<&Sender>, stop_flag: &Arc, params: &VideoCropParams) -> WorkContinueStatus { if !params.generate_thumbnails { return WorkContinueStatus::Continue; } let progress_handler = prepare_thread_handler_common( progress_sender, CurrentStage::VideoOptimizerCreatingThumbnails, self.video_crop_result_entries.len(), self.get_test_type(), self.video_crop_result_entries.iter().map(|e| e.size).sum(), ); let Some(config_cache_path) = get_config_cache_path() else { return WorkContinueStatus::Continue; }; let thumbnails_dir = config_cache_path.cache_folder.join(VIDEO_THUMBNAILS_SUBFOLDER); if let Err(e) = std::fs::create_dir_all(&thumbnails_dir) { debug!("Failed to create thumbnails directory: {e}"); return WorkContinueStatus::Continue; } let thumbnail_video_percentage_from_start = params.thumbnail_video_percentage_from_start; let generate_grid_instead_of_single = params.generate_thumbnail_grid_instead_of_single; let thumbnail_grid_tiles_per_side = params.thumbnail_grid_tiles_per_side; let errors = self .video_crop_result_entries .par_iter_mut() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } let result = generate_thumbnail( stop_flag, &entry.path, entry.size, entry.modified_date, Some(entry.duration), &thumbnails_dir, thumbnail_video_percentage_from_start, generate_grid_instead_of_single, thumbnail_grid_tiles_per_side, ); match result { Ok(thumbnail_path) => { entry.thumbnail_path = Some(thumbnail_path); progress_handler.increase_items(1); Some(None) } Err(e) => { progress_handler.increase_items(1); Some(Some(e)) } } }) .while_some() .flatten() .collect::>(); self.common_data.text_messages.warnings.extend(errors); progress_handler.join_thread(); if check_if_stop_received(stop_flag) { return WorkContinueStatus::Stop; } WorkContinueStatus::Continue } #[fun_time(message = "load_video_transcode_cache", level = "debug")] fn load_video_transcode_cache( &mut self, ) -> ( BTreeMap, BTreeMap, BTreeMap, ) { load_and_split_cache_generalized_by_path(&get_video_transcode_cache_file(), mem::take(&mut self.video_transcode_test_entries), self) } #[fun_time(message = "load_video_crop_cache", level = "debug")] fn load_video_crop_cache(&mut self, params: &VideoCropParams) -> (BTreeMap, BTreeMap, BTreeMap) { load_and_split_cache_generalized_by_path(&get_video_crop_cache_file(params), mem::take(&mut self.video_crop_test_entries), self) } #[fun_time(message = "save_video_transcode_cache", level = "debug")] fn save_video_transcode_cache(&mut self, vec_file_entry: &[VideoTranscodeEntry], loaded_hash_map: BTreeMap) { save_and_connect_cache_generalized_by_path(&get_video_transcode_cache_file(), vec_file_entry, loaded_hash_map, self); } #[fun_time(message = "save_video_crop_cache", level = "debug")] fn save_video_crop_cache(&mut self, vec_file_entry: &[VideoCropEntry], params: &VideoCropParams, loaded_hash_map: BTreeMap) { save_and_connect_cache_generalized_by_path(&get_video_crop_cache_file(params), vec_file_entry, loaded_hash_map, self); } #[fun_time(message = "fix_files", level = "debug")] pub(crate) fn fix_files(&mut self, stop_flag: &Arc, _progress_sender: Option<&Sender>, fix_params: VideoOptimizerFixParams) { match self.params.clone() { VideoOptimizerParameters::VideoTranscode(_) => { let VideoOptimizerFixParams::VideoTranscode(video_transcode_params) = fix_params else { unreachable!("VideoTranscode mode should have VideoTranscode fix_params(caller is responsible for that)"); }; let transcode_warnings: Vec<_> = mem::take(&mut self.video_transcode_result_entries) .into_par_iter() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } match process_video(stop_flag, &entry.path.to_string_lossy(), entry.size, video_transcode_params) { Ok(_new_size) => Some(None), Err(e) => Some(Some(flc!("core_failed_to_optimize_video", file = entry.path.to_string_lossy(), reason = e))), } }) .while_some() .flatten() .collect(); self.common_data.text_messages.warnings.extend(transcode_warnings); } VideoOptimizerParameters::VideoCrop(_) => { let VideoOptimizerFixParams::VideoCrop(video_crop_params) = fix_params else { unreachable!("VideoCrop mode should have VideoCrop fix_params(caller is responsible for that)"); }; let crop_warnings: Vec<_> = mem::take(&mut self.video_crop_result_entries) .into_par_iter() .map(|entry| { if check_if_stop_received(stop_flag) { return None; } let (left, top, right, bottom) = entry.new_image_dimensions; let entry_crop_params = VideoCropSingleFixParams { overwrite_original: video_crop_params.overwrite_original, target_codec: video_crop_params.target_codec, quality: video_crop_params.quality, crop_rectangle: (left, top, right, bottom), crop_mechanism: video_crop_params.crop_mechanism, }; match fix_video_crop(&entry.path, &entry_crop_params, stop_flag, &entry.codec) { Ok(()) => Some(None), Err(e) => Some(Some(flc!("core_failed_to_crop_video", file = entry.path.to_string_lossy(), reason = e))), } }) .while_some() .flatten() .collect(); self.common_data.text_messages.warnings.extend(crop_warnings); } } } } pub fn get_video_transcode_cache_file() -> String { format!("cache_video_transcode_{CACHE_VIDEO_OPTIMIZE_VERSION}.bin") } pub fn get_video_crop_cache_file(params: &VideoCropParams) -> String { format!( "cache_video_crop_{CACHE_VIDEO_OPTIMIZE_VERSION}_{:?}_t{}_p{}_s{}_c{}.bin", params.crop_detect, params.black_pixel_threshold, params.black_bar_min_percentage, params.max_samples, params.min_crop_size ) } czkawka_core-11.0.1/src/tools/video_optimizer/mod.rs000064400000000000000000000234711046102023000206420ustar 00000000000000pub mod core; #[cfg(test)] mod tests; pub mod traits; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::model::FileEntry; use crate::common::tool_data::CommonToolData; use crate::common::traits::ResultEntry; use crate::flc; #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum VideoCodec { H264, H265, Av1, Vp9, } impl VideoCodec { pub const fn as_str(&self) -> &str { match self { Self::H264 => "libx264", Self::H265 => "libx265", Self::Av1 => "libaom-av1", Self::Vp9 => "libvpx-vp9", } } pub const fn as_ffprobe_codec_name(self) -> &'static str { match self { Self::H264 => "h264", Self::H265 => "h265", Self::Av1 => "av1", Self::Vp9 => "vp9", } } } impl std::str::FromStr for VideoCodec { type Err = String; fn from_str(codec: &str) -> Result { match codec.to_lowercase().as_str() { "h264" | "libx264" => Ok(Self::H264), "h265" | "hevc" | "libx265" => Ok(Self::H265), "av1" | "libaom-av1" => Ok(Self::Av1), "vp9" | "libvpx-vp9" => Ok(Self::Vp9), _ => Err(flc!("core_unknown_codec", codec = codec)), } } } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum VideoCroppingMechanism { BlackBars, StaticContent, } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum VideoOptimizerMode { VideoTranscode, VideoCrop, } impl std::str::FromStr for VideoOptimizerMode { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "transcode" | "videotranscode" => Ok(Self::VideoTranscode), "crop" | "videocrop" => Ok(Self::VideoCrop), _ => Err(flc!("core_invalid_video_optimizer_mode", mode = s)), } } } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum VideoOptimizerFixParams { VideoTranscode(VideoTranscodeFixParams), VideoCrop(VideoCropFixParams), } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub struct VideoTranscodeFixParams { pub codec: VideoCodec, pub quality: u32, pub fail_if_not_smaller: bool, pub overwrite_original: bool, pub limit_video_size: bool, pub max_width: u32, pub max_height: u32, } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub struct VideoCropSingleFixParams { pub overwrite_original: bool, pub target_codec: Option, pub quality: Option, pub crop_rectangle: (u32, u32, u32, u32), pub crop_mechanism: VideoCroppingMechanism, } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub struct VideoCropFixParams { pub overwrite_original: bool, pub target_codec: Option, pub quality: Option, pub crop_mechanism: VideoCroppingMechanism, } #[derive(Debug, Default, Clone, Copy)] pub struct Info { pub scanning_time: Duration, pub number_of_videos_to_transcode: usize, pub number_of_videos_to_crop: usize, } #[derive(Clone, PartialEq, Debug)] pub enum VideoOptimizerParameters { VideoTranscode(VideoTranscodeParams), VideoCrop(VideoCropParams), } impl VideoOptimizerParameters { pub fn get_generate_number_of_items_in_thumbnail_grid(&self) -> u8 { let (generate_thumbnail_grid_instead_of_single, thumbnail_grid_tiles_per_side) = match self { Self::VideoTranscode(params) => (params.generate_thumbnail_grid_instead_of_single, params.thumbnail_grid_tiles_per_side), Self::VideoCrop(params) => (params.generate_thumbnail_grid_instead_of_single, params.thumbnail_grid_tiles_per_side), }; if generate_thumbnail_grid_instead_of_single { thumbnail_grid_tiles_per_side } else { 1 } } } #[derive(Clone, Eq, PartialEq, Debug)] pub struct VideoTranscodeParams { pub(crate) excluded_codecs: Vec, pub(crate) generate_thumbnails: bool, pub(crate) thumbnail_video_percentage_from_start: u8, pub(crate) generate_thumbnail_grid_instead_of_single: bool, pub(crate) thumbnail_grid_tiles_per_side: u8, } #[derive(Clone, PartialEq, Debug)] pub struct VideoCropParams { pub(crate) crop_detect: VideoCroppingMechanism, pub(crate) black_pixel_threshold: u8, pub(crate) black_bar_min_percentage: u8, pub(crate) max_samples: usize, pub(crate) min_crop_size: u32, pub(crate) generate_thumbnails: bool, pub(crate) thumbnail_video_percentage_from_start: u8, pub(crate) generate_thumbnail_grid_instead_of_single: bool, pub(crate) thumbnail_grid_tiles_per_side: u8, } impl VideoTranscodeParams { pub fn new( excluded_codecs: Vec, generate_thumbnails: bool, thumbnail_video_percentage_from_start: u8, generate_thumbnail_grid_instead_of_single: bool, thumbnail_grid_tiles_per_side: u8, ) -> Self { Self { excluded_codecs, generate_thumbnails, thumbnail_video_percentage_from_start, generate_thumbnail_grid_instead_of_single, thumbnail_grid_tiles_per_side, } } } impl Default for VideoTranscodeParams { fn default() -> Self { Self { excluded_codecs: vec!["hevc".to_string(), "h265".to_string(), "av1".to_string(), "vp9".to_string()], generate_thumbnails: false, thumbnail_video_percentage_from_start: 10, generate_thumbnail_grid_instead_of_single: false, thumbnail_grid_tiles_per_side: 2, } } } impl VideoCropParams { pub fn with_custom_params( crop_detect: VideoCroppingMechanism, black_pixel_threshold: u8, black_bar_min_percentage: u8, max_samples: usize, min_crop_size: u32, generate_thumbnails: bool, thumbnail_video_percentage_from_start: u8, generate_thumbnail_grid_instead_of_single: bool, thumbnail_grid_tiles_per_side: u8, ) -> Self { assert!(black_pixel_threshold <= 128, "black_pixel_threshold must be 0-128, got {black_pixel_threshold}"); assert!( (50..=100).contains(&black_bar_min_percentage), "black_bar_min_percentage must be 50-100, got {black_bar_min_percentage}" ); assert!((5..=1000).contains(&max_samples), "max_samples must be 5-1000, got {max_samples}"); assert!((1..=1000).contains(&min_crop_size), "min_crop_size must be 1-1000, got {min_crop_size}"); Self { crop_detect, black_pixel_threshold, black_bar_min_percentage, max_samples, min_crop_size, generate_thumbnails, thumbnail_video_percentage_from_start, generate_thumbnail_grid_instead_of_single, thumbnail_grid_tiles_per_side, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VideoTranscodeEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, pub error: Option, pub codec: String, pub width: u32, pub height: u32, pub duration: f64, #[serde(skip)] // Saving it to cache is bad idea, because cache can be moved to another locations pub thumbnail_path: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VideoCropEntry { pub path: PathBuf, pub size: u64, pub modified_date: u64, pub error: Option, pub codec: String, pub width: u32, pub height: u32, pub new_image_dimensions: (u32, u32, u32, u32), pub duration: f64, #[serde(skip)] // Saving it to cache is bad idea, because cache can be moved to another locations pub thumbnail_path: Option, } impl ResultEntry for VideoTranscodeEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl ResultEntry for VideoCropEntry { fn get_path(&self) -> &Path { &self.path } fn get_modified_date(&self) -> u64 { self.modified_date } fn get_size(&self) -> u64 { self.size } } impl FileEntry { fn into_video_transcode_entry(self) -> VideoTranscodeEntry { VideoTranscodeEntry { size: self.size, path: self.path, modified_date: self.modified_date, error: None, codec: String::new(), width: 0, height: 0, duration: 0.0, thumbnail_path: None, } } fn into_video_crop_entry(self) -> VideoCropEntry { VideoCropEntry { size: self.size, path: self.path, modified_date: self.modified_date, error: None, codec: String::new(), width: 0, height: 0, new_image_dimensions: (0, 0, 0, 0), duration: 0.0, thumbnail_path: None, } } } pub enum VideoOptimizerEntry { VideoTranscode(VideoTranscodeEntry), VideoCrop(VideoCropEntry), } pub struct VideoOptimizer { common_data: CommonToolData, information: Info, video_transcode_test_entries: BTreeMap, video_crop_test_entries: BTreeMap, video_transcode_result_entries: Vec, video_crop_result_entries: Vec, params: VideoOptimizerParameters, } impl VideoOptimizer { pub const fn get_video_transcode_entries(&self) -> &Vec { &self.video_transcode_result_entries } pub const fn get_video_crop_entries(&self) -> &Vec { &self.video_crop_result_entries } pub const fn get_params(&self) -> &VideoOptimizerParameters { &self.params } pub const fn get_information(&self) -> Info { self.information } } czkawka_core-11.0.1/src/tools/video_optimizer/tests.rs000064400000000000000000000000011046102023000212050ustar 00000000000000 czkawka_core-11.0.1/src/tools/video_optimizer/traits.rs000064400000000000000000000165131046102023000213700ustar 00000000000000use std::io::Write; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Instant; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; use crate::common::consts::VIDEO_FILES_EXTENSIONS; use crate::common::ffmpeg_utils::check_if_ffprobe_ffmpeg_exists; use crate::common::model::WorkContinueStatus; use crate::common::progress_data::ProgressData; use crate::common::tool_data::{CommonData, CommonToolData}; use crate::common::traits::{AllTraits, DebugPrint, DeletingItems, FixingItems, PrintResults, Search}; use crate::flc; use crate::tools::video_optimizer::{Info, VideoOptimizer, VideoOptimizerFixParams, VideoOptimizerParameters}; impl AllTraits for VideoOptimizer {} impl DeletingItems for VideoOptimizer { #[fun_time(message = "delete_files", level = "debug")] fn delete_files(&mut self, _stop_flag: &Arc, _progress_sender: Option<&Sender>) -> WorkContinueStatus { unreachable!("VideoOptimizer does not support deleting files"); } } impl FixingItems for VideoOptimizer { type FixParams = VideoOptimizerFixParams; #[fun_time(message = "fix_items", level = "debug")] fn fix_items(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>, fix_params: Self::FixParams) { self.fix_files(stop_flag, progress_sender, fix_params); } } impl DebugPrint for VideoOptimizer { #[expect(clippy::print_stdout)] fn debug_print(&self) { if !cfg!(debug_assertions) || cfg!(test) { return; } println!("### INDIVIDUAL DEBUG PRINT ###"); println!("Info: {:?}", self.information); println!("Mode: {:?}", self.params); println!("Video transcode entries: {}", self.video_transcode_result_entries.len()); println!("Video crop entries: {}", self.video_crop_result_entries.len()); self.debug_print_common(); println!("-----------------------------------------"); } } impl PrintResults for VideoOptimizer { fn write_results(&self, writer: &mut T) -> std::io::Result<()> { self.write_base_search_paths(writer)?; match self.params.clone() { VideoOptimizerParameters::VideoTranscode(_) => { writeln!(writer)?; let total_entries = self.video_transcode_result_entries.len(); let entries_needing_optimization = self.video_transcode_result_entries.iter().filter(|e| !e.codec.is_empty() && e.error.is_none()).count(); let failed_entries = self.video_transcode_result_entries.iter().filter(|e| e.error.is_some()).count(); writeln!(writer, "Total files found: {total_entries}")?; writeln!(writer, "Files needing optimization: {entries_needing_optimization}")?; writeln!(writer, "Failed to analyze: {failed_entries}")?; writeln!(writer)?; for entry in &self.video_transcode_result_entries { if !entry.codec.is_empty() { writeln!( writer, "\"{}\" - Codec: {} - Dimensions: {}x{} - Size: {}", entry.path.to_string_lossy(), entry.codec, entry.width, entry.height, format_size(entry.size, BINARY) )?; } } } VideoOptimizerParameters::VideoCrop(_) => { writeln!(writer)?; let total_entries = self.video_crop_result_entries.len(); let entries_with_crop_info = self.video_crop_result_entries.iter().filter(|e| !e.codec.is_empty() && e.error.is_none()).count(); let failed_entries = self.video_crop_result_entries.iter().filter(|e| e.error.is_some()).count(); writeln!(writer, "Total files found: {total_entries}")?; writeln!(writer, "Files with crop information: {entries_with_crop_info}")?; writeln!(writer, "Failed to analyze: {failed_entries}")?; writeln!(writer)?; for entry in &self.video_crop_result_entries { if !entry.codec.is_empty() { let (lt, rt, rb, lb) = entry.new_image_dimensions; let new_image_dimensions = format!(" New dimensions: LT:{lt}, RT:{rt}, RB:{rb}, LB:{lb}"); writeln!( writer, "\"{}\" - Codec: {} - Dimensions: {}x{} - Size: {}{new_image_dimensions}", entry.path.to_string_lossy(), entry.codec, entry.width, entry.height, format_size(entry.size, BINARY) )?; } } } } Ok(()) } fn save_results_to_file_as_json(&self, file_name: &str, pretty_print: bool) -> std::io::Result<()> { match &self.params { VideoOptimizerParameters::VideoTranscode(_) => self.save_results_to_file_as_json_internal(file_name, &self.video_transcode_result_entries, pretty_print), VideoOptimizerParameters::VideoCrop(_) => self.save_results_to_file_as_json_internal(file_name, &self.video_crop_result_entries, pretty_print), } } } impl Search for VideoOptimizer { #[fun_time(message = "scan_media_files", level = "info")] fn search(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) { let start_time = Instant::now(); let () = (|| { if !check_if_ffprobe_ffmpeg_exists() { self.common_data.text_messages.critical = Some(flc!("core_ffmpeg_not_found")); #[cfg(target_os = "windows")] self.common_data.text_messages.errors.push(flc!("core_ffmpeg_not_found_windows")); return; } if self.prepare_items(Some(VIDEO_FILES_EXTENSIONS)).is_err() { return; } if self.scan_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; return; } if self.check_files(stop_flag, progress_sender) == WorkContinueStatus::Stop { self.common_data.stopped_search = true; } })(); self.information.scanning_time = start_time.elapsed(); if !self.common_data.stopped_search { self.debug_print(); } } } impl CommonData for VideoOptimizer { type Info = Info; type Parameters = VideoOptimizerParameters; fn get_information(&self) -> Self::Info { self.information } fn get_params(&self) -> Self::Parameters { self.params.clone() } fn get_cd(&self) -> &CommonToolData { &self.common_data } fn get_cd_mut(&mut self) -> &mut CommonToolData { &mut self.common_data } fn found_any_items(&self) -> bool { match &self.params { VideoOptimizerParameters::VideoTranscode(_) => self.information.number_of_videos_to_transcode > 0, VideoOptimizerParameters::VideoCrop(_) => self.information.number_of_videos_to_crop > 0, } } } czkawka_core-11.0.1/test_resources/audio/base.mp3000064400000000000000000035246231046102023000200730ustar 00000000000000ID3 TXXX year2024TCON ElectronicTIT2 Test SongTPE1 Test ArtistTDRC2024TSSELavf61.7.100Info  !$&),.1368;=@CEHJMOQTWZ\_acfhlnqsuxz}Lavc61.19$@O&d@Ӎ%2":3Us,{3sѣpL\]...{(((...~"!Kߥh pp|sW>@```nq p@BɈ*!32>HW+OqE2@vϯ $(ilx7a->?nq?J 5˶Wh*>1Yu֬I.rT;Ա:g i{ݽld>\Ϧ&+޻ӧoYSiWas XZ=  -@hWBT oк/g챱p JUnÚ~I| tp280s!fa 8yd)bCBS]5[d#Hs5.b81a,SPzݧ)e0]U:Uie)1ܲ_$:6b̽  4Z`b “}^g5mˎ``-\ER@xPP|l(Ei w<`D;2*Q]礩{d(HW,D@,Azz$LD=+a$v4fOqYSh2mOqS\9̹˷ڰ$^v1i.N0hj #Jxqm\vkܨ' 4̉IT8TQjJ {y]fiЧd!K@2O!tb9 񩌨O2V`2 ]ٌ86fiO}B4zJ)H1UC ɌNw'3[۠PITQ+1hjK&5Jrlqr20Ȝ6T4MXdT欣$ԧ:&*}ɮX=4@||!n:xd5ZVMDr,!"eQ1]̬|LG s0o~VJjN`A@IQom+Κ2vC5 H~_,/;)a{jDK]l1RH"YaYϷLM=so|;mvRz!娨Ԣ%֝%ߔ{ݡ"ZSy)!Y :(:T._w]@ I%!Yμ0)eLKjYR9H.?jI@LS;tU[Jϸ#(l۲[7d3+ e D pWG" c >"thd7G P0 Ic0s)7pRϯ`Dl LDz8#8}^"ߙIOCh@+'I4*MĢB|EE̷,IoK+ܺcADa*ѯ`d6x eGt#Bjq5fJnϰwͪj0b-u$ L# AJ/ F]z脕Fa4*&|mDYsysS$By'&،Nab g̕K$CyDƌ:;,_6m0TAQ)ucܯd3d3*3SrRQ@ P]yuN!dBwHX{ ,/bJT 'aӀ,t"܎?m"" d @`\])c^%RN|L쥖:Gspl9ŕph,dmYEeb943zzT\hgÐTՋnH +Fw|8EGy}ehs`_;?7}`P@CfjOh)G`jhE#*e\=c<`,F\[=x"(H߸U^m~wn قvMqr#o,^Y;8FKj Nk$ܟ2B5T{?OdSIX5b0{ <6_1(z:@ ySJ'hR 3:F}jG9_Tןױ,ޯBGnFTĪΨP~PZ8t( ,=J^wV"/0LU;&vNd o0[G~?A#Rr-tPE2 h$V+-Pi]m {[EƿާJS8ilַ[@5X6S=6u[InGAp4cF87cܕu"UGf+{ a|k.n >ܾA/hdV4Gr/% iǍH<ؖxemne\T@,e;b9FALcAt@"MZ$f"^+ =ݢ"P<RYMѰ DqaiGtA9ZquX߼t+:^όbVΒ"IGQR$$(!R PR9TT&FB;$h`+#}wЕutU3NT41 F'/-5G#Pr^hmN_ I,+:q"ƶʤ(ZC,X:*ys<%b`ڰZ:-j&HFheqJyo iNTzHmI=dMFq-"N )Zf"& '/dhE[s 30<591oǼX5~W@h!dhY(e$$j;]O;ŨC5$H$a"%ifhxuvdmE (5}Zh@M`h0Ùcʋ.;ǯw. mȺ3J@)Iz T0&'cӕI0 rdH5Cmje*ٹxU "; c2Y*ԁIS xv6GbmdnByKp/ =/%!gH褀`JR4C[Z3&o-.!6S7[zž=aӰ?'"-v+4b`BKt\A} @YJr6>{͐4ߤ*/lEu̷Wd30N A nI;Q4N.cb=yVvvg瘁"*DFyh(?D5?pQƕ DJ@遴hz%"W 4}4 &Q;_;LgXE̩&HPCQrJB*RxӚ_1y)c" pNŸ\Z.Ǭx₆dmKY-j iDZ-ȴ*si:ک2Ay5JgJqhVM6pTh8xo5f MƲ j~1cϴ5F2|`[U LuƔz c>5Lwnz*ښvS%V"jvjvGQ@Yٍ[bf mp_gw uK\D,'Pr :B)M3IWD-q Oxt!3] fv7jHY?4Rmq7Uo*2kh sp?#S5M@R?и(А-#KIP`sCdhLY /Z`$Meǰs*@=ekZJ֦>$% ㊵q0vZUf5㩑ӻ(,e"* @H|7((W8 o_CGw$G}*P@' f<%a\ĜP̽$3/ܟ7•453tniˋh2SwڼIҕ oosC۹y ݔ!3%\v?b܎fۀ$fk'S 0Q@ƃ8+K&fD VzRy;P.4(;OD\Q=diW,E0+ *$O[̼ t0č)=m3Zd(Pϔf?zyҗsҺ?rDURWO:t4_zS f57t(0]6{ŗ8$ \ sn瑁{z܄_ l0 !wZGwI a_NC(+6J  I(Lꑘ1oy˘*V^"K4*f^%-Z͛-lZqiw 0*4Z9뤂ڄ@@<521:tW{&D=Jh+0Z8:`0HDAh*IdkLa+#pm!]챴@2Rt cڤ.jۃV3(A͏#"\<>2HMޞ]UֆM3\ōΆCt .uSz!P %Q׈ޚ0(b,#(W bBݟ*P00nHq! FBب _mk"_I)2Cyݟ^S?49Ԓ}9Ljƾ׶5ac]/gZlA4[8&s*]D{$9Jg* .3Bz=vXqs BDl"d`)N/5`-CzdM)[=trѶπ|hwzstoO8d<Ԫ(,*c#|.veO?d_oַ^KзVilwش~mŔ6AAQag+dHNգOM+j "=UmH3M>q+~,`cIBHCc>B(gna@ X` Hy",)0g92rw77$' "jbTiD))#aп4(7F4F- FhD~n惴%V YZh *ޞ/Д분?:͏M۠Z;H8? Oݕ>ɛVX܅׎ @p;@"3NA QhF h>Wɔ#s@@rJ @јF(G~A]Ceb5M:fd/]OToN(J ',Su3Um=: *& GJL=zCڨx`< ڤRe(JY~V%#4_aVgR"}ƎX &diԀ'cB9S@%) !3]##z?c |<0&zL@b<ƷiW_Or.tn_JaY:n-ѬSCK>o/?lLTSK7`iL*vTjrST6op ,5EH`+Ž.8g/ʰF^ϬmdLW.#j0+a%e5 r)^*3nU{IђB2(LDwVܟ>:yO`D͵t| Σ3}uh֤b <Ф-5/ tZX|b4]3+7 }f!V QUbaCmUQ{޼).OTak"QF@?bNboG"ۿ59#qC9a`.)I}r? TrIR2# m%`CL#cmQj`XGI$@3R*SDkrm`6d,H֣O4.f;Yl *`3c=Jv7mT鄶BWMFcSگO}׵`= M(+]D,2-f3DXLWAH8-. @0d7Ft L!>bjaY)vG 3$!%W dQ$,}AFd  <*Q 32o$-Fq T)61Z2yҐl??h< )[I@@ sL%g;Q,ykA@6aw*qB8@'@?ržM>c))Wͻf=^`ӹ"7msEߝQchLa5f= [QIp'ڵWz6YMFЄ_3eqbgR> NZja#[E?ICkNW;#G3nv %xH S Bd HJW[,Cr/Z$(6]+ꨐJhŖ"2 fBZPdB?N&)m/amNv9ͫ `#4䍩Qx+[[]0L*@pǕo (,"R0I) Re+_ 5xGOse&83˫ޫIcғHܿ̕>Lu(ɞB\TxZh<+tܣdi  ҟP0@pzh "̆YT5y_375k`\ :uSgcXig)iu@'0F$WAܥB$^ #G7 }8JREƼ)bHccFFd%?Xk ,-CjjE]<ȹ kQ`51J0)XĻcTZ_I3hL[Pò60FULLi_c<| cW 6\<Ιe(lSk۔~."VR˙>zsԥ^+9XUVVG6HU#fR*aqiK gzSV%Vt(j;ut./i 7]`D/?rzU`B\D %)aEAVN.OϏ,w]:dQD0mBikFW= ȵ6 .@%+[<7n^[ ?  H3~fhm}J~.֤ו. =oby# -[ޭo?}HsRqn9ABKtj# =U)"W^j%#{j[QMy{J~wZMr!d=6bTH/%je(UyP H ";r h"@jZ( <O:"frKu^ Uߴ O =36 D)5ay?`Zx|nJyuU2B$RTtWk>T2*R(WDT ܆pXEHe4.ub .kb˧3:B8d,Qi;R>_*hZ)r79` =xFrFC.6BC}y.ꌉ"@ѸNc}TbbT9)y_Gvee0 r/qs0Q T!:] QN64MdCZ3)!>%3kα m 8Rڢh e׾|Ib!pB[^߸RX6d.!%hbfLx]cX$ۙĢza0b3bq҄`aX:wγUMF|G;) '[`\% Dczj5\3<ѡ'g(BKlom. 1Z81Y ލz:a=F2Q:BD4#]BJz~"&@"aaC [P9lboN%0@6M'I6"519 !L79\Т,aC9Br dH(r2+m=Mi4 +C/AģK4V,MT(9ۄ: 5(Rkxq>D$H#&CZmX)9(lV)ŷ4pvKPmc@dLJn\FX VI~>b(p?OY7LmPAD <Ί# S3mH5ġ!oWs!iDf˼}]dK)c0>",Mgm8(33P(,qbB<6nYc@geX`wK4eip j EbxY&"3 E{U4u3b2MU0,\Ey4*mPYymO&hE_[jzJ`L lN%%07 0 Z ]OVk-̙j(pYU&TJzjU@ $ep{WĸU 36C-($7*F4 i` da1X r>$k=V Y1c0k 7 f{a^y/^5S`L0AN޳ p8 yCgoߥG @H}M'CRiA?P=#X|?_ȋΥ` Z/[- O'] Zt &ll1a-HE\}ޢ$їLgz  ߟ`S`@rs F7jk!pfnK[{m϶3 \}_Y^3XvKt`ekֆ "6Jq4|+|e&V 4 .'.x{/ܿ'ΜJ`hPN ֡1V<ΑP!(j$kR y ;mg_g&护U2njQ.d+B/B5a] io^= ȿmtXaZ[3ϿMQYIm{sd8xX©İy'в[<+ڕ+a#  @ P8FQ D]Lkl T138淄q=,>(Uօ1ORc_55ؒ eKRg*q1+܄Fb BkO]QN=qobM V-ֳ4,JXo4&04JEN-O~AW  /;` y4X,g5zU4ı2E )Wa%?m3EY&ĔX8ad(F3X A#0C ̱a$k UjГfMf*(Q v~L͂ 4pC w,߽vz쭵vuZ!'d#:\Vpb\>'- *{<]PH$ćKTsfg1OupeP.dIKFy~S/=}YĹn4JD M C^@a@!G#DԴV&d(V&W&=N tsa!lu mo:a|x zhlUftD )PsZƷRRhlClhI@p2ShjI 8/#LɃ݌pOQ^X؂%QT u+ VKm{RH}[_^:90@  \N4 ͆)c4G: mв(_e47qz@ ů& &DDga.yq9~ @HbwC ;MpaG%94 JQnCy6)v(s$u.SifΟZU<1ē<.ZʗM~:l7x1#4<>I3\d FY36a:=Dwy@፫$ Э/ AN5;I0D%D#dI <pG ̅!g$$nHaUxxANA5Ks{h(IJ1r9<̅] 2Ik歜kB)*`„;) 1(UyT Lq[E  ) D'K=Њ5`ݲhA&.W9V$͊N1<[OՏ0wB=a4nE@C*zHƘQπY^$pLh|쑄﮹9ٞz׽9MC97;[ZW!gƋvף|"S۲ݙJ[i^_hw>ٖ:5E̲6o7 1~>9 Bha|7Rr{nw5ʞNskoM$DEP) BL=t-u?B 'Tw/iutdXYq/1Eqm SȻ7jTZV\흼XU6V:{p>Yr$Aʬ : e05b𺎠같+y3ԑ5gPgx-;VFc.(JgGȓJ3g8>cE207߃Rϓ杺jLbGGpSWGa4{ΥbM1ZPu iWKҪsjF[ߖʆkٿ2!X;d3E"E B\83J MI`ԡM5 Z ogFOׅ1Q8a*K|Wbf#-JX%-߿ɌKVdAYr2#=%VigsHĆjd%xhA_I/٦ "6fz ՆO$?&e襲 .дdUժ\Z0N/GN8rbq> .S EQQT q쵶ASI[$5Sg97ԤTMBÔ!jV9)%.B }ɕ N R3u5D T. @Y \#)KŖB!3C 7qß _N@D hEu~Vq3(7 פ?mNE֛Mn15|Vumd"j@W +p,ʚ"$ =_L0hLhgVPBXM(A/Ψ|8eXXӉz*^(ۆ,pǀ&2d8%86(]8v%§\k<ODTJx0o.mWCG@kadUHo0/xiaf"D唦Ro!hL@"S`0QAa hjibU p̒XPҩ^!< ?M߷'Oo \ \qxCUuܦt`}깎7<1 f轳c xrgBA !F\+zwOx-!D'&|@ $L|4IBCBHΕqDa43,b&ܮo )pog ZyxA_sOXXv}~Yߤ/ȩTH1`JvXgc9Zn@E@!%3R.7, (" Ă Oia蠰ݥ[N?sdv!; ,2 2 [,,QLtȞFKH-'yYNV@rcu#pd-X}B0WBR)b"N4)v`(!LC=njbX6׺ L CQ A-Pr~K3'Uui0 A"BPBahh$@pX|b2Ė[ŀdk .? cJRiߣeFWES +TeJ,rӭo+;ZAĨY:*Q֎mZQm"*ݲ)mԨDW"9J1>1#ll:cT'!k/N*SdCV6VS)`2J`"> aWKȺ״#+KnZSJW]NԮLjGFVk6#ћ%:qk(MJ"K}XBXɢ NDh^]a+DrG/6"Jq mŃP!>e~ ? `6{!K((a 'D:f;$$-O߶km 0au` ; GMiT"Ѹe |+d5+4R1] GUi|WXMxRڽ:y/FK`UErMݘr\Y'jrze7}e)y{= X`GQ2IȐ Rp}=܆U',El``#bb9N_T(*˭$1~~EfEFb Z3UaS$q$K5}f۽Ŧ/Xk"5wH~劂<Z̘JhϖG/B0@ ɤmp`Ѝ3$Ӷ+Śsigeh*GKfd#XVep05*&sN FP *$ < *F.-hsj5dǀU+R1B =#* EҞQ--[i K}%8q"l5 HǍߦR8g X u?oئ3ףiv߭UyF囯nk3~~Vf)_߭,^eL7;3rsxo=~Թ?+@!Ԧ@pigKvaA0)%S Hv!Ɓ7A RHaTRoLlW Ɍ,ȷ=.ܴ%CF*İY@OtΗ|/KZ2)US%@+ωai⧁q 3Ć5t]!$V"d"0teIId΀_\k)Ymo `ލN}hf>|&-V3;$VM U⌨07|JcbZ<@P`r]s:4n!{bCiosTcҔ`d4Zol>F*@0 ~!koV rG*VŖ{*?F-.Կ}=)6}vs_|T5t,;>Çe0N `wAaoP:bb.\W*7wM1rXŕV";Uop|ME o>WvuZoV+Ug ̉ d8"Nׯe)#;Z HI{Eѹ'Gk61ք+pn(%츗\(A$( QC%Ww]"/!m]fOm4Ao@c")D'"M(&x9SaYxeWë&bG \I zɼ8+y?!F $/Bܘ>@%#%"I$]瞍1T Tv\#?S6f_A._cʀVuxGД@uC Du,@s|Çטpt!Q7h%8sIVݭ( ABkV B $`L)Ʉ?(VTB勔Ào7G_sG7r#:?C HND53Nb2ROvÊ^Q:lʌd HVi0CPab& y QU@ȅq ;DRaF-t\Oٛ~$ CERI; h^f3S22 >8,ѿUL ӷ(NP@s?CTt, eDBnf!&Vwq6jeqrQn\MMjGR:mebTG_֪9rj嗫cw2M oA@ Bz2'BSb c"8:̊,SyBN[&٧27p塚n̉Ndj6bePZWR08BBp"bjy(PѤ7/ҏmMmoCB 0ш+U (La~/]fKh׽3ѽbyNp%Mj)ppKf$nKR.;W"E5}CbU,.%xeRDp#/LIGj Z򴨪^X4{rsxNz8cj23ǺܮHѿ 8(^1PaYm]wV8r >3Nn#/?m\Y PjNt"N]L}eF(Dϣ\`§b)"2>.#PJjp]C[2dj1Qp/&Ȱih CHjP%r+d!@Ke+G?G%ɤP3&vC6407`~CocNyDFLTA!Eʒ:\y95ǂϛ ]nDS\&{nj{ c=Nb4G*[|θp""$P1!踈rB EX ޑ5a!ub\P3$جWҳD{ `[ xpTB)ˠybHR0f0礌iPƔ[5ZQUPSi^̤Y9#!Eʼr`R ߀n5Tad.(Pir50& ;fQf$HX@ 1hK։Low;bv=,TU EI 0\4:}q K$ˠ#OR 7M;1ׯ9yuQNQS1I<{ݑen : cu# hIczՁgtM7~k{>BP*bnL!hRÀ b(an F";#wᙙ3&~ߙH)p];k?NZwsX [b jgOS#>Z%1I3ukf'}nJ*B=%ߙF @*$FBI;w?cFMeٱ^BB0$"lI-Yl2-ūJ\-p1"H@jh T}U<+OiK!`.4 A.%0TbeDxɩ`:Ir}CL? )'huCGj iqj [uTønc{w,(ݻڟc=]_d^Pma.Aڤs_?dԖmHdm|q^J) !!nG-v<܍2Ȑ8yxygG"gV74UYcR6) al&4d/x"#+,oIdA\.oN<[ae^vLLJW(>}7Jް"{Ē%!n?՗ *Y6KIXD^q( cUs ` \'1u.m9r%\#Ȅ2' .bл2pPi̯&lqƪGo0+9ġ2-9j"K:=[Cq "a8߳"DUd\K^naȻǰMe= -304ȭgLf-i#A y^O"qX`|Gkh"F #J PqfhPƺ6!aD҈0D;:V,\1\u}оMul:^^Iz; zd6-mtd#(NH.D#bxWR[wa4@8dRDx@xЃn8 CT穐zԾ.oJ\hDY3 :f]>o~`^tZp=Kǹ`)AZ6NxaB6y;SLJEc_ؚheuNu+Zd c1] nI«*& is'u  O Dac符=9֕wӌ;Pն暽.5#\T 'X-WaZ䱔|4 M !"E  c}A2Tl``썡U6s` :ehj@'SFĕa ][!Vox;_K"DPt'vV6GQ1hFSv9ٌFcڧy>'zyM͟wDe$ ҀpS{>G@yJ!A2ȑbxg ^{J]RAPT.4-̢*ܓ3R06Wuޞp t-dFdsWY; Gc[Z$Li$k,\ xt$TgA;SSI =I3aNRڑ!r9]9&?c掎hpp ڠJH DDx4Ј)^"˄[1Ԧ`PbY6 9֡9>C\04H)$y ER ZWRpprlB4fPlKi <# ,5ء"ڂFfNuT9Wwy`MV.ҩ$A<: :DXM5VtRlCQj[92vrv?huF P"f*QvncVj2aKd 61Zk G+M=(G a[gL$I F p.uZŠ KvhYG3-JJc:'<U,y|oQi v2쟋{i$7n95i9$)D9w6ʔ HE&r;Dudf͉wa1M]D+9*p4ޥX"|YI=`S2L`^^a1@xʃLbB9\yVZL9\[WvM?]+ulcѐz v8%%Q "a%"P~f//Sܪo a0T+}08D!7J&<`D0.(p]݋bC Xm` om>=xd 0W pJB = -caL0ELk AP GXD4W9WF@F*jYx>1 j V-w\B宦&3mmX K) X)7s3F/\vmZw\N 퐑T˘\HPR V[l1gZr~d  Ei%`lH^X!Y8d 1K pG# M=. iLHng8vf>6NZ9JSXtɶ<<ݵF>i525[ő#&JsFJ}ݫ@9+k.*2jv 2F;&vZ029ƈGf=(@·jitV6tǖG@mHfachi8aPWc b[cڮ.2x?\TeM6^qsHYBDBF6h\uA[*i$91&@pAvWDg,,)fiPȵ BW13"1M" _pΪI-SIhZC \ar& n*bo:du HIC ==#f hog0e+[tֳnc%*h؎噸t\@|"RYr|.yZv)6uQYYeH#9jHs\y!͟QP 'HJ'R¸|gDxURG<4,ʢcz$E@!Ɛ[( I2&\3ѣzB |ZEaI M‚.l6$A0c$Vڛ ͟9,ux"{KEihD}fg=Gul1L |2 MUE+ xuRP/ :"7,FleVTk1ޏrB6%H޼Nڋ9 pN۲)LͿF6_"ȓA -O`pKPO 2И<5pp"=LrLd@[֋/I`ID:=f y^0ǁlę)U\Rjײ| LRX_'G%\,C<9z‘QJPe;A!Pb߿ + ̀ 0. be@5EtyCh Im%F9К,B@ #B%;%knq<`WOeYa6&e(!iy'xΛL܆J!2CA>s8+2 160HD gABJLZ|A4dN%L˪RWz\F$C~{>&ULK0Лy;|`dWQE+=8JS^K4 jNp1FȻA}B6Yoj8FWq6"Y;~moL&a9*cb?\, |_-G F ]KeP)vL8u'p+M % !cɱKq,Zxһ WzUNnr/L Ui B7O#F:-*)^cia]E\1uY"`Ay ]0{m}]i@:2Ix@1&CQDj8ĤIfMTY4b kN@0Jƌ-  0bpEEmc2Qne@ QU(ӱ}XuPѥpcBŅUXX@>41h ~āɪuS$AciO0!z-k4!f eO!F!2uqi*(2d;"QݲZ 0xt`rĶu봿to?܄kNPTm WiAh QN#?S$'0FHCjaSuXGweyHEJEg6en:04h Fbt"G 9nTIUaLJ13],;c8;@Fi€!j%C/ǙaB`BЄ"XC@s-䃵1Lv̲,ݤOÜm-(4iǜcdnVX)R3J{ ҍ1+gL$i`&ƵRRJ$UP=Hܲ ,DF &(P@%)'HI9H" ͜xN+!PUad jb+uG0lBE'X"Qt!M#%&"jJ#ESʶ$[U*ֲas;a ԰16 s)0r_ӄbU9}$0,z S(\c I0$q[4wuJL5*<4:SHb̔P S[*nMBשf52(dJrȧ .%6Ub]}mkHdӀW,1r3[)=%&,_%H00Fմٯ#Xj2`lG4Y+;ʡj (jxĆ1pL8СlMfo1jp :f/R.vHL\??2kJg -,+`X% $jJ0f%GF=;!Eܷ>̇C*ǖ0'E@u0jAVb|MLR!L/2jFR򈓱4i YxRi0ޫ?Fz_Z_{~ UT)D+ߌ+EGd]Yk r;!k=  UaLp獬0Ž* B0QKFZb==u`aE/^2bSAG(3 :Jݏ0$J '@h!$9UOr;hRT4ɆݧoqvޙW1cԎgUg̻޲/Wy5KuM4R x~:*@:<o]GBЄRdT ckL@ TeC*_B)89_U"U ! ;=Bi2*Q̾߿2]ѣnhLV \ؕSEB٣K.Dgv G`d"Wc0EA Miq<$й(6@B$ 8 W`uDTPM޲лdlht&4G*}| ~♚<'94 [q&AFK`zڣPH|Q|>+)844&,%t;6E` 2@Ͷ1Pa+#=0 Ȩ*啵?C CfԺ/Xs&PڃAjY%H- p@V^]TNPBYWOzހѺ܉JPhpIIX|3-uĵMD7Q*FĐ5F˛ 484d3_1Z;Dg\pيTBh2(19{Wj_+ﯳ 225qáap1$ؑp#akʝۭb-EQ(@@.kE62lP@a b>4X4dc(ԛLrF#="J wVlʈ+(*xZ 4c<'ԡ9yUcLfY0Ϫq_z2 DB#@hcÒ= $dR*̉0!9sf{x H=\Xj0Fѽ(D",],֤um Q-48; $00Y +&gx8g[0 4a4I_*EA u6Z~ CTɗ>LW S=%Mf1YNsy|?8q{"&ʽԷA_<IA)7Md%TL2@< GcL0hEku .gCr᠞CCխ%tIuI;aMekI7_T[ۡ1ۚ31ހ"USC\[|39x*ՕS8bZ.yݫs\YI@Qṟzgb^-=zg%nPd*XlYƄUB Hׅ$:RH(wn6'QZ"f3;y%s3 1]:"MTb9kx7QE|  IG:D1L0a0+iSvI-Q1Hc:%igƾ/5B6{1 ^Ҩ \/PPA PЅ0Xd6 MZ1#rTgo!14nj\˳J0K3x?.ds2SJP׶w곸> m-ۥźO\4]ݤcmv 'T<2a ~#DAE kx@g9 LuDqcӔҏ; y5gb/deg1Hi.`gMgjvWsp"ñU~=539Ky{?UdN]IIzOZUneXg7"_sSJnZ~Uf?/M-w,9^Xzq?U'Z3eAF#J-iijT}M/eIRDM%TUakWXs9+/f5KV;IX@RIԌ5]AZiXXb/ltKK2Ed}Vk}/4s+1f#𯖰ZLFXG3Tw,+nT폋䢆vcDo۵ceV+{{zSv;Ɗٽ_u{Qs&3S#!I(i4D`dd!GᾹdoF*QgEbxR[u}AWVGvF3<^=5k`pcF\Ϊy)߲Aӂ6?L?#ȗ$:"+򚗼'熀"C!N]!T DQ :I5IDa:V=*̼ i% 0W3MӓD]i>S4\pW1ΜT#ҌH1*č,{8?:!lbO7ޖ_@iFae'U% m*Q&<pqBMu!FMy5 kRZU\vi) |R$Q5#fX[`5jJf+Xj K 4Wx(lz4Zit%B,ZAv7]"4F}0Gd`< HD WHPYRQ " Kl<5Ej"w .j;T_z%fUiH'Zr =&`e#A{^ϸhMj(#'r\s?!:!֌$* xXE˼#"9K'QL(񌇍5%k !oe9 \P$hȞ+tC"T]IiiopᤵU2$@;ׇP5#QX;|d9" Cj=T Oe$ @"ԑ6Ӵ?%ИiǥRGeK]T#ک#%y֬ K LK+4GL9cnl^"4ƙ!ӹo p5 H)OY%XV.HC2~>)%:p.DbO)$+6"{7Jёݒb|Զwvu281f{tK& фBzG9fF!‡#:OŒy*!Ub[cgr&>MjG~fvݵDYs:) 8$88D ˃"[H(XʅTjES|dN;7Z%&$t*(ᱷ pJrp<"A`PB?oUDDSܜ&NM%R) f^dhRQF\T13R/S& Nph*<\i 'n}u2*vj.Vr, $6Rڣtdt441w"Q|V711PT!tU>'^Wiri tfndf#q05) KGct"b6ZgO jc. T\]ȶtUza@B{lj7=5f:8)WD @:H*$fIIʆ峛|EuG_hɾ&& $ uMH5K!F$Ʈ_NdP X:jEb,Sii<h[%=1X$:L&t! iL g }'m07 *}s0r8]LfuN6AJkmW=0(tٱD*D ka ϸ&uq[t[OuVlce Ɗ0kl3.YЄ!+fFB@UJ;[NE4ȯ&hbh38R ׅLQlC\>P B"w-Mrkm)Rdc<+/X< OgiX$pDvb)[(N-TI&x 3xGnxpx׽j;sE8=B[ҁ>[|t$ Cܤ!e.sד`2-귺_k+ d%q+1"x_l,bF 4Nsk J#bz2L{V]+F--]Fѭ~ŵXߤ(|.p|= aR(0{ 9gf糧ON[WM=U4Ta!F!àgİ Sٺ` Q ,GˀUɚd3SWi|0)L1'h I䖒yD음TAEp/GIKِ') ;4a槞YItFGF(/M"gҟ0F'`cjPĖ;>ԝ1zγ̚5GSoÑ-? @ ]!I׳T JHED&Y"L0 .B-B%&A.U]8"D]t1AF2Sfg_UB_ىQ##8h)~v;IpY5CgҶ\mBX1'l)Uҹ8evh[}G. dB]PvaňumpwP)+"h* a9fGОDnp @D #d9O#cns 0pR Ab z6vҦ:$Gn!~{+ThoEqTK%U!#nUs :UUQigVpiΑ̭K #@}`0,L: oK vKw#{l_ UN2 hTH" ҡ q\o/vj*zQkN~[V?ɧlK*i)Ewy|E*Iݚجj|ٽ He\d~4ga- i#k@@`(wB_OM]X/@Ptk QJHx])2ߥPFGF6, k6/M?jQGbH@r$}4ϠriTp?:{ؒj5SMPʕ49nzj5#d"@B|nw=|@ i&oh7(<!UnVLT24S2Vt&´ (\:" , 5f ^F!PC}IIQ*X6 9g60`cDA4=([_73ﻶ pӑ;"+W fQ{ɘ=ihdIYHP- " 91e$Ȩ e xjP=lx3G>QgaAL WW8,,p錧Jx,u<`QUͷoH8Q}*j)KҫnJ5t=r(mGjYspbV]BKRҀckׯǸŒqҽzFQb¶Ԩ%%<ΨBФjx>M{6"cBֿ]ZS+dIYc .j9P'6M]gȢeqP %`8В ]9Ƌs~$%w_ ۘjGj L$ToK-'S$y0qq@'&I5HWUWGT՜b'h,<̲ #-B82YBH"Ȓ-eoWl g;BN :N=@ X0!x>)y`],c;."R!0"[1 t; 9q9kh ].pV*dbajejvqne!Yt%I"rO#bjFf)TRfn.lnoXQ% 3(a  AO]ǤqdAޗO,2-unA(l@Ar/1Jtz'R +r^"Y2'HLI#LMLHMLȢFTʜfEhԅkmnZ @q!M{`Ymg1MMH;)nҴ5oU%2sdW6'$)2~s=E$1P 25́\ܘ ֳIcjp7Pk%u>'⊮3X*fCR) -fP7q.K \TO),tg=3֮:4ut ~"!fddGWi*(I@WqL$:ыbbI dej(T3AA,Q -"zTB-:^*1ԙ*E9 @PÁ0P. <ۆH2%"%&V9X00jJ Xo&|`@覊t1#ܵ3*}&qISaʱ LnJс" ꖛH!eX@i34 (7*e,X9SQ-#rt$=9^˚6t4$ u>Y"g#"%0qz,44 4!.% r-=Yv(l $d)3W.`jg( WS4X#N3ߺw5Ih\ u96%Vy_eg(Њn G7#r1Qf|_Ñʲ_pЭmrHm$E2zeN1GtVXSƷ vAMStFh`l !6ax @h7#< @%E<3L : ⴏ#j6GۉK| --ԫ 8xtXQp6,յյvaTzIS "H@0pq )eEצT;2=Q,1YQmQQiI`z.#. +>7A/Ȓ dd΀'U 1B/pfM$ OSbA`R1#@D#go4;?9=:p{Ң2!K!P D?V*nAq, 6@ A^6L6X9dc#CX|MB#(=RٗR?ˁg~Nf6'kUf$Cܶi{}~#HR1#S8cBgUpOLo 7F7_%IosAhU dZ`U%`BDL*EydV@ETR #^6 )d֔еkv^e2<%Ptpmx,ZNt_ֿx6ADl#~dDCRa`5EPo-y#C0H G|E/V?V o ncz7u+/z`Өص_@蜑߀=%BE75-W7:RtZC{ 9Pd)4VgNg]T(+4HGQ׆/YRR>ySO>RXנ˧Zȱ]FCҘ̶j{8RN]ܵV))gbS;n<xʦaf5K?_߇G"@L;S[Rfq~tb*[Y齗sbP%Vf0@58*`Y%92. @QJ@#pHT1%& VҰu3W`qV>!HYAO5/P8(5OuIUI4rfj"DQ{{zAJ | Y({z)~-Vfg2d1;G@` ^Xj  @ Pi^l5@01,0K&t4tʉ8 27`3&m}-Vu=e+ bX>{2YQ]q+Oa~_haNٛfubN255 ;d″@HPa,(`* ="iq wfCxCH_d֠W-RKٽ783;ɉmUtsSr+o_ @뀦` !h N:Jv@b S:W),r$݅"m#UR댥B-' `ő1LaPTbj_/@!@zgFwnO<6])c=qÂa~2pM*ven1xEyr\o4ƹ'}/cfeA M]VN3w{+NƯֿ%,ׯ}ܫS}6W)[dW^k-Ljw_,w *zUćzAR9R@L%a,m۴w G 4aP$ c+ƾEKPr''f`:L3?3ٔ>rfgX8E"8LE t 919Tsu={q| 0+P)V__!?G-Ӏ!XpZgFer5_X9HLkp:j,A "l"AF4ľc5ߥm[QC35Ni( d3$#) Z锳T_ ِqdȔj#.TԋdOHTa(uyQ uȦhi϶f@ YJ?ԗujb7G;?U\ ؎0BA@u I.hv8RxЄ!4h k.\٥H6kVh5Vdy|5(y^y**'$+ũ& 4#@ڱ Lg1:bSQ UK։dw&pD(Zksx_@5FAF "/cSD2@L" ZzԼnT=)QvSq8'ޡL23?- N!G4I[JOdEZRO5r&z}Q =ؙj HCYair(& !RIaLI% Ų)%hEAl,%C Jct{L9X߾oPX}Zz%y_i̶EMhF 0nYqYBk{ й[*HG 0uPB B'xL8]5W\ lݥSȀ|0eDf6.{T&'[&o;MN֓YV -Z x k+GFy x1FcdTSrfv)4`??H`E` @DBa.QBu5#}ߥ@`*퀳ŨC50Ha ~-djZSKL5' z: y@aȏ  J rTm *7jجM.fDK ZREkYf^&)3Q)ހnvl%3`GD"<|2XR׊j" CI)T&U?Z&~˾Wk֎kLX8[ڔ`!-k߅cX1^Q]rCiL36yTi0UhNgԥ`sZޙHDm[H c NEcERr5%+;5zī.:$8#r<h@F#FdDr*ED_jZw)%9Pvv?!}-l= !xR@#'ӍOw_c'{=zxXVQ!@.ڲU+O(*\$K";6H|bW"D4rψ kO_?TAWhLhpE*D$fbdxS@y1$`E> qkǘlȳ,Xgձ1S8(Jq}_S7ŭS=oܝx~̽6/JdWF*4(Bbo]!ź>|jEIBpE\A PC/ZM(Y"SH i^3*TAAu-K`0c5Dƿ{^@&٪D2Hy/0iZ/JD(2Crf U3|}^ږ[Xs~ς˫dݯH00—n2P@bF+Rs'i.sRw.d4"[2c 'mMȵC5L3tBMg XCqe@L pjcjqx8F6UQr:H0:X`W#i"yM?Ai{gvujmZgndS0<"n 9uiO+ˆd__ha*$- Y F6pQٮ0` :?*5>~G([aiNtVE"PweNr&t3Tiec@)L3fԙɪJ}EogzTwi9نѱtF0 FTQ=\lp1WNB"IP?j,ذA:lT(oRls[o7ǥgQ0C $EEB`.E6Vw$xǴJ]@VCW<). 3Jccp:(뙠yZns0rट4Ed[Y00<" ][gǘQ@4PEc<ܫfkQ(QY08&òOgj*4N\#JW F`" fLܝR> Rb34֫*q}}J45-}%/KG4Qsx!;Ir= :?M6r ^bV\ dDOr S8_~zd){,ZC0V^JgBHDWHL¨^X_يEH.0ٗks>^eoNz=!KH;{$x)"It5d[X5+ icǙȮ'.C?'v,ZCTDz L!|YLF:3]Y}]LcvKUAcL(18\0@C#*Aq`t t`drp=Җν^!<(D.`b6x%FD:O2ĺLԉ+ ҢT +0 K1QS rV)^M`3Z Qj z@ p4!e_Z_tz?CU k|c \p҃ &J搟QnԺ. 6dV!'j0$l5@w }D\TkJmC;d#QWs&Lr,= =W1@pHS?{Z3V+Oږ SkBF#{@4Q I,qԏJ{A4#*H[djz*`fpo= 74"&^e˘5.W$$L "]@b &oms"nB"$03Qst0R6ą1j X5k h#Bp-4A(&($1JS:i9䜉W6Vn2RT6u;/7Z6MC1zSgARO_@e:=XjM9d KRyCb&y$,}qL[i܄jU`n,DDBvɈ^XR9027`_^jrbP.@jZey}`#A?~D;Tr`mƚn5,|Kx%ґNFEcI!OOMґfK,r\f,90?>Yv]`,#/ޢ X`pM (zzPl!sO41`ɏRm~#czi4x+@4W*\eFPh 3 5COcH }.ix}.}$NL-攕m0HVEd A95nje$DdOKyCp* ! 4)O`͈kg.yVUfL/G=?W~S S0E1s 8mAp`c;4{+P`/h[Yu[jӨkOQ#W3 ċ FTFfGDTkQCэ 9g#FE&:eFX5@ >YrRm_ZULOf{qߵm(])? *fH_q4&%A*It\ 'Q%&֕d>L{'a,1 Hia'*?ɕ*i*ArdiHQyc`)⺭!"2O-<@#4GEf"3gMW.tZ !zw3.ʋ4i 66׊+ }%/hSw@`cdZ.MoYs#ag !N8fϼI E {d=L+֢7f@Vqp^{?󞺀@"Vg`5:a0d.XV'#B˟1؛:xVշ_. QJKQ$Z 2 /UI$3+%n4Vkjjspכꯨ*ͼry$BY|ؔMj,o~5_w $% 46@"dV IRo^+!z(R=]K \m`>#^eE 9GI%z Q6#@AF^ 5%P"tZ-*]lY NE /L(iQy?˔-/_ܱ-G%C8!mt}n.>/[T6чxr(a\u7A9z*ogJsw퓀F^ăKrh@hsdeSc[4&e.E~ !F :D-וIw>1J?vFz4G>oRlddxId@YRk'lOq}%HzltQG6m&$SL6H"yEE j5a4 ?B,w/ҿ zCC^$ h.~wEAtT%0]*ͥIy+`eR$&o$:ik{8uM鱁3pq&&«)c΢>nj4o'l,gQt.2V6AH!12$ggzڋ6;pGS_bDK@1&A<.LTN 헺1nY.5! > uhn9ǢΕ[Yg6E CA֙Wm3ӏٸHD!eR`@|5u0FSb]}1vl(^DqoiE J #>D<'tps5G^ `ޞDh*xhKS _c-^V|_X4Vq_ E ( M.X.'4(NjV mA]#~gU&@ ;N:]2+0#x *H2j5_dXUcY:'Z meP,[`-E{$5'z 7ʠjP]hBУ[ރΧ&X(i 2]3x(r/FoәGGM RѨ'zikCS+zQFHsTUo&MT@Q (6!v$2Ti]?')$l䰑jVMr@qꆌ _ڭ 0_Re qu-nn;q'ߺmA|~YFA* T8t䳧c">M*b:(Nu 6&DYˬzEaMRѮ +9q)K jD|yŃbd>/+`.k_<&U/H+a p @Jok q`iv9:Nda1'9arj="r1ą## EH!#3D(vE񄰡 *Fb@qt'^7u֒8 WX!qѮƽOU}ke}-@8a# 6r>GG7_Om~OMIN4G?XF {a\.=!7-H,**mH=4\ h г2* 5̀ ;i#f,c %ebd40W/p4_=8-mZMztx_^^ ]O]r\+t-5Q== ]C(} g&wbr͊.L9Cs+y")7fSuwFzY N{'O?򚝓EJlʂH3$bn5K qEѣH! T2KqkNq0.  HpgSLW g R+dZW3/*@"-=,I_cLx lHsCeچRI1[Dǭ}E?nvf^G p`5C#U"9󲱩?>sć T @@Y(KG&SI2(#T̪21V , Q@TDHA@E7A(ÈDNƞ诹Sv;2ݥcTbOA{8Mk ;u,"b1\2L3)S4ܕ qTRW9i9P/3c$2h0` `gBL/3r qA"tM/|ȺPU++GLjxEI( ç±buF*Z1C E.[ݫ> @pTݘ`r%&a`7.ga̝&&fe=fu1f?1-|e,Y&+tCӱ['?fcAa@5?EQM@ Udd03EyCB[8nv4'MZւ sdOXS-6 1re\X-X@8+"X󤟣;B-æwv-HBSe[++Nkq[CtrU `P FUys"@-vr :*B4 Ip! P`;MT0L2ePpmv$OzIIu.J &~uy肈8rXX]sa ,1TL(y:Ǐr;4l.ȹ]~HvY5\ۻf8Ld#JH@6BZ1"t gsPX TO.Ns2w6Ҫ7WSl&,Ws"PYi.f=mR„l2P;MD `a'=I"A ho\WYǽB à|:_W;tIz y=(@A^<`^~utp-O, w(r*eN:LF\bIdJ*2*p40id 9EuLʅmby$rԖG$*@f2AQ x4T40"iMS1+HMERV)Oy"PA荞D&5車PI v5Ɯ#鍼|D Hb EBRIpp84&5JN@CLĐ(==? ׯ7nS 9P$5u_E'A;ym֊Ə8 I:TzD32@I<0ւ˦{J`,'DKMi79JK ʰcNF%L9uC)3kRjJjh%2$daJY,F4;- }7cL$Q@-hÆ5Qn/:I[.LTԋj~%OGsBX AؗdZ(6٤I\khI4a&Ij]WOl]ƻcvfY NP59.@HC4rTAT7LѤ4k .g30 Væ3Wʧ)\sw+.hGTx߯F,^*glQL!^ vQdXuCrbRTPPKAש?sG0? VHD zGݝI\wSM̠ WT!ҝ+P3uӗٿo_eiٔOdv+T\R(v5aY0fqI%_,-tPaǞC&,eҋH"4&/3xxed($A@Ġ2IJU1,ѣTiKԵi>tk}xʶ0֣ýh!/V46^źhJ}[hU~̌%MgFUEרV@ '%^8²*RVe3/wJ7[aȪDVL&.Aly)np0z\9Xϣȝ 4ڏGy%غkq88Mv5=ess>%c^[b!D!D Fr`0ڼũcB.dIZk/ 0 aOm0cM v RcR(![V,MJ3ڒ1 e (_LǘrN(}IjP`Dbo8l9Le,ɧiWܪyC$.lxPU¹ӄȃd?P/Y)E,:cǰeŊgP 7ET18L".t %qtqhEc,˕!2: j?Bv59DUBe@ ֏ e˟+zMltis;hr$+sV'P\H[}B4)O{)[NֿUY P'8 B^@Dѐ<27I[Sz5j O.Pup_aT5RS_W{+ *x`N((tO-pTAC   Q@SwDQ7Ћiђ0)FS! :AJ1"dV:3WYr0*}) <]ǘl&P&ԏ PYV6S Ħ)U *nN]r*tErDO XNXEQQ풿430F|\9]bLci[L;7]EX2f 0 )O=L !OK):N=Q.DyfG*c(#rYfRA'0Z*MXd}dZf[%Cf@]3/0L9~i 1*"RoG:DBa"僨#Bƴ?N1oWRhBLjFHdj. 3:`ITesm`ƈ+d/#AA(]wP4 N >߈@@Ȁ PX0M``8)7m嘔&9)r9+/vGhD@IN$,B4&<@cBdKODN$ Vc(R,5k}Gptw={ ?%VHA+B}{b{%=XK4 "DD#.Z˖^DIZQ¢DSX)6 q]`EJE74;T?2_t&f%:1v}X/9$ 9 IР1r42 _ŗAT 6 4a&DzM^dz 1B<+=H uqiP,t X rMqI*L0]=I0'*R,4yzGHcT1 Ccrj9R^Sdo~o4UᥜH'hȠ &< 5 20s{A҆ ٥o"Qfd U]~7bo1 iL$KΜZ"r.걽r x^4o.4hH:Qҍ8Z#8|rmdmyݑM5K&̯jʆ#9T,e;)HyN}W|`Q Crhw[G#K*&S搾M>o`(I"k&Xijbh?K?o2PԩfSGyRHb='(!Xd(ٳ ǀtN{-^%ؘ[Ũ~v=]qwU(nRb8Bf3a$bvOKmt>XI-!i[i| 4,꒶ks?v`XL d$ٻ@6a= eyoGV,tY4($hlP8z)V2 |\Zaá oG5q1Pu-S;K+ZTE[ǨUAcFȚ"'?GmH H˱%IAq*$+=J0 (:u℗(VSghj)^~_|L SMiV7(13D4ftՑϥdPT IF;;=:Eo\o4BMry#4#n@>xv݇j||olu-Y͌qA4@afqfi @UAX*l">n=Yjg7>\FQp/1Qd_T/3E;<Jm܁$LhԑjPB3+H͙4IU :Cp="Į-yh1VF8 AC,Fd8ŕ6Qظ kZnk n]++KжKJ&9Yʂ É _Z-~AMLvk T(G_R@jitf溾ƨ5 T0.c@eR ʩg Fa@\R`* W c!E4+@2)\ _> [zfG'd&1HV$Lx~j7;CɾPm`. \GddWSxBL*ab\H lu 鏝icKL -GLT]cdQ^=ȀY @"6O<4]l(GHD$ݹZwS@/%[EFFa!Ij#q3NZ!єzB"LE1F<ƍ&thEX=iee_Ɨ&!jv-M<+dldTH0w HtCdbN$I!qS*_kI s5x]*e& Lj`j$@(C5;wpEK25VF &M=w *Ƅ\%Afsy+ď= ,;2fsdIM3\@Hp&}UPpyB)Z\dl@D-)䦌kL Btpe^YM)cQ#5c{.2F"O BMDTT;}rF X0PXCTyt[UDjW}1cvᬫCf3&dE ^TO4@+c =#Hu'RL 0PCK%IL̅|15 0#$ǡbRyf{䘯*[k pcNQ@2\]ء!<IXAo=`# ,y}+~Sބ•y>ґV.ɤ"Hp+(sjlF+?w7F74;2OO$tPv  Ax 1QV(6X4TgPo` >]1^W#BzșWyVz-j`Zk盦@Oe3A:E)߰ՒBFoNʿDt{,"yd7c,/:'5'Y,\q,@]fIV4a=GW&2&$4&w%FҕTA:OB]^s!2"Þ|Nǰ NA+MWTP$f,W+ӧSk>Zz/S@HG_#ex&jaŵi8Xbw8]UWNC7ΛV=bB6Z7RZWWw̫i7Rb3?5e#(,%* Iҵ$ozEd@FU/- {j%Y<@mtnjdJm H7Zɮ-Z?FZiqH6gu~=@{]eR&U@`:P8V&eJz`Zm]=&0L9.)eNv]4;pPT_[z@ۜgNx(ܹ.w^/U? L9dDTHw@a*"}Bhri7Bf=ɔe69 %*GvHm4F&B(8aPK{DKȰߙdbnIYiP1 bU!_0q.~~?=-̙,Phqx?r^.M d(+^BSUT5)%Li@ eM@)DueRnwU^ H, ]a d x7"!3olF6ӳJfA;Jĝٱ{>V(AUaE&~z+Gױ4Ė%mz RlTc/&# xͣO@6tr㉼p2ٺۤAY@.-菟XV6 ڎ^GtIv%n_I[֙ˑ]Tcѝ (tkQҵBQ"PXȵ{=^&eʝ}ǚ`@5DE…l[wŎ?e)YR!FV,.ô'cGg=vGŢ,8 N;~`4_lanu %sj#;H? nDd}WK,R1B[/ 4(qdLeΓT4 3? Gcd4 N#0IWI(N@-E|辴4ѰkG[N70Y>vjƖjqYw=lJ,7O+T[(?ϫh'D @.I* PajVyԡG*I+̌F ٵErI81 >zKJ 36TȀQ-F&iiK"A^ #+"GTh\ÎIGP]ynv)b>d3V;,42Z/1 ĥXlJ 4:ov`F(*bͳ& 70S",* _h` 8]ZȴF(r˷$t`0L,q-)Ύ.j2%1(=c!Pg(DPFSjSO,8B&QV><.FL66/?`(4މ})){7ShQ]L9WDFjR BЫw&vEzL5'*vb0J[7'-`3}K+ A1j0E LW* _#!dDѬ4 <ٌD-Q^ Ҝ<9x \%etz TkfrMxdjTX; r@ IqWLtsp /+< $eЦ,G~/;}E$)(2$z!(yi~V>wq1^ :JB6 B/'$\r<_VGA Fd rnsK Ld&Y 'jgO֟JqʧQ:y]|) *A%HLYqQGvD;4-(@Z!$9 C`8ȏOرtw$MfS=7t.=gSܗF\g2+4RPpT9j(pF:F" =XU`%0-'v/s틲 0.0McDFFjIS;l*{Uw檝`ɤΩ9fHqEvhwEi BpW 2w䭴iphh%D?YuBg/vxiCd."rESJ!(yo=)V%"m8")w:m2BXkO/Ú[ jcRY\|P \|)0yl Uzd(V;,2r@ba>NamV̰kYeCQn&5=|n *Fbgš]U8oV KaQP)z[drIl:HA1dLT7ݻ ~ўok0cؿW3q1_/wYwBA+qNPT18Y:P|)k:/O![Q^O'#3R.tr^sTܙ^!h3I5i#HLW?AEFTҘiBX8V:a`CGd *U[KnIa( Um' k)`YQŔCŞ,)|w#SoOVH%rUl +3v;Zy=$A0 H YS5vW%)QeB}:v4V+HKbT`4x)Go7Záe%@K oQ6,+ً8Ɣ; ^[rqEs߿ࠩљ-pq-XLg%Uvte)$cnE,ՎPQr{3RG2)pw3 ݄hZ Ĩns%4+.o[ Ee* `6ζr2:(GRd T"Xk,F[ =r iS_L0iꎭ F{&)pj;,FB#&,Vn@A "Ë"(QjwJ+NPA ->Qa&ŭ~T(v¼*5C:uðR7Rc^ld.4Go_q>|F b*T?JH +bkڛ q~eӕ'';NRx]ّ%>#'?|\ 8ÊN#IM~RxKCH)Y/9Rħj]}j.u*#1}''uA@ 鉆P Y 0 @da\W)2EB:a8 Y_j+1|8!F"ҳ8J$F4F}]Ixtei%٥gDNSWhjh`$,K8eq;TDP^$ G>Xr_wHQSmK1 UTB%79O ;+1%-t7*d 2/[n= I̼cu80i+zgZBUWD44@DFwŚe_hPUS3}jmа+'ϣɗ8c0J uZyzcZg.>J'XjXDV'#ݡ2FtZ)rz1A $}U|EYH$88i1WpEi`F42Ed;7F zl D6q:i~>,6'bNR6=U|`Nc˦@k`KTaоyDơM-HqPvVA@)3 P?d LZd2 tel1l; bvvŮdfv5_YeR[FD@۱/[ek]vK]W?6KߕА@S: N uO^Ua1k > zQ@GNT# |K#>ECZa4C:v"Cqe I2Zd0D5"-dGJgĒ,2,9hvlM l {.q%=ȠIRIRllU5#v Qo~=?NYjңX]Pfc|y%˂bR"EdGXB/z<8!a,=k +h qn{oً? eTM_ֶ莬)#<:dPaMTKW_&;6/=߈(IƔB h5Q cṗRy82D k"&Ӂs zL3A $RrHXP{R5ytd.A#{1`3z="D\io@͊* ,J(Q>im;EG%4{e}]A*لj?_[#jĒ0% `jFhPMLtQ^H\Nv}x:Zi^L?7-A)].IQuKq#dcRL+IC27,.ſ_b#mݯmvAٹTJjb8P@c~\Xmڴ? YmK%aZ6?-!vS'2Df@8_ϗN4DlCSunO֪`07h+V;Rkgm^V8X+dB,.{ +3bz`( -)y<Ѝ juN]P~G Ojm5 zW4":;e`0\oQ}k?*(Ϸ;HCc!BWǛ"Iv~_;'PN3,o:JN xI?>bzҿ~TuW0ٚ͑AJ5]Hem ԷZ;WWA)B=l>C)9r]CGƶP 90V޿BÍ 4rMl2@>z'x`8EN.`3򖐙iO.?>S1d4$/gM6wR=(!TGbUeJV9f-%mNOojjHWRbcRfR_e)w)Se 9b" r`],FRe-Ydx: 2'V]s%6%QT 뗤GQ>vqڜeG٦Ync)[)ܬbmEodn*T[p2 UoMh-|V۹EWuH"KD * hº~c`5NSZRҚt%_'v[%I/Ok,!B0d! g )~=]!84Jte;DAPU3 ,dD2X HIvU 25+ + XzGЪlh,?Y'.6$A6*$k0I #um#玔Z,|:ԧ0FTgv1H#s(ѽSMewUO_m6Z^F,dpJ !V&E&'^MdVc)px1I$]aUrԐSݟZJ8X(@ Dv;SoOTSLޟaqwwh[#It_@m8-#EXz,}r^喜Mus2DaVCKaHV @QI?A tF+Jʹu6!Ʌ Vj((@HPS3P ef1x"( B/x = o>l 1o-)okgoW;j>T*#d5cxOQӜ?/q?)>%ɪyj@  \sTI`PyRNA5umDnn3RchOχb (kҠF߬?l;qr\)^>aBAKk=RTn.)u 9yghR$ G  *-L^L"Ԅ (H5ݰϢM] (d3Fi5i zȇ:h_6L҆)Ez[I7`e:[zJ<}@ baf3UqKm4ѲeB D<=:0|yG] \ 1yq+~J˖3.S3dUK_T](ʗ--z/DooQGD4।A0}9S'KW=61f>n`|k,9F2&gBkƩ+H#+<=2NI"R/ISDaea^|M?I}@0[ !DWX1PxA_!dVW)("1O[ up؈97o \N:csEٚL V{}vi𲡪|Qf3Pׄ~|zwNHs yA/D䢔)H莽sڰZ+_on}[ >R_9GA":B1 RP6@ôvYQPnb'V_WKb >Jޟ@q~~>͚.-j !50E*^Iu+n/Ѹ:܍fz٩vΧ2ꩋ}:^#l,x/J 4bZ) zEҫdD8H3r*C<8Me]= ȧjh_ңFUzg{YVUcۖu^q52D쒾)eU B)$4{ĪjgAgeYXb\XtdzZCp+ i,%^yn RVP4nq& sG̈́D_wJ-w)˼MYu @ 5p|_O\MI;6ق:jvS zE0 IBBu "k9P[͍$t} >(0by\dwYK Kp.! <&6 _l0Gk<`e?e;zhUBP$_jP2ǒ;80t RU,ՅJOX f $P)K('5\.,LW&pFMXհj! ^api 9~cT̹Cݥ^4$"HҸhUa(GŤk?*>$Ԍ>E[xQ,洣*.tQIp.M*vy!.~Ap Y ٥e(.eq(eRtONUsZF_N[#j83Pn2UG5 3Z%ō DE %tYC"D*S+Zca* @ioo cBAHa4P ܜ EZy ^ I ZnXo8+O6] _﷫`*I0]$o_aO6Ym˟9bJΒ'IidO=bH&\H?/*.S'x 0$C$"+/ǎ2ħEF ,5y %ŝG?QP2C|D%UҜŕcJqAUz>VGf'Rfeœucg%Q`nc@ư17m#{^[\$<`DX$ib0ND'R,0aa#, K >`oVk-1&($Η"ǭ?:`! P*vP!H϶%oΠ]?Zt>7I|6ꨩO7ܼN[om:WUW쿋@@"NJ4:V7R53V35ͬ3W DP2/'5s80o6х'ҀP_̜H|swH"Պ*iX/gx4[OMti8_>CT@2SݵQ! ӑ&p}2S.)(;\@\MV!@@P`D`2U65fW32.n23,ztAz$'zOJ%j,?D;yyVulW\x]M(s8^W 3t:}s#eVɔΛ_0y UN6KYjףvQ~g1TV ;3~\·ZFP\%;.@>*AK sKU*q\GϢg,d3SA4* E$\gǙH*`f~o#'T( 0oҭ_H};;Ϟeԗ<>f GC=e],SR-psH>9_"` _@E0f0Q"X Ku:&U( ϸksDC c(J RJ7Dkz}Rbؑ/=rOVĵ=OW+iahPB%*:wgȌp4"Uzb$K6XcR!(8Ӣ\e`?&|ݜCkrd$ScR)"ʼ #<a gDZ-ȥ)ض(@\UA#CalPQ*uZbORʕEPP(G*vbox}V댓{o4ɞcнE/zw>eݙ"n ]U K9ffܩ>(V;]ΟgO. 0DRZRh*|&'\Zx7z7f盽j-c OPgh PAmo0wD4KѻVD(Q#@$ѢerStcS^j6#sh+8V6"cCp/}ߖR?vd4M#'C"xJG~ԑdTY2+ ggǰsȵ5<_:'yݩPx|' >G4L~"K,FD֠q4ASfّ*;?W,t"S@6 f^^E-Yw(J,JXSvltfL2 vA' AQ7 q@BIx&[*˟֯'MjHg[6Gk3ke6|J\MUerޏp%-xGHr\۔O2[+rIa *b4ш[\mFu!@`R%$v}jb@]P5$Xr7^J'&dGCr*ǚ "`Ua0ψ*$ f#B}TIs tZ0M%t[ ɈeE-rHpgbiX-IXdU-eՆ @\E]@I$SZ _RDꨇh0+W/m7 hh-L(Q 2˂Rr腼i*W.4œc|y .p);3ǡz_zh^ſwBNsijM]?9W?jݛ{[MEON5w8@C#;@Q3.G=T1*  rDxwB`Ru(Z˅XKLdGedC>X *ZB\0g% 12p>|d|3!I%o|̡djUA}2₮[}FF:/qWܰ0P(# H Pc]]q;y-Yݯ  @D2t#3ߤ݃~PjyMHstkRbXHK1Zi}i:dl+כ^tzۏ5~\ZGk ٰ#:с|QUñ~{+!f+;uU@%+uH1cMi#zX[ >xTQmĽBX3bd^1Xk B0%;= (ua]M :-.N m^럹gVG ?jOf0HRGj8l'$48UAQuq{LeQ#H49ھbfDa|gS0EeasM;?y ʗMrLWc_iÍ󆏷#l6S(摆JW z)QgF*cq 3<;]~HYVg¢XE=^Rq0k~;J6@j2 4xn!~jCB-@#˄it:,>±R[p2V[qy&ְ#gbLr2C5qyimWITQaDp\'d+2AZS */:!0gf)g$O”( (qPY@dEka[ 1:is 9B%X(əWA?pLMU0n9l ?<]L=~z~BpEY-Q@B/oOi9J vL7ѳ D4 |jɴI]d<),1C‚ʿCICO7(a Hz|Yy!á*bmv3W-Q,`NŌiiG o[ Z|6:|tg[dE >Y0eZf$%j yQks 4&bCmݬ֦㩸lQ$P:V&~jt1鴺9&d880lH~ېGbYZ鲠SF1M)x>6T , ӂv+PBMouP9CR! wjG E\$#XLj57xQeZM(@9_ G!KU֝;Iq;<10B(QɏbЈ]㊩"7KBh XgqԾ @5LB$GFL0fdrai=K=`wăOͼc$j4Х2 $BXϿgea?6a 6m㐸ͿDmA^ěH1&n8O-{rXv O}w@.f憞a^pJPK1 $jcy71G+vd8Tl0&!0hv ֠@c^0 tVVUj X>7hBzilW\ڿ[[Kj* ~Gun~doEst]G=!tblӘ% 5DDE:4i>&KSL "QRqe!Psd"Yk 1|M&="g $Y!6* OߗnA1oL~uRix0 $6(b \  p$MQ%Ȁ`ű a4E-:{8NHK7)L5)7jCM]t3:v9( (SkVñ?&s?1E C2̶͈Ynr9U=Y]~7_ؔuW13sܳ)sOhJ@aPLt F@LD9DȀ#IyۂV?_[dGDxq^G"G&%ܓz|`!P% b4,f!gbubh¼5p-r̓dIw|`1go0zg8vl%V*WrlʦʘW۷H GhUfDD32F@ m]L7e2Qt貨L):sD$5_U\FTV)Q;&RAO {#;S7Kf%"Gļs)k7StV+3<{Z&1G5-mjvǖs:sVYϟ]C5wy8C2Eg$d!Gf L/uYPޘyޯŕXnN4mr!7od!i\Ya)_Q mǙ <<\jj,8$g֯EN!Ymcfc\M.0UqtO"X:-,KS,+!(X:7FhBa-e$Țg`ۧNm[x! N(:ojBKO$ʉ-MD#՘T҅(OHr]F,)B2 g4av329T:HHtG`0HOe]> wOxDzarZ\:ܛ-.éy2RS"P0L' n6Ȗ.U(>I] $NHTEeY%Vj:yķ=_TvSԲu3p"뫔P!#W}ȫu2;S?}^Y Ęn@9ᩋ,a\ Q1dKX 3r+C*"N7^ȸ*p%};5}4h6,a) 5eW= mW2H F_(}&&糉1Q]—raTMp2'# pk7\@Fg?YvV FV,%d.0f \_,o@䋬 'frqUUV╔BXo>b`<@P@uqRd61YS6=#J eL O@Аk(>FTDrNhj4?vg?/pVcn phT8t$0Нce7riY]R4Ay$<՞ jzZ[EsЍ4AP%ȕ d3t8،灇CNL{bFFr uG)_>,*Uc &^Zk$s3WG^\Ԇ8i;ydqzܞ}%ej(jhfc! t9eg6]ˍEi(d෥bLB5wљ7>&U$®D8CE)}dRYY `7[ `l<̀ 쨃 H4챢Tɢ~G51i'})hwMNֳ?6MvwQVNhv TO &!o9z  &X".f6D @Ax| b.Y *2 FX| [QPy͒4Ș*ƭcBӇO v*"#sW Z_2pW;ҝ#O\Ƶj Xh1I@BCn怤A- ›%o~L)}-T%kM!I-X5o VŴi:$Ci߼Q` h++Tdi@Ap8]  Pk@n(t/{#NF`MLZx׹E2j 4+ZuNVv^;xyD\7R{G|U4Pm!$J>5!7}q gӬAʔb$j^3Do%X=m(.ԝC.s20V3AmD<J&hX|"pӸYq׮ԣmCmWLPLhj2;$iN`5(%؄%D  ו\'\<[θ>l}C\{-Bm=NŽVӿS F m Dpk" Ad?\i*9=$# !h9(wvRsY] $A@'j\h6i#UO\Aל q8UC9cgGUb"˷vw]5t#v9%tc> [H &䝄D;E$ںXP`pD3 !XEW1l;\\ʦ}\Y׫d€ZHX@8"+JicT40ϫsb3f 4Y SJKJQDK,p`D*|Gjmu9̖?o=7Q4ᕗJD͠=L&S@$? $.mRsH>kp˜!CnjrT8q*"1vDJ%^d643}7уoZ$>?ޮGI@轥 1Y:R_S7]ݟf",Oi߫קA`i#c: ($/)ઢFe.y-7;]VŮ2y uǽmc#P+d/s[嶎HKq#^_vY`EV._x7ը;zPMZD/ +Pt" e.Ą=C BL @ЀI(\X2TQ]iWFhQ]&ЯdVV;&4="SV0ȼm$DZF:[-t}0EtGo]ٗAJͲ+Q?bꌢy;Oa)'!Ih^ p@(0u$[s.j9tc 0 3AI" V$cZκ2w ,Wp -G6G}=jf|R^W_KBկ)PϦ}:JZh-[LfJl`/*Hk]t<[xʦ!ELBR9-2!]!b1°DNAˠdkT.6QmGZ4504/jdÊ%i%?А@+)|Y 1SgbDȉcW'|n1$+{,#䦌9SkexնJ՛q=O @X (D^"m1S@0H,P4]ŘMZ!`iR瘪d?k/Z/+]!S"DbψLt æ>c)۳\`r4tDCLJI>T 8TwWYJye2d_Uk(`1+ aS=45,x=Xl&0Q%-d\Ϫ-K:_WIeG<ڻNRC1GoӮ=lF#ݵa,|\BA7u#hP hkhggs.RY҇?t1 v00ha3.ea< N(۟6NT)hSޤԽ-O:gƮOM8I?uH/F6z t?/Pd$$$_ ! rȠ,H@QBzrGm ͬCޥ63&5 p!""`OT MnP%-IXuWSnXibq((dHTSO,.%}<-Nmy10\:x~Hn fUNK"t1_2k|w^H֨_^b~1 HŃT43ubME9Wɴ2ަV+kijEdubY@{b/%"NEGZmbnjb(4) U U1⑉h~d3 n_+dBTkl,,ak% qS=ȫo}/^n7o,~[,풑˘A8g3fA2a5 VQJ^h `CNgAwq]B@lCc+hɕEcm ܊$ԉ^i9H)@DQ@f!#?x';Qf8L%(,[Hƛ_?8?AfVĘuFгR|C]-8w[W1WPDOM4f@LMAGQ9- ?;cڡ5#D fmv-dQVUcF0.BK#oa$oĈl@5P 0 ̄VMAJg)rC?R-f{2b& ;[TXKq;J(0RN~tJ `Թ?(]t16yH$޼9$&LzvuMI؉ Hqpz;L% GjxG&)cj~po=c/5Qj]hߘa* Iw1dљUQT>,I3uCN}BN['n.Cq嚭FRe5^;pgkQ`4.prKR e;d̀[Vqv1=lqΈp_^$uVoKh4|]MN Z8{Uevzw:sc'rnBD^a7|yP"܂iZb6 Mrz";nb>vٞfms2M( 8. ?3p7(]zanhm}OJMh:wH,|,|y )o ( 4#e/-S_Lh1$Rf/d$E ji&d%g< c? 9u"U:gG# = ua2$zQacU^d܂#)5Z@3+=Կ[,6Pnud5jvf5wF\vޮ[Ί/o0V(wkf%kk]uc =;U=wݣn|}JP&frjFhhg)Gl]<์MZ.pT$uh~}t3ĸA<%h$$+3F\FN_XYxTfr}gvZrV N洊opJZn9;bIioz?KFy5A;#M* '4@b cP pׄS1sƳK`Nx{XdwjQ0|DG^Qa n< OijPS4:ƱΗ:3C7pMj_0 AaW;oW-lbD 2`j1H@Lhg00xu:^-{(-ަce)Kog2(Dw݀\ŘyH{bEshGqƆ ^iZ/ZRZsu_f?ةogU6n0X/Y'oYYl]K]MN=c=f`pC Tʀ# + PG@dK0EHaVӚZ2o}Ï-D< # Hllp#ذgS3di NRc,3b0%Z<"P9}K1 jFL;k6w/ 4N(5)"0tme4IyNf4JZDq#`N ŃÁ Ag4=9Dv:pJ7UK[yF;# ~*&"PJD@GBY * 6թ -x4Yl̵ÌD\P2H b#@\ (a eԛ-=8 f ]A{q)ç=50Ofa0D,@ zAEy h7Aɔu^]0p1:HYaU@@d%6.#:d^2`VsFM#-_k>un8xǂ lJ .B@13`4R"3PPR5 B~**]3De|mh3Rg7 )o@i"G=u~:LXUV4.TVTO{y JChƋX6]Z .=YRQ[OQ??ol%jI\Mde?aԜ(GݸNN`!)Ab'|<U JH\Y:K>9({\76nxR3SHRET(D:/{%i#F A+Tҽ"V*-nc e12ld86KW,{r+J/a,1&Ȓ焐ڠM=RTg4;>w abW3qM;,:ETp)lA!U=*/F8Q_ޑ)W,j %*o<U`1'kpXY& ' `Fl 6*H HAR 9rZp&E $@(4}]7H&&r)NIY0\bYH~e11TBsߨ-Hwڬށyx?u07;*$ b-5r",Lv#xWk½ ?($I ${.O!G[ed#Fc 4*D el0dܖ$wQtVwlc@y4? ##z&-z5ӳͬɂ"ܨnG P !؄g)+cjgg@biCO[ KH'rQ:Al!~Eɴ0h"HXcI(H n,_kOZbjAP HX0X*,P5B5&32#+we⨫fj l@F*G9R}pkV{O Tv{H1o}"CqTTѱd'7Z{0-& Y46c<ej (ڳ R#GC‹H`g $UD@Ae`&OgFW^0 ˨8)*UJXA}-<<}H+"em~|BWo27icR3PUIS9y F.ۍmؿ{X~Mn*,joJROO%~Yx\ C_!SgctU3X`bB>Ub5E6I62~f׭\wJR<2jgEC}q2Rzd1MBZy+bv0 %gv@)Q@g󿭹`9B16PX@p@?kL|SN@D ~B>E .ا-*QqRc{toz+u-SW]UQ5~YiE bcڕDcGLbR}=_,]]uAp[-RׯG"3ϥB_RB *aUBN[1T0RDZ4 % |P(,ɗ#rzۿ=zD\9N^1C`ePh0{r%1-E8zg;; d`1Zy+3C*" k砵Ǎja`WƎM$3L~w EG~m3ݍQ~ P@W1T於v3wYpxXɴn,[>uz*f% I|Dmб1bS?! zVYO>3([}Uu8hoFtQ~~?:߱Z(j@B #]Y 7.B`1ao/MPCz߶\ɂJ<Л <wd86{ȁ aD^q_N޸/ r&D&Tͧ׵Ku"; 21yLS#S+I ΈmP0d{J[ET-J$% 9c$RΎ h!~#?;ҺZS[OοX QdS!D@M5<H:B2[ nSayhI)K4rF!O]ډI?\08:Dks H!Y ;>ьr4L;D ha yw"lOI6TpuLP!7גhJLh/A"$>gQ8U厛$0aܢ]$讬gBwH-!]:?FvrU%Sܟ,Q1АvZEx\hZilR)Zd1R5cz<#N #WM0K.m‚*@!$ߖ80*McF!'uz@dt"=Z9? \b CU[(Yf*4,?nRRaW` vU\WGGsQo\h೟oCE}64Z)Cg}11޿EڢT<9MWeu)ma DƔ-hܡYK~臦Ⱥtx@ +1iź{F c¡].܆~*<e~=媁@P dIX]-$]0%9#i,ό&PQWd2Ҷnc‚ڦ1qbҥT8:s38&>}}uGr31R2&)4Ϙ>  R /? $*.ԏM4XSaBBj>#;,V8Ih%2W*"P?P@fUG?f}PNp;J vTfFT9JkXf?6[JŢ+P2s9P DQY4u b>#iN"g! d3$wXCz?BlǩwB#-b2"NK(8N{bgd(Zi,D2 1% _< 018N,˅1R"pM*HJ)RJ+ImZX۽؆ r&ᤎ- @"wI:>ʋq7_0 s,eсAM:I_!YvB.@d~`F!Yo&'5 w廞>X:bd/wpڪBkQۢOe櫭o,Vݟyw|dπGVkMR0k ##[ k`jDR(L ۓN ݕE\[2a6AD. *{xH[tI9fWx> .ҰL+D~y1ÁŽy* IB`$&.P`Kn*n5hiȘ-gK" y iak$qI drHCO.,a|<# %!Y p!$>81%"pam9ahj ē4'EZ+`ݚ+iIv4{]UƊj,=.o/&#[:K냉@8 c4P|э(f6OXR'Odӥ= s : 5Qz4ԓ*>Ƅ!Čy!aI$H^I\cw9睉 XD Kl [X`Bə6RSAY~Qr}Re^-Bm˷o L)аKmZe{"2Q J>7"#tgaWh>SHP'übK dق^ԃl6p{m@c,0k-H(HGc_kAc+2"4-jQ6b&$Tp rb JQ$rPڈTQve.{bqsUՂzk؄2rrzpm\Ies`]c9Db,2'p_Cl_fnҔuƂ]dcKDN\ʯ,畂۸~ hĀ3F`Ǡ,ʹ"`cdM`]Lb C' MD : ðŢnZrEL{#+[!*q:,]_WJx6,ޯ8%;((F珒=fE^ptLNd4V/2K˽ U%V-=2 er_,6j8(1 BR<)[ bы Qv}rREɛIo,TT :|m#:*"!.rN_MO 2$En$`o j&hܚ0-)WQ '򽛩 A"_R/JHx)^Oos&7jϥb};\vi& G9Bw:WG/5$(qKyc˞+ @ }PMs5 ,lP?Po S`cA`|Hp57åR1XaR~UJ)ie(W]Z.d~~BUOLr7;]k]U0 h P"]P]**6])emvRCLLξN:{vyt&eXL@cDwFVPz Rj"BRkia9aP]GN|ksZH]"AfHaa'i춭4*Ǜ~X)2gP$܆f0Jdl:!!@^z *>qUPd*}qSf@ΥS*IbMmxIҒO+@BOg[L5t(zd.XAOs`F4dg>Vc/BI#=#-%[M= (C,TZU'Nt%I4f-|&I3Gkkeȯ|kz4͠r#kM_1S-%SBSS5UtdMXW(rD <¤|YLrXs*yd{NϥK_FLb (CKrL*_4ƇR2*ܬCCs[:Y1,h&}iώj(_TltMn}uIBjnNTB'(;&.;]ws7=o_B?b&?JqUΰ"d@Hn$ o8imr-{%¿8c@aB ΊaV8s&ǩz[;/i&n.gra{J?\v$lf+XaL0*_W9,kNjOJFdUhd9Xk)<>;);]L@/ xF)(Wr|<=mǍE)pR¡ҩ-  « ]RIO䒻\Dvs1%]ht!g<&~9yiWѼQx BbkdЭ;2*Nɤ5gIlE6;QWgZ:ڏg қ7[SP/<ݞNqBZ/"%Í}.;W2D <+fJ" @ `LJr*̪Uҝu.ӎN0k31 ; ouszf^ ٮ]D感Jah`ޅMd43 45C:1 !O_LU 7lN*AlsMEp\hQ 'oqH^έE"!p߷ dZX$@)ޣ)4'i!F~F^tX!U#Ž8؃UqU*>ڙPk#ݑ!ز۪2k.)ŃCB' Ƥ![T>y1"/ Df=" ]k'u+xe_8r4 t\"Az1Qe|3k<HjS+gL4$4(\ IBkyc&`.~HE/n^ 4QI62ZP`0aC! Kk [hB=+R.̌Gs?ܞ{6F79{ u $s/Ŋd1HV!b2|z4|Q)⤐+aA``UO @e2}+hp--.UƏ^2a,j/8Gq}dh`IKE' 9ҚAB@=pd5#S 1A >, uO_LGC+ sLuJ\ڌ%{%Cj9g ˟kX$I([Uh`@ J9ʈ4lZ1<;:00润S#MF[]*w}+BF)R3S1HV+wcˆ6brC^I131P F˚5dJhg QYU;ɁH0o2ljXARJRb` \]4U&-V|mROև]Icw{HG\$ j@80>^U |%1 )8XL;d; WS/HP I0bOa0I?lv$\& -!ې%jTߛٙu-S#1ʨ^IPl(sQfzzU_vvmuq*xIYڷITZ!4Ky(2K9wIYUtvcWX8v=؏ ˼;LZ&TT2{bbMtVk;iV xRaC?XX [eURrpcl:x NMuJeed%uI2ۭӹdI\KJ&l}K*}EfhU㇅1II"%Kd5WS,IP=D:=\ u1 kr݃ڔmՋѬC Sz/N0鍦 ҐmM-wCpޡ5G$JjGHPb5}Z*yơ|Y 1̇o$|@IHh4P0*qb`?^KPf ]T@L@0oZPXm :בˑ?߁0dˎ$a"Ti!uDf E$MmmيTNAWA5@=hhxPM' @e1['ܐ~X|OJU097u˗eu/gܩ a͏9-=ogמ~|KcƯDB|]n=`J4 ,rO,0g>H>@7F:xa)¹-JqD (\\9üaK ;LAfe &n0Lm1q; k:)$!&MJ.'C1r$,d^k"feZP:;)3v";gwV}Ws:3` !&Zj5Y '}'i5!b=5z7wףN 01{`?^v,:ft4W^&n!a  Bm/>b<0ZR#2k`!c(:_WBU1.SiſLmhHfCdM(*Eb&D g[ @$35.Cw%`>T}0"1R]eD'4HzU&RC04Rb&lBΘ$upH>xt5D#n`'p )T,e*.NJXDhAĎ)(13?'H(8 Bɜͱl.CUzfݫ";6iRenb*I, p;hLlM@br:dFr&M9t]Q"e+@i,Bxl]gB d Z :J 0b aL0e0->z<蹑*cu\:й~ w2BnDXB@( -ވ1~5]#Y/?# ;N^'^wzE=dG?{loT0Pu*@A`|hèY!UIMos^li,rbS̍|?// !pdT"1oZ_.  âO< 'KVR-; Y4Y&B?QL"$7^Ob,d@!&䱊OQ4Ca 0ڪ*IzG~ؖ?}7MpQ{iJO`di;(jrJpB>Bz%BT7(d8A2q-Mm(God09PJa Ihg^0A(mF<'{6 `S(b̓I4"5P7x\g޿##}8Y _-"1y'Z^{!2"C uDy2(0:3a)jO p>Xz mtXF-P bMA䈓ShXK We/k:ڪ"Ԉa_E#7lUj/@u^?cG rմRLʛfVXBQNyįM}۴+Y"~#piX,j:ղ8~+Nf0,<q\t&Ix4&3d9",W G=#fIs_l$iAj0 2R0e:mi'ْ.}T28@_"|1a  rسlj'2lbmʮ0 08+2DCm;*[P@ЃBdTx Ao"آp 0tI]Q>po*wZp`ФR IM ^ @Y@d(}8~KV aYAS Ie/GiK@`bdj u4}&TR:d3]WrТŊ-fb& p(\ @d%E]>dPY=k]" m SqDq'@đթ+WR䋈fVR]`ր?vQ̟oh*wFЍ3ڠ4\>*x,T2e }n 9PH25@HCMLO*$NAWD 6D# ' Gsb(5) .gj>]rCI"Ψs*j1AS]wٕqP+T dXB⾄ B > 6'0~w*ir#BN)4C-n;I$lF0 i@d1q$3vdjHk `6A[%tU[LŁPA}J"HAwZO#\~ Q͍,-@gdDaJ,Onٽ\N$zOTRStu)sFC:Q'w53zGAP3,=oסNDr#Ѓy* PZoV bo FZUGN0Z&j\|m%-b|6}Q'[  @u [T0Dx6\H-XV[V]zq :) ,-mPQ^d`5^{3w& j9fped"JYSIb/i]ŭt2,j}Źyu=8#B3sYMp#fu&+[D&Id1fqW%uĉzM(ƺxdq~u&lHn>i~ibō5v $Jۺ[^S*9't!֩~3!v,bK 'kmR7rƇ:h'Zٺk=OsN[*c:[uK[v3e?bp{}eeXfCAD#j Ψl]ZāukTB_DByV8 _({=l^QB+s&Zc $#:$=]$D.@j~؇Afyzc1+LDGT7C??]hW}Y{Kwժٱxpf /]*\AbQc'ۆ"DajX~aK{?0IHmq?``8i^P m"D1rGNznT< 떞d ZI11d;T˝ p\_ywU+ߗ}Y=zmnNn2䶽k1-'-0rO:f5ڵwK1ՙw# $t@P= 'j vb@F#N9˜֕ >2SpQaW5?okc"C ^#`Q҈;2ұ[E.eW9< fȽU"I!EޣlYpUYweBQk Ol?ZÜe'Xd YqYZ!O;"rAqS#05db\+)s&E$z)/S}]:9b--rFvE<.X똗2DF + {)_p hz Vrۺ:n'VaLHT H&bfPFhKlPuWہC(sWt(ۓb}2C4# "ҧls,"W57#&}".T?z'* KiPD ,Vq3Q0fj WKh*Čn%|_H0?Am_||WUJAJ|wzZ3_WfL xe"@vó{:`rrlSq`gk*Mz g}|s_"aI <.j/r@&b aetߏvy9E#LYYqK"81>1j@XAcK@5PT< JRtD N{ELgH( `s䤫YրAlt"*|94 HCvxYτ7K$_U<˼D%7lP"p+PAB#3w- Ccd%sB0#ET<Y[O,,N:=iᭃvVK%qV˦32GEro%_# Th]Z!:b y4yWt8ͬ_ZU*A7J~D o!W4b B(hTd9%Sy29 ?f$ Emԅ8W֠P$PH'hA=ؚ8qAKw6Fl2 UFI`J4ԥUsvigVAr܏QU4Q\P,k:SDH`z."R KX`_4iA^fɡ׽Y$6[{C+e8XD0ɪ'1c(!1@PKT=e_F/cQwűbUzS؇r8,j`D, wTb!!pH`*!^pßA!1!1͂ yoГic# :dQ"(x,p6 b T=L┦`MJ <ȸi(8qrYWFIפԢD!Y]їn@O[pCwzGT?}Q_S1HZy$ \#:ܯ:0q(\7cI0N\ ]dH#J Jd2Lr98b< 81tdPVD<y!Lx9X3Jw?bEt_Id^g˶˄$rY\k! / +,xn a4cG kK\kgvw֥bYEԴZT4^6i)'-jSWqS yegfbPPYN(GRYdMhaFkw)wt6 T-I+9[:( njcc!)"2ePcJ83.XhCXhYZZq#GSDV 5/V4Azb j_jd`^=TY(o{؏mP~Ӹ]at<秡oV2IɉFo;x6/kn:*+JEv3O7F/?RmxNXխZgm2/r1,kq}^_-ܿ/nݾ04"wV5߿S3C҄;4+AB nEDS駿}(TF!6|eF5,$\0 *~)LuEF1m%g: &֩C5F53ñ_?ԙ\kC#U^ꩣ!7^L;_c + ÁHC/R_@@ ( /j#?쒀L dO8Gg`,d9 нqf4BM"')D3{#l Mp[FcTs}M1+zDws#QݡZ( XHb&^2` :bGlKJ00FH ^F7AhfȤB?#q"dOb9 *FY (A IH'6kbXĤC]-'(m 8ByhN}O. 0ekB"5j/G=CvaԌ,O*]ҬܝO* <<%4iv~{6A@GJѢcD.X- H4l,HNdgHYc H-'J"4ޚ!'9'I'?nZόry(Uϯ2DVv"D$!NK b4/&6z,ImxԺSW?:}=u`T,5A.2r<+4KIdkGIXc/HR, ]1_#@dWzM (s*A1ѳ̒:@' =>"F"Iy7~} ='@#y>Ȗ,}dK#].k~ !\`>0|D+?^CI]+ P$5%i}?o=BBX!Tm͈  ]Iע42Ex+ 켆QVe)L,dV~zuQX,\TS%WR$8x03/"F6Nk*$ރ?/5w\P"L՚JM VV߼ 3?(E)A 1@Z:J.O Q?"S%q8&,!If"DIS`c(օ%EBVv8d&AiF'E^ SOIs}@|+c*kYX ܺ 5Z(?#..SYuEq9B)3U]w޿Qz ΀]n: #$v!)bl4Q2T,)2P_1Q F, HRuA.S5r> 6,8#CFF=­ q2@W>گ;ep ZTQ XPA Q;ЈoϸdUu4TA#ks ҁQixVX6l(Ta $BvQN; dLT 2 |" Om o-D;bS*9v#cEʀ,:KⵗoELHc<̧5qAJoƨĊlŽ\!gZfVJ mqD5_8٩G9r5d@n+[]u TIxUkKE<2 ƹM+ULAME3.100UUUUUUUUUUUUUUV 88YZhB]ıI/`\=d RIVE,i6KIMڒZ9Ў`#X.M _lFYX0_=^c[d-Ryp%e@|"!>%H p4*w N { }~2m"fD; N*zFZ~bD\$CtАjGa48^|(2j&DƢHm\Y<&dxQ"E,+dVZs=[:An0&j(0n5Tr_He|*Hތv*,^*Q$'jP"q-+YT)@G%ƳЍzS}Wfp j6rC3Ph9C&0$Or%@\'5l0рTp *0Lej1HJbZ^H:>.F&bN pn,M)Hb AAB+RXVl7(BV=it-{bq[B$K2%+2L`fI}"H2VYyYdX ̃ K(1dY~A` AB% /|b]|]wD詃ѝѣ9jPe:sy+׌ Pʙr _S4'UM-jtՐLr:(Åz >q-f`€SDc[G} Ѽ*S+dvKLC 4%0|+00 YpY2l<(cx'3qL`,"nL33;pq{HDVCHq vpCmN3vLJt,Ce`JJB41TTELR~>e"UϗhNPuj @<옐Gdn >?UqW B1??RaSm M)$d貪ĪEv#˓T̊{Z=|2"T{#!S$^tV>d-2;NN!ة\ss{yo3͔bd?C7fM&$y;Ⱦ)DrŽumγMGKiyL;̜¬w~)@ͅH),r(,]+"Ä1 BD2;.sua3߆.FKL˹Fn;D/ c,D,(BUHmܽkLO{Y\"Vwv!;f'SLK"TM<=! 'F=ɱ6?g/im}dYJ42$YjUk/l0vȠⱖtzw-B 4B: LZơ vpB#PbsIk,RT)1TH(C=dK6ŋjлYFT"qS0"rF@ش*H*˚l c"z,󱘸U?D}QLP2Q4 M X$-5Z@Vu0jf q{D٪wA6rnZ}Bs|ȸؿjRFyD&A UvfDp`0ljMe<4KD-0j^"1D 3T|a }5,m$5`@UdۇYJC/YM+) HdLL mhAC׼ zi>PCIO!t< bN;P8{}'@4C0ZL ZT?(ݵSE"6pLM7]Lh<weCR*1wQJZJ$_$>%}<a|l&d^=,#*VcPpf3O xiOXL+hCʷ=|~<|Iij '˿ W!6:4Hw#h:6@ћiE$HXm*?lPwbA:BGAU$Ff1P.mEUʍlÙXЌJw_eKj^"*443f< ᛖZ>V#Hȩ3FDd=`!ai3129 QNbB'BkVWo2~~+0Cz$r$VhZ5KbujK;i^Y׎~Xv>時GSFĮuU,΋=[do|SW1_po>oV]1&'@ SXG@[=)0ML R۾0٩h)}ߘKPd]ScgL-`"-#Y hx@ $&rLV)"F Dj\ ,?[6b&*,]$2ŕ<{>6gv{f d;bvR],.@y M2b;Uo_WwUk,^ zt.L<QrID CbO+E0Q>L߽H|pȟF>“ r@ h:d߱9BA`C `a` $7ܒwgT?7! 9M!U0m*R봪8faгd2Vs Cr.Z]="OY$@(\ PeM;ZB_HTv u ?Mb=VbhJc 8Q@йU-<[Z#&~Q&df0l n7GԼ)IgwO7o=E`P@!i%M:.LUpsˤϙ ȶh2 lrOvJ'UBQ-1ڷXdTz_TVZc+q@xpXX效gR?G,:d;Q"0 -tV٨oM?/?gM_OU h 9JU2Q~1wz;$>ȢdXRcD3Z9) q GqI p ч~-LNNH=1a4R[ۼ4_zʴ3xǠR|0`scT]zfFj(oiɀx&(ICI:jk`DJ,,ĉ(x @*!DƟ(Z.%} j )!8zt2-Qűk}` "sIV2i^]vWL]~_zpK@"M#Rƛ e5A/Gч:I1(TZV8YoCT@`Bj&*%JIX$YddDOKA>bZ?1* 4AIAV&H9{5dSD+lzƑQägTcd4XVM-n]z]-uUtFVS/Ǔ]3j4 vB tݒj6LBmHѥP]!/B$(,h@IrO(c:;`PЌP2+0; -Bw4]L"JxBQrXY/9B1&E%:qՊBSYh hB<˜<BCb At& Xp4i1GU^V@ ~5 S\ȆSжr2^_GF4Mئ$ma0E?^b$œp4AeC/V*0s 'o*dq A(p<#&d 2m0J 0Y[}ƀ -0>e% SQ&pѡv8|\:ye|R;NDRHcԫ/J-eh¤·M 8B!O^GL4x!yR`(,biGHXS "x49H5uzPTH$/t O`P.7[?њ=Ds˽`hI)bP)/ˊ:n xB[6U Lˆ6W,G . @`;=lH#mŞ_7P\ywµxfdN@h.Mи.& BPrd JZw=0 KyaǙhĤ+)Zފ)#vEo2Nƶ{&T̓RREkfrN+IZԚ )I:N5f$h($L@D4F>+'] Mh@.H@#DMX|i-cѡ%D\x0WNrO\ H:\Er;*e2alϺvr]5)0`3ܲ$x;E]]{i.|l,懊 8@ '^heF0]hVQ-^*YGрQ a-)D9 #!~Y x#ou!BE0|C@ZuItd IXq72/!J<"(=!Z0qHk4NtDx`y2 <6GĚjf]y?,B#z]ة1*s;H\^N'R\Ԡ4H.`@.H#37jg8}>юORѷ ] HhOn#)$ E9Ne7B.pC65uJ3P'B LǘA*ö^;FlZUMʆ)7 aƤ$D X;@{5D(,-̄Q+_FQ)FD h;rb73v-!GWU0fW2X`3ϲJ&dWOAR.Z`"3U_1HLj%S,04Id1PcC P.,mH靮>=넪sݘJ?!ij(=q3;ؠ:LS H P! wt5xq)|\O(mJT !Ps4J@@ӏ7 BJg]B8+R?*]H[gJe.e>Y>;㵮鑸kh~,yh3h2,i,E>oWMN)MZA(r%0Q0kBF2Fr]_.I#%8 @Qב:Am([rodYc+D@,j&_-jt#0^:VjHЩy3ޯT` 5 .:Ivfgj/nJd:h&,, <a />%ґ~eM" ڻɵ '9t)pdrF_%&a(%9w7å>mJq(! j=-t1Ӡ/%_F*UZ?kv]ZۧPQխ{ѿ*dbJp",GJHɫvdwoe7UWZ `W^V'$)2l3h".A=og 8B:HlBnqhV̬JxП*6AP6c/mdABYXk ,@3a-D ]$WY.}w@(_P:$Jg N!D2vm"h,%~uY- r륩39""s$Fu1ȄnmMO`m@D FG/TA]vzzDETyR~$>Yh`L>;_8߸H%ʕ.7_ 9w{2p:8L#BL1,GakuL鰊OH(zӪΚqL8ԥ`Go(R2D' I9d]ZX{),@0Z0Ba,Q@ *t Sk(7j5Yt]i~wI#CCڕ[]jQT3^-*74`.qӒWX.sH侥̪0$${r!㴡#9v= ':x~& )Z99**o=6-i!aii{v\y S-nEC&i9a,KmɻM90?yu_,PP&k><6Mː)).U@erYP# >yA:al#j+ pwtԿ\V? srj(bd{?Xk ,@3 <_,sĕtsS,B-]|:':Q& U$#C3=Un.lkj=| L_P` N9Y)'.6Wn7::GГֈxL!+םK#pke'R + n"D;v너@q1­gtl@BB;IM .G}%S"1{WlOnauA'Rukd O6uTH0< TqؠE5& B:2+WKGZȇb$ʦVe)%9E$ci_c["T@@()@HmV;+HWN"9 Q^@=Dј9!H>ձxd>2Cq.!X5ԦwF+OޟL YGeI}r `90@F7 `7^wKSA!VD*hv@Q58iTAJrZi{h40H8GbdXVk),@/#z:\VIA j4{\7ɒ,ĩLC9RLDNA$GfKIG=.֤izn^kvlB r,+]86 j>,7hEjFKZV},o#Oy[)w 0)\f)wƠֺoOf &EPYgJddJM}l8 + xUcq`f:Tz.LNCpoGjH "85i)-$PQM 4ƙ1Lk`޿Ҷ>b/UPE,>@  1qn b\G*:nDBDFdd; j^V )6#[Z#WV-z,%xfT(5Ϝv:iڎ607DF95Xi-Ve!:%~ Ab^)wwǼ.hװ0s6)  QjUUi3/Q^$buk=q]SW*;OK~߁^~+_.KբSiOeu]̛7T9ThA bqJcOC$ wd7I1lPPX## haJ)§3/i,07Uԥc"M &lXauuۋ.%F3}8y/0l$؄^Piw4dUXK,ABl.n(KA)[( ہ nm+KH53ն,7Tq#IU)9r3|w?RfG W0"`!@ ` AnbRs1ޕȕَ*KQQ` Ei?'=[@$,^ޡ^:@"4% apIpeR Xl(׮2:2tan0PXr:g*`L`$L*UF]LJJZA("Kkd )&k RJ =. uuE2 k蔘v.(A»> VMRW8YyߴOSr7Vtx $о  _3F0U(W©GNUonQn"E3:o(8yS}3ĖUQqd_2,hw˼"a*6El# 6iB RvzW)'^ڟ+$LE{Pk@ĦLdbI^ܕ)Xi: a2SLACǭGȗyPV* JN9@uP|Nb"?d"^X rTC=d waL$kAH촗I&IlE;]Rh!N SڮEȩe%̭)LtN]^n>e`@BU\+!Ihc R{mL)6Bcٴ:sce#C&s?9dE)P|)P=8l<`(RЁ vT.Ģ$3Y :92#+f7"Ł (;x lkȩ,Oѧ%Q⥂ IQ JHNM- F>bc:װGQG8me,t2}2\(y-QD$) Ve9fYED] d 2W;,1CJ=L aksO8AΰHC+s#) )Pbl$=l yqBp`T"  ٦vOϨf8Hʔҝt,h`JbtI,rQ,I_e3}0Lp$эZ_m >`a6hN Bf!'(3vvVw>tVeF3/\[C z8Т}JWJmVCHKGw5Ve, WuK쒽SH:-dۣJz|X#da >#wFzou J-BxeO۾MJ)ZܡTR N\_PNpd V PD%ax5g `Qc).nWDGT.% 0B @pŢP{k@ѴBl@\0.zMo}Җ<5o  @X>t $J(s[tF2%Vz @Ш0Af˨@>5ԹqB[콝k,*=RAJGά- ǁДSAPK 8w}+sn}yʇ . Q$ sCTI"V٥Uwa9Ta٤ X_"q - Bw%2^F*@/A1{ԳTH\a}NtIuJP1A?̥л5,6d|Ha;=6 )%b0e+ h+A )`SB3TY-Lljvێ0 6.1sa+ץCiedb;YSB1 % %g Q@דhp`[ |ºG?;RSDmVGsRڟTwW 2GI_Q@[`U(4&t̰{H@r)^ގ?G)2uڅ#> .ꉹV-bv!@^?pgC$Ǎ2vc{m_l-D[=3pTOӂCwjw2gTpO K[]\1~&Pi)_1jMH T<#q: `U!#?'6%r;;j");HeH{[@hЉJ'$d{#HX 4D:+%ɖ˩傯-DPy镵Ę#9~ʾfg}8׍Q[ʱRֵs@6w+][Z΂%XKgq+S*wyZCIʔ~U=)TJ`} e hOZZjZ 8*@DZ%eDK,2#s4 fD-LEL{d{[g} 4X=oom0䍚d6MgI,uֽK5?jI}_1"DdL#ڛGX̼J&R:3ǝW\hxze%K97 Rzb?R+:}uK̆5:H<öf {pPRzޏ#o:SNOSPAܦk D{`c=E~Y 5[Shd8@ pEH_FHGߚ^ōŰ.sT,pLOwc86Õ!ɭ}4~! ;43f{O/ h!DBMr9U}6kZ}uq%S3Z"HRgĖ}xDת/iȰZ9JP+w󟾄Y;?EUaRUd;C)6Z2"1w̋ެ T`@F &:/"BSuWmʥĀ:̔8VWo-v[Q#9hTR$XdPH22 mg@Ďhܴh5* ͈53 Ig;xљ?L0!5Y-4E 兖DI P UR4/,h.?t@,GGZ$uT6hg RپhSQҁ #eq"_(PKՈ2QNi'~iVEg$LFgbN|ޔ*(.4{G/=?c4DA9NDRtR9Da!(2цXK23<$=P㳚4# ܿ?dmK[y1bi  9gkH͉iXG9P([%ܟN^3nh)P UyDGFJO̱=R.Y/UdRg58 "`YB"NH.B$Zu&u+*el}hgaM۔"%&'o O_1 0P~C?wH!B 72C =]9"5Q`MW7l=ABz#% @dLv R拮o61M陖tҒ TdT (A CF /MPp۩ hװ00 L+@O .Pd/y3Bz HNgOW?4*d)(3Ȫ3IG!\DW P!.=V.v*q{T$6s ZS[+iIw ;,y L 0p(*QJG-E8V ꐽG!E)HIHK)m8z"YuBVd^iK 9Cz1# m0i,h!nsV(zOk:{OSqj#TnC3G"`DŽ? *ᬬ.KzeW5Z,B )sV؟൮rOmEj[F0-n ;LlS0TԑhdyEj`E=WU"[x$q{}@T1(*WX#w nͺ$G2\T̓UEL&ϒ{h]uO!sND 9 % z฀> ʴJs#-6 ЍFqrJkS#цG#{dt1[i=c;*1FQičnMV(X ټu%tVRe^FNMp!̴0r_>圤-ز%6D9_'o=\%Q-Omt70?矷/FH'B @`0 h ~l2 M<(^z9`чtkDRmYρ jGXA :#\Y 5CY3[ Anu=ln΄,Ğgj8zJ_MΧ7Uٯ!AGp"8y$D+8QdXmlnHeάc2tlXd?\iP=;] Qk0,64ىΎYY't%V1H$H,. 0-Fx2 a,EŬ pwOf:XbS]6D}v"Q9ю ϔu ڷ=d@Y@[R\34-KMTLjA[*{5͖W-xbԣ5ĺ@蔪"b%$l?*8t:>. oSϗ& 0&@HTtX&|~ڒ$ A4%#b@]1_J4Q' D&lT[MԭgyZ+^بfuZ)iJWgRDE<>iJT2d;[(@ ==&t gsn4:Ъ~nkfڟtJQ\pnZzǂBcTBS].r}hI1īoU%ɢY/́rh3{)23Rؒt՛[aLc#(>΀%4ozJq=|E%Є &.FKpU k, '2BK .uv[:0)!5QF_dbH`$܏ E|/Kx`vu  %=wڍͬ< xKuipduw2{oWQPgPu"SCuj j^ϝ  dd̀|1Yc`B }<"eL ,ǙЃWD x94,(ð4 QɏJƄk]JH.&pmІA.w~В97$YߋTݻ=rnrAE7E35] ވn1aob4w[uZh6 2.7ku׿,^C \(%!%AP*\g;[N@ l 9^C9^cYJ[S# )ygEowg#뀪'*ʐjDDR3<̷K,뻽ynTV]rs%=Qc)0rs?> BdX 1E+\0# as]LU9 r&H5Dl{%ǢC+Zhjҥ6V[ { 2^T5x*;ln,r&Fm]svR1̂]iP#$SNk ąE^A-¬aƮqʹ*WR0v@b!eRܧ*h̢@D m4쁘 z yDAa;hVdvTl[/Rn&b!DC|jB8#-! \%C܀--h9 Rf2}WmoZҕK7IkOVɫިktMg@9.,!))?yT|f"dTW; BSz<„ i'l l(,TB 6e'a+ʰJg^4UrFDp^Xض޾P4FS{VR8b쯗yt):>={i! L2Le5ʛ5gP ^M:)BBa" [- RB%4Y@eugGB$@ۘ! ΐJ&ƒOPG'CT/mUk)rPGkHlRe4+%Ǟld"(}b7ND*(4(] * YJhb`' $~5e bµqbFPYgз#.x֭fUd3dSWS,bZ 1#H IZu/ v`Pje|zSX{r< XdK6Ƥ7樬8U,(RekU k_ǟskop4_$<0K64e>C.("2&4$\Rӄ$ 6NBBqs)AEJSڑMN {#lWP#?l ٝƨc0gmc[n[σJb6o|G{?+OYk I{Z˺ˡbbɉuv<q8E"@AIlr!2* KUH < ,I=ףsi!QTYt:jk\>f9.K mKdp]eʺ̼Ywi 0nj[~IKW+42Z6e2򹟷1zc}X 5I8o<-~|{ ?U)f=wQ\ƴQ(o;s5XK{MfZ\{˿ϻ}+ yXKdH226'AC ZIJªY1/$jDVJptRA~َԾ~_;y]x=EAj!T6])s poE`?c8y&*C0Um_χ<0DeKKm^ʢ-ң#򖅺12!9g ,Be`mL( `\3!"Qx-pd@kG٧=8![=  g̅-|ǰ =}d4 ,@`|u#Qt` p3 J~.>hONF He 3}wx6F1XeqKݷ5B-XdN7rRDe!dx-$ 2z *dPXXD1{| )ah !-{+"p f ,(Z}Vns-$,b>2*հf4(#(8 B&YzKk)T՜F,dCB@335k0 w.fDQ`0.(a XpTr\ .oU~Q1A( VYZ# m&Y|諾Ʌ*f.cі2O&EW 9ao/TTdn*KuDeTI+2;#2hq0ؚ 2@)DumG:z(j;һ?_ @XT ="dNIYk@/<%#a,q@4k Yx1J`Q9iH#wTڭQ>qY+xܙw[V#&E2R[&k*#??hc>9-y))8`5?=>;?sHb0S0/ђru42)* `SFA`-R}BA2CWOZfBMoo0y;c}v c:jYYr?>7ShgqֈbmpN\]} L=zۯpE θ0-?Mr4 >:Aj 7F/o=Ddh+HS+`2$#.ea$ l]QcOQ-&ݩnF?SQʭSOٚϙaL!`)(Kۛhp%|*ϋ3ewH $&Ē.IՀ^@c&mE&oyk_?^zV";m5Qag!s U*jM8+2( U?ռrmf?G4B4BBU^sonx$  MBٖ.GzCи14{mn-tY3]>4Z8?VX ƋZh(1ĭ?d1Hk*3@Xq`!BFWT91fRm#W B24{FrWr#q+.]SJDNΠjl@AɤP2a{A~W #֬Ȉ*%}z= C|J 4K@ `MY -[iQ0Uc#r1w ŚF,kk.]_."pAP A0i]fjX"匎|XR5iiGMwmhcI/k64M! RGBLER,BA(+yw{ &T 8p "vW KrS BAp|NȬH" ؀ЌF89BAє(ƽB0eXfX ?=5I* %6\)f8GChIY~2f*}[Ѩ=m؞tgx:EV*_  |Yd>^a7[<%5Oe 쨴W-Ƴݮl[2 kX:>j} Ò  A>-03iq˷8^6ugBĚ$Ү\ߨD%\*UI-{2bȮE;lʵ ̩1|mxd-0x tc!!md6z wzZ)̤Z1hQɯ@@9ˢU7 H?3[ 9x.N3B8;90rM -_7՜娃 Ι+G-Zi a8&pQ%O%0 E$l7dCL1c A 0b %e,kk j+6s%j C ;4f'* p5C?YL|Dz_WX9E`Ӝ^\]'z{gVQIY3#b$""_|qywU^Yp5qN|FKCiT0>6,LIߊI' n) rԵĨ3< 0#^cxOWU @t{@bǜG/LA@dw[xAf *ܟ fo,v7f̵drBq͟0OsKny!ӡbH˚HDKfFqUq+nOp˻фDMod^7 B =#< e aLSm4š܁Hr!Āy/uB4w֣aI$wve'?9IG{\X񇚺R0[XvٳBE&HPET/VֶwCcmjmk<哲kl^r )TAa^7 d8pB4V1LZ~/Ǿ SU0"g20?V`ݸDV lPT[Xq &c1Y7y*Y$ ?1E nQi s㭛ԧp*4ǤEs߰}ܵ7lkgfqo^RMÙ*i(S>dW֓(;$M<|]%_L SjCdLةb[-!2@2LIvnVWz|V 1qz fD޵IghcaGRаRyK^lzVr$Y*ť9˽R:p>7{Cn h.MsD `,!HB`2~ ҇ ,=7NT4@ܙ*lBUu޺3 G9$}&E"81WPZ<=q$,N5X7V ԔZgOu2Zd62dNԃL64{=0f[,hC$5'-.ZoNѭQʮ3YhԆjƷ,4":2-|~6=ZL?@$ lV(h\ɂ:Kx  pw_f:{5'46u{N$n6YódV6-|kjkt>>,m[{/0Dka ]mmUơ"0֬]@8ש&2 m]tpz1Hul3*7:\0`$ VKJ'ILѱ V]$GtFѲdsY3+02k_R= ,n&b6n>W?K&N HBT-Ie%Po[.-,zdXzS)Qp۳'Kp @j>BBs٢J#!f:emtR/(B%,{RT GXg$~%䥂j . ͫnp H ;Up:d3{LIIAL !0f 䇍NӶ6Tenlw ԆCo{ a(n,4V`;꣫0;hvv,-e$SB c x C 0 )7 l (C+gƫDlP,R ")\HHhxHBXN] kT@`V Q./)"`=m[jt&>o"V@P`dxE,K$2.JPV >(* :A=iZ,tP&=,iÎz,h4<(<8 t̺5: ߿yc4[‟4HH>⁔.JX(]IהxH/FhY7G鸝5D`D<λ Itd7WS,B6{m). #[,-tdgɚUWPWmo;JS:c0ãh-UT+YM7H*5C[+rԧ:0RU)SGhgY]@-]`[)+Nßjuxw0+n#h! U %d+hW}C2U/s).(@7 S1pD~kTP$Xl)M ~sQ0V6CsSUfl֩K#۲CaD[@ Mx A`&Ф+!sqeLIFhsh6MN.٪8BGw($c@#J6My[LqQʝlEJIYdKk B4l0"O]،nj܍[+QP$kI??K1moL, &aB `Y2k}zcqس@O]=NP`k,+]%gkM ڙ"e5oABkפK\BEj@JYAD sfZtʱt)+MٟS4Sqdr[W,9B-aJ_Sm1@ $q5+ R^8V}VUhhVt@8Gn?kݨ- \0&P3Nv+!hwBpYqؒ+f}J`bpuz2=@@be f, &Z;@>ME>D/vIJ\ `oz׵&;R3ʃ8`sbk {[QFaoyʭb]$H.JN6ːFnG7SD"wk)@T ͌]_ 6&cyFUGo*TTYv/d`(NLhDq$Ss-dWH8J<\U$ -|d0h~eJGye ٶ1&=LD[kt|ʋQV:Ycd4 9(74 WDEk3V |l ;K[avDKB>:;6%, :08 ` GXl{6^պo6_KJAHD Vtfo:9d-޷S##cW_zW IrZ]v` omt^*Bsڌ`(4M_W((m&; `%& ApuF2000À}X&",μqDe硔޸F$%Td dƀHZX+@a"h UUM0InteQ(B:cQ ܙv3i>|4˟R(-@!";40TͨLNIH"TћWY:Cnw· TH"34a>b [7M-HS306) rs ٟs+.#&t(hSO"&dٙ])̹Qߦgzy`G!lOe:P”vOQ%D~-Өw{N@A1M3 M6e3ei!CӰtE~>5~WAa@IDL1@C'ޜDNot2RZDDqJunmJτQ =m3& 1-KMpCrg }{Wod`6UkL6"{wI.h %8ջ?*c{Z2 hsЁZ*"&XPL L 'O0k@ @I jh]34%Ir'@j !` `(>Z@Knd.ҤgD TugNR\rkEbXRL&;@Q(BŤ^_Qaœ̷d:Q5!e& I5k`BBIioy6L!"BuXE6l",g23eo$JDyAԦaeKAF1VvRZ.lt ҏ3xC K"}P ,{IJq`u|#(IVJbpV4`2Exh]:"&й`645c;bNZn_ >5H+7wI"d(RmEF-jdݔK1FԁCMHIQEf%BR#SF88XcSÌx%I1ʌ?N$jT P <$*fMFgiE '`dŀC^ejAkǒ0Zt*@Q4ֶ,Gi5IȫL֦_bc|5%ZB แ|$_o& UQcNNZްcq>W%]<iRZc_<^PQW2}@6F1g裌SNdi2NI8g1T;x؊pofK$ ΅GS8CnQ0*uP€ @tNFa#Y/KnPS>zR)A؋`2Y^s 6N8DJ Œ ,;ܰllHsn\ɔFw\ S yS  dvE)2`0% g0%^g$ȼ*(|E!YOq2>[&w>^|Ok' *IR Ua2gCW*|E*UUjU*& )K#esLw7`lz=[U+&J3_i9O0iP/ dLB._u+Ҙ1Y&A7dϺیF2SB;Pgζ_&||s 8>P9(<2`Gӄ >vQ[ 9!`tp::qn#8$yguA5dA/y-E,Ԍko-@P"U%N52ZBB7>x.9< $$ՑH EAxP? FFLU:Z[ `ƬZڔk@TJ0s`8 ʅ5oo)vZ\1'@DAF8RfV\r/[!L͔CpG(,#D"N 30OZ=f uome_[y.=8L# cb*F'`4B!b,H  (Vh,.@/EO4l`&NBx |x-ȘZcǴT}SZT> ~-T8#O#,Z!?fAŁDY'9ۊLZ:Ng-@F!K3&1WGCSLۑF_'2:p!(zˌ:ʩjYt@ gU}cͭF?jDWb@p*B(Y],-" jD<*&L/ @9(4D$U] 6!&j]C^ǷdDRR/8 !qqȌp {矗S~wT&45mYiK?BeLP,+@SDFd*œ1Xdaj,"aH%ҿ_]Q[N@"3`ԡf-KXJhӵw\dL̘hw(m-TUUII1+$ʘ! ( x5V60( GzĬqQ$D*$ێ8>@/k@`;juIA~{y(E(9&TT2H'@ (yahSh k-2@oШ]ӳdD`~4?ÜO;%#fs4egu(TR$X0OAdH 4AM0 q0c+\f PԺC7 - UL"- jؖٯ\ IYs j\{#z,.8SuO4kPfJ8BV7sW99\7Din Kb>a҄&*˸kyh|wu^^a bZt2B+zU$eΘ|tx4WnF+^r/@o#ݝXT̀R *QliC,I(2K"@xUh+DPȱ0 HM((`łH YI -֑`R@9OSr*2Ѷ3'J5q040dH0 5i$A5Tɢ ,8"x–-Q|VrB㿕yYTzʿ?(刐&*&*$RDql}Qf7stP*Lҫ+E̼eĵ= ÆQv}}ph_\C`/^&b>_o58 C?V ?7E?ƁAڲ;j $D ydpG6Zj|(0EG7*Zd .d&}M>RH"CԔy*vSQ@!D+dgHe@a`#*ٲhB(}\dDY0P9Y  iQfij:/>X>˗`@woSD PlvS4,T5\org;Le9ITA?H)uALTr< D>r h,PXi?6$PQ돩b@ 6дs櫐 .e'#/eT0#mm!TjG8ZUSV)ڭeߏ:[ejkQ1a 0x|! @Ճ\t˃P5WZ|+hc(1N-@h<LB 6be`ԁތT@Hu̷"2L[&<\4ѦɇS-+o0e0Hly)Q߹&lpFdc}51b:9=#QA4,gl͕xu(g<55b&GD _џe'Q#B?S"_V0X^1r'd5Q(Fydg4=.3;JUHByͽC$AT4TFq6_۵UY ZMw6e)1:`%^# X͎NC\5̘{ϊ;Wu:RNgG) h|͓$X 8äa.9]pdhĝK|QNB->53Ck!tz &j*ۻYuqNA`xa-( p45 hL \x (3d9SC`;Fk  QGs䎤AxwJ}FTgIFP (ybc"D!DӉb"D83~w~wި2_ 7q 0[ r0p?LIK&LL2x4ԇ E\ 3:mk,Ҙ哲T!2]s$fbD"Ͽ1/ /858NN_YjecR$Obb`) 4ijV[*eEEu0@*Y>p9 sYFU#w(dťy1a(XePEJrr0ZkP Qr2$Z}:G"w15%I"(c8 A8d/= 488bTPSß6_2 .2#Զl"L<d2D(P ?+]kZW(@OjT8aSi6ƝP0b0(`P@FaRҡM-NdKg!H(DHѠ32J)\~x6֯9[.KR_&SC ex$yw)44dD(UO 5 !=G !A,Sȷ g$`iWȤC&LII![s}ݿkȺǬiS2! Los7bb698!G@8cC  5IG٧D? wXW>ZL{OXW^vv%>[:ԥ-ϼ_”VdYr8H@'"Z'0M,PG,"RJ[o׽f^OJ+q:d2ycZY0i$@,CzpRV]7Y\fعƀq3/L"~~Pptd%z`M5p2)ab.́A.a@XhTOݙcZ>}o{[قvucI!)*z$w7 <rxoބMo#/bwcƋzYRh@T WzKjDrd@OTF DL\UO%eNVCV.FGz8 !P)gPR|@3M\^~o*v  8ܲ&&,04Xʸ,voO9k29Oz92ĶMme0ngA#og  U!d_QO4**W<"^eqM-ȠYsM :ҧ]'{@fÕn&!)L=(Tr=1a%p`9Xh<rĐ$d#̹w 6"f R9҉|7 qx b&N64wZXmɈݘTA,jJc|m<7go1wuzM4HgY'U$RLoM:? 8p5<ڐB Lj0f"Z5C/8Rf+[# H#\ dMpX,W06pYpzm?3UZa^Ĩ)zz-zcdP_cL|rJbyuU 5ȍ,^r5?LtB)/$SZCT$?hjE_^+\Wx*i卖ďBU 1!i'oW>q$9@JI@T$P$L%^K_9صl2NxU-delN؅Ti\\L8p(gYu (cqtGpigkl٘0Aak3\ !<vBBBB0&&FC6PXw3ؼ^LI+$izћ+c[l|寮^(a\݅D gh&)|Rb3eT;q=SݩJ!#% r=2&$MN"LğР?,ꥑq@xͨL`PRIhzw{lYT*d IUU'u@a6\`O6bbXz4bkm%{QphSzʥ+I&!>{;G/4^q=_PҧT=U34a8lN  j Mwq4Tڢ04HnVTi5#fVr+# bQe,K#*\[ 76r` 7h6'A2Y"dUBUuyq J`OE#䖭V&!{$(~kYid2*YԃO],j9WR @ꌜ&f0Fɑ#4&eV'~+Ej@: O֝冘GGmSzdS=ַկ6]846dqm}Wo d HYk B2b[ B +^̽ d0+M˯zj-VYh( H>H\$ WrW/?qUTDگʙunHW[h <Ag4YhF$VWn& ҵ ;X"$o KbHbHqάTgj$;zrœ"bJY'Tթ!9B$.XQp/%\L̻2ݸ~4ba,lCܢլ'Vv1܌pRrqd.`/X6 G$=jR60l GZMbYaH hS@*D#D ;mdmdIXq.1!g] ֥4yӤ\;[`< I"! skWgņhqsxw60ch|lUI8lBIJVy$w8y`t6`8E=P^S7PAgL@>oHR}6bHJ:n3z Ro*u3q0UoB#8H$ $V²V]qiԃOVkzk׮[؊XCT$[#y3AN$.%|;VjԼvA8w᪆o_V} 1`VR"od ;{ Cb-L 1 %Z<@l( *4E 椏NzzJ2  ċG{LXվOrVX ;M2kMN qcgx* R4»6kۜ#{o<L??)VQPv&NbbhP &j,?7oԮ%w5oAw:TDG1(zO3PЬyRDmZ-HGmCje8kskpd`UdDcCp.+]A:[ oqqMIaW %i1v~_!@qPZd^ۨ F(SV_mp6΍xoOko<6uP|?${<݋%#/3BE4% !j>#΃)!U6x'T[S3AC&!*n_e/=͸Odac]+\O~u *d(anBKzBl^SKVdAW :Q @JO)-SHd3T)n-cw/5[Lg3!UxyL,,Xi.uKӆpE@-h"p/Lɱ[WWUqvo\*t}=n*L߻qZrmO5:i{``BU܈yAUnq AcJߥxBgwv> @ffH ٣sA>ai;$@Yg/&*|Kepid_U)21C{ R9Hx1>}h&0~MɁ(q4fEn"#†qyފ!m4IN+ԋaSp4jA̠,9,2JP˝LLJ4̫KMɮ4vo`NhM H@B#6PC%ruW&&>QZYR!U4U1il`SK@ ꭌ+'Yk"Ye'*\G,obvd 8TOrQG;|W<`ah7;7'Y  T[`"^i4Q5|5Ņk$ښ24ݡ7 OB`%BQp] pI0IHd >VK,7K-=:b$o +č`hm@) NBafE8Z%814b+!0d`C@5<^0&vѣ2<-A T_@tX+"9a X^clTYSѨ<>^~Z>-XeZ.` `dĠciI%`y By&j Bq*l$D'BSi wV!`7)biRE.GJ۰m  hS:؋!䀑 Ң -Ž41IJPX(ҙ'h0 JiAT@sg-YWady dI( xNJQPQ,׻S_ϭp0LäYM,sd)VzNjؙc_mJyܶ#h8@v$h JJ"q 8 10JYNBE %_{/Hh#d q[CNFE!UZkY=C3V+CRJ}Hb*MM)XU*M04`d183Ob1!m<'O9{XM AF_} DR^4Uf3Q (a~R ZSz beYC`kG8E;1C)F8#*:}]opH`Fd ) (h"#Z%MG{q~55 ,Jщbw$(z``cp#EedM!уgG`V!]>ȞD2]OD( 4w Sқ̀+m#Y=]Ww˃gY %hn*eb7dGcA?ֳ,@0=,cZ̬Ok™ڠ<{- Jc|!$Ŵ*)sdeYe贝jx/UOx -ZĸzSK̽TTCPyaQ#4n¢\{h}h@  x JѡNe—ID  $LD `diݍX 9VhIݬ8Lf6UAl$2_qν̮ d=UW `6b[m=%U U[,0m !`PqS t5@z+zv)5lP[G_--`0E@ tUyxA9fiAm`)-D1ZD=jV)aR>1 f-*ŎSF+(< Z/yKA c׆Ki<` .zdZRU ^yxp(n>R2QM4PTTQ^X>Ue|7\|*&P+՚&λ 'X?DWdE1tBjR8}bw6:NrjD -&/6%㄁CW5 *.ܮYdpFՃ,5C =#F/_L%P73;*[h؈m}ipʕ 0C8!)0ia:bbΒ _IAA6qV/=-K.XBAimzZOx#>ݫ+[\ڗhnf#;-Z`(T(E\Di`<$Q$|?(9 !a% ԗZ C" ZD&ۀؖpod,Hx)ش+ڝ<{֏ 7,^x#Zl[tdi@hE@P&4X%K _k'0kdP8Jݫ)S*H 3@`CI0Vqz0s1!GF)h%Ư%\ň;8L >U.Y`4Aҏ]E]5裳e11ZSn!r+!ŌcUh42zTT49J\gcdVND-ճ[x;w43LkчF$~qfVTbVtK-9Ĥ !| m?Ыr7Y)X q u;}? H,}:xXۗ0]п}vPНi6n$pyVX&T%{>\ןe BrnM8_ǹ|*x_,~GmvYM55z$8mh n39K贺udd_/4,»&%"g],$H0.gEN ?["QE=ԕzB%3h@t.WY<~3C֌EEPC4*"]wYcin<+Z̨5AGRD_"o)`UѬU$z0nA!-6!0ar!4 y)Bv*6%•q֟S\q !龮RZ]G\Ҕ:) d6/o/ $*=T8_'\l!إ%Y @j4$#?<ߟꥫ7S]9Ӂ00+r6+FAPLZ>-*b.dAWC)p8M10 Ma$oօ2Cɹ]?ϦH܆LWɤAs"9]&MXy=1ljLZ? OP<14Y;ckf1fԔljj tﮎL{2YdJߣuRrKJ;*"Q39::Hн1EZGF}U 6L0M\񘜾HHL~HtR@pل@GWf)%0K=tU&V_0 k  FMQmYmvTCH" F_/3e u}f@>8Nr_qG@j.V"ă 0}@pVdW37;l=bG {\,q1 6OtLtu$ L]!z+])Su!V0BJ}­6ZKFnhdQIS) :H1G+_v bEbIu06_= iނ,+,lj =A~ߖF`M4*@A3BR{ESC3YJ G5?[6e'.jBؒZaЊH._FD0-˾^ԭ98ċåǶth(h^m!'hYY- D6nBKJ}Qᰛe E@<㮇Bb++@,Azi+t5XkQ^S VWdV ,P7Aۍ |c M+Yh[c=}N(#yxwDcdy_I `Ledl}?dP88$KlT&YT@":\–h!qp.vbd{Rt'1{xp"2헏:,!@9c.@G kE B+FU )3jコrnVPe&muKpn[IX A2 V#B?tmSsI)F# r!~1@F͗z^V(}U$<;YU]d׳B9ÊaH U_,0l ,j=cyFu4 йU0qa " Ғ߭[n&@C*V7j)0/5+E8)^ n %' Ö xLk&.5͜X$kNMtw|QJTs‘hZĕ+^*d5[yLa&v ],0m.6nΥ{\yi@IN!E6*Nh8)ڔy RTJ\EggLPZ%(Y]gI@(6Cܝ uGgiDe"Ԍ^5u8zO&<>`àWy'NQScrsͬߓ΢U~dnPzA'B4ߡ)hJǏk>; 1Ir[hXZUFRLŗ $-Bѥ[uMEΞf qyKv-_I)!}]ˆ ad22iFÚa"L 0iqhdV9CJˬ# d(2JRh=dzb釚K0q\ dc U&T%T`ŚsalO3X8 GzEa<QeHEYF2ui ?kJ^u]4'X@!2 G)67P="Kq.ℴȂ^44%oC d3K `>C-#g[,$M + .²dD|* U2Io.'_YG7~ _q:l`h=U*$m@+4` ~g'c˿,Eұ+=]R;߽Фձd&vh|:}_-G HdD|E3%'uD$طC|DMnx6BFu J1`;i'=)ܡx{*Y6Tn}EN2J pi3#/ N:@-b$.tϼ_OQ-*|19qʽ;6BS兞[{UXW̹fiJuUުawN&d?U,Cr=C+ =,]yao-4;/QlR*?$Bה` ?,N.}&dta%V.  J(C( $$(.ѤC2* MSmH:i?B"?]%g*q6 B_[$5PM3||wW!U)lR~nR\m~ 8@D;*jX E*fL'RO`i)ky8v==J ;@PEXtZ1āR2!763DR]5i~3HQBZghP@S1hZi VkbsK.(hCU;tU uP(ܳLIhnd03)r?A-1`^,==Wּ9K.noK߂ej=-OY )E"EK*(+SE%WQ$TWMO"!h5+wCH.4b"1͘-g ?kV3dКGhEcFZ_`, *mki75O[;UDX0 л"J+C6:q J5"R8>a@zmFlC[>nZJu.n҄- y6  HXhk`cbU@U(ah2s[%*f\YJ?eioͪ!ED20j9ە-W}yQD PU97j:J ꩆm92R)Wma@ o,x%Qdɂ3V7b0b5[.>T ?:}%]b$ T N[*M\cɪ Hl]J|𰑶XPJja;U*O` H2RL+Z4 `N*s5Fyj0K Xhn?cdلеQD{V3u+joBܝ , L`?~_h@1x|D6!M4:/d<\X`T#JaF d$g#70]2j$֍1M[KP(ԛ[lti3) \(Bs؟g6@U E2yF J#Uqu18T/KPilb dżJJOY3#ffhwr:S;{H$ S2R|x88@u&1Yh1Bůn;!`l.zV?M0x5j IubZD TaI{ؓ ,65VlakazMm;N=lJQpYD̿zVqH7w]w` `"%$Ӎp2)Cd. X BRV"aD =gc$m+l(=xf/ѣe2$^2`MOL"G~ʗ Z)Ty0V/iuEʑ)Yޕ0i^E!)wiA730d85u2ri+!FEt:=_FgMYAbuN|j=c *^ҎbFL2WX*O0{Pt]I>;NFRG$C30ST-wSѵK^Ex^ܐ 9@K`-P%xڴZj;idh΋}*ԩ  6!hB.tVd!X R?- i [ m1'8K83ӂVWJ'FU<4(ɒn܋pyѠLUp@2ĺXrhKcqn!d/"jnr̎3*Iq}/:邬&@P;'eڊwVzÄX`pa̐)txp"#ZGLf4;]E^`+atA`iq=kY4@|OC1D45Q[ѱcǨ.aJrB DD ڀYx<N 5F!GV2Aǃ ZSSmPHho*:&bG/*Es'lںG0ٜso;xhYb*D'6Hv4X=xKtٮb-C$jSEËD֢Vn"sL @#N%҅0*dYK3b\MaqǤNҐo4 b{)K) :Y20j|~)k̇M)ML*ΑԃS(Үꆜ TY&cӀIF;dmARC]'Y婏$ԄS2:)K, a&T^l,QpVBAF8`Y ؍EC ) %2\ :]=G>brxKR0 =#Df;jOU a hb3c{<&M'e1IZH1R@J2iUlŞ;]&P?LP +Qà9H8OAU'Nht|7 H $LA ïdf6;0b("1YF,M(CV**O<,\bX!5Nc26FS\CnG6`\.l zL ܗQ %)N-%}(V-Y|O5`T~1".~`xp#kwk*"VTVEGXΤinrѯ3d] 3p7n?D iǘOn0Ų d0a1atTJn)otwЬ8kh2j;Kv D +Yo_拠div6(&ZAZy4IXXsmD00ܢdJLLIRa$cRjwa d_oQ_X4Kv>?u.;'iDB| R6)4Ӥ@ls gb~]HdLWJDשo ]VPP" b^0=Gt)``^kh#uB7O+֔Wq!;jH3YӕQ N3/dkZc 2{<)09k_,,mtАZBI{&R:j>KD?!\2رf|`YlZ #$k0Qߤ bx2~3R#@V&"".*$7ûֽdr|eR?$y"pQ Pr;[/K-1V`"44lU5-+?{v_?n"jLGe(:Yw^j$Hvz#ItB:alH0ElSF u%KbwF!Bc^˺)Ό֎d9c 4R30+"U]_ LJnt$(EݒF='?^KIc0w2!ZYAF%bEu[m_)C|ӛF(#P ($q+uS)Zs+Iw7(,49G0U>X:U'CWf5)m}3jͫ56ݶ^@R+׸V0Ƃ¼[X˧6 ]oHHZ9d́v{j牞6C]Ac 0 {U<(mS!r0tׄI v|y(# f`z;~ ]dZXk R4L0b _c >d n~ut;ϥ͎Ǐd6Tw^zO f; aH$ɱa%lUƲĩ4]ôQ]WMپ <ZtoX@mEVKKYrEG}adS T|)l3`@ZdÀ.XZ{ *`QB+ 7ʥadV (@3+P)F'U $֦8t&ms^ñӚAbl d Аx*Abkqʀ 0*v= ѥX Z䉊dDi((.C0 `QJb 0n}l7EaȑbDLpU~D`%rK@122o 0٩VCnDC 1`Se#L M_l01HͨSI1F0dāØEH#̐|<CtDfϩ]ʀ8>@уS쩷4v'3q(ǚƅ]aWY7٫ٻ#Wѣю 3*Wr\HrQ]:n$jb@SZ9.;sz#b3J! %:bx,ov9ҁOzpҤ~!ix!E5`RnWPZ)߀RMg6p"ƙR²tʢzl>P 6@&W:eY:^F|h{gadSc r8 e $m mpN˪SreT1ɵi껥ޘ$EI8酳Dg05@dz`֣Md6?%Z(}g׭Ur2s^Ÿ#.Gܬ I LB=||-{!zK#5y'{ CN+t`fEJ3|.)iA$rԹd LM!mp Yoxn9O{-B%FϢd~p-ʽ:,Fi`茿?flp*?U25{?E 2*p#O&pz FJ `ЀKP%$d+Z!{P3=< }m)Hl`G@'Hmg DV\=/afӿ؟Ⱦ}*XbTJ D(%W 2йh 5˺]tt(*Q8EڳJEQ I<. Z>" #2#zn+E ,*; #(+ʗl w80wX~C1nԩ$Lj$IjT%`'`Apt 7 LK &EJwP)#e%wW;v>$h֒BZ΅EsȣDKdfaEɞjxHA"ˤ?\*Pdh2 Z) v=z jT΃mdq&pz@|.ƙU~ȑ0 [CZ{.wjK}y҄ }0Хכ T, Y za%! R F)dpZcpDA=4 is0g؇,dFd1\> ,F,@+a}HOAc^ԭF-BU)]i{]E5KQ<]/8sH:5+kT~ס*qu=O{A!u |"5SFRwyPIr_D0 x嶷|r<*>V=TǹkZC꼷= (a3utmȽbrxb=ZU hu} *uh+})$[Vh0(NL"(G.a/<QMd:c @;C;=* xd$OAop6D1 } 5y0N*XVr})I"*CBCBХ𩮾/,^VHδtz6Aҹp\iu> a&X\[^ h$&0U'?ҥl*\°A%%nV t*{q‰L3 |4 _襏}$'גH ^rkL&<ܾʪI\\.[ bamSF|g/ܙxD9k#%gt1~:mdTXE"kcL$ql 2䐑%E~ d(eFF ǫ&cgBZjʿI@y8n6Unw^I ԵZX:Tr_‘).z7C:!]IX#Tb2)M*%en7/#T@1FdlI}B8 Vd&!س3*UtdiL/ a."emWognP UQ V|)җ:D&=%VU:xQAEMoZȅT=]QRZn,$ʔERTBOrSu$n+>J|9!GӃ6pdu\?+,<‚ ekQAl0 Ԑ cs)A>mQe@J ,i OD ȇ`4P6JL:K՘=BOX(Ygg$V5EWCv*q}Djf1%SO_g"n7|D=_6ۧ?R Me")Mu4 ],L:Rg&Xrs?,ո6{1NC'_ E8Qi @1!Ȫy{fNtMXzDЯ*Y,*ύ\R&eO4ZdeAXgM(C{Ev,$1! Ձ9Tu81r%[:G6KKY#>FpwdFg .+$b4@ȵ%RK!]K9H S"4Q.u Jhl1 N. R$.Sm"PyUͻuuޗ8'Uv VUB~+v&NToJ(_A/,}lAкiS$ .,ٞvftNhwЖ#.Od?V,4@Bl/=)Mc$qhAiF:F jKQ2߾- 0KP!*4`Lw ϙvP\ӥ4<6t:^72 6BT]^,yU eQŒ7%?}*\l^5i>bLBap=| =>dS2rv4"neQԐgTˤzABaB<AD ;h;5G]/7 eà"՛//LՂe@pI Rt'ȶX}LaRAZqŎ$OSj7Wt=˰D>hב4A .P[d܀?Wc,R8b[=ffQ]c$q䍭t  a4N2I#[n$ HgBYfP`R%YOʨgI0"I_l*J8@%% oK[?9J,r=ڶl;;[HGhX^Σ1QKm:p?`<ljFAFIve~h !d݄0cBHcKMqM[,z@njcP4DU BFћJ! +pXLX%xìf "eQ^dHh#ʨ! A(0"-#5/|Hm 9轪OY*6=ZoMǬ^ͤUv֭9IV?|*$^E&s)Bsk}v]oI) ^"<.|N] 2 HFrZ` 1sawޜڑ0* !:I*^͚41%2ǀΚ}HojԀ$}7,s!qy*@BuM43*U7l⮌HnqcdpK B: >olt }[v߾- 2?ɂ*V"Edg4ܫ( oDvϷf|X=V$ɼ2psE4O8 8cD< .t|HUtLBX~~`'s0<8~;Ǧ^Mg㝕C 3)ۜ`:Tv?qi*L W@(HxX>-򻾿^ +@NDE6hen.,)b;/c9a*H#B!ilrJ%Uzr'=|4zhtr!v+SFѪm҆vRG0.@5 #-jvdЂ4c,6a  !} R:Yk,*b6=,FI#am.!,#[ zzح5F9j#Kxu=Vn_THߠªAƋ`U3\BB!2d<Nt08f(`Qgd]e4) {H-Jzp},X{R*.\ Sky7o߾22I:Y+F[ Eu6tNCT5Ѫ9Fݤ<&d'X1VE0R䡄UTdh!,]F޹ZK` J6`.6zΫjK]]JO&MD~KZ:Q|j_YlTDtQ *"׷ UadUcL0?k,=L0a=$ ,F&xcV!RR4Qg^ Ex(\L20<k>Dh&to.TDGe.BM?Kۚ %>{Q xPwlՐOd)T܎:8KgEɜYU$`\os|]J#]ĥ *K |6׃L$YGlTa?~5V j]QjP6E HX,H_IA/5 !")d Bd|޴FP}2&wzku֥զVJcVgeD=Ersʶ+zXd¸dJK 9+< #_$m!0VCM]hⴸU$Zន?>?ηQ4VH^PQF#}BUzreڳ]׮ķ|G+s3 si˒}@Y0m^]|f!W{4xb^e=yoN@Z`>߯ @;ƽW"2ۻ)ME|\xZThČN. S, q#)4!?Ӿmz?5vn*'yjM " @>Bl?f:KH iw?\B%u"N!PL PZC~q=@Ƥ>g!$IBdG;E=aOQ1_l0kq (80@쿓o$ #KYNJ\.kB.A=:6;89o@6,I5I,:q b?YB hZU=XkIf,Tiʰ؀@h d88X p=^ c1 `HpN/5 {8 x2]PsTp0NxEH\dCv οh$ua4nJ>츶i Gi)6a St2T*T,S19\Yhӄ AX+0堬`avpPYHB@vXy%I_E݌a6/T^T| *E ܂@u HXięSoYqѿk X@o/i3bBB-x aHRqf( .Uw'"٧z**)'hvTossHʤXd3Y =#k=:qalqI6,0,PZ {f Hip9{l@}+"b iԽΧ(:mzi-[_׳@&"*r YjP te%}zFk. Ϛ/ ![˗-mbfJ"? Rm|(*ۊ4Z !I5gɧJ.SK;*Pҕ>J$濝kq SA"+ ip F X$Ro <<&*P(3"wreE•.8H-UYa=4%&|J /4@(%%MË3VMHl3S`08- @z<|(d}TW C+ ="X al$m@.9'[u>P|Dw%V6/Y+iү~iݶ''t0(j:_45@(ߕWK2ZlPn Dgy3-|Hnr>`FÕgɬ! K )_F/h~!%m⮇•7/@eINRU}ns0OvK,tLaspBG{o n`kI)etp~qS͔b2i=PFs;jF_zyWp.}X4 !$@ b|Ey![7mj\ţ2%rkv.cDG"EDoG22bq>i tY%n0muLZ !ܤxi%LD$a*Lua zBD L"q݉pT2 2l ~<.\Zd/  𖇆!P@y3!ܜDF!HxHo$O*zO IgsF$LZ8 Tx'Zȫm ,9[3O3sdEZr;nH h\p|pp+avCPL  d zQ]lS} 3򈜋0- X``AobWBhC,UOJSQl 'NN3:^q(-})R9e B2cB*XB_Lwܺ^b mGT:|mUFŭTM~͌<({$1X d$Cc 3p?- T(qO&R1as5z\ BBW ٖiKQ RmE VGzcoFi=7=veEbܢ@wRg.:*Yyi3DpEJk%ew׎Opq4٠dIΓ?V&%DZ4-(Hi4| `vt>ᡡ7@ndJK)bL.gۺW5R\x3m-)_7SBed)i;9WugIIتa@(!` hUT:Ud]>Z 6N3`eWa= |H!$g); xB:Xͣ%z3b7{B "RDqI:jy,"#hmrG \^h.{h!e˝~l >D~GVf^hvUV& .bk8ZԎM!G+D qyZ7d4rȌxd h[ '}wmUe!h)y)k XxD$n_;Ĕ"BWi&˳hUA+ӠWVX̌DC'}PSȹ!HH.mTkdz<#p>=Fuga1lثk o LiOXQэb @` rQqG)K C2~f 26 TF""H _MN6`՚ʭYi<<׹+7p bI  еS;箬J%Y,Ţ%&:t43@t,RaK2Fg#+8bq/$dj\U$8'm MEM:v!(`AؘUSţ [` (0 K^6a$ @n@3Tf,\K~ky+L sα9Z{]j/ W!Am,+:p6")/ CCj&kb\*5639$[!5HAy2+]Ҁq0!$@4;A$` R@JXӥdނ1V Ya, UYgf4 r?<}<)?S_ Yo>da~9RIԝv4(0IZI*0H4#"D2 B[Jbo(zfbH; L>j$?j?yElHQ' J58EĶiqDj6g^D'e>Q CnU?as1i_Lg>&f`olR3v_1i޺w  Q.̄$c/r􅎡!!6<=r[?0máZvenn-?B;dc/ǣYЌ>O}vs刺jf G[D Hd̀I;s)bUE="X `a$k m %-jKov 43y f$ >/eNWj!Ycw۶}b3=̶țd#C;;܆>lL.@Fez rJ(Bh|` 9օ=! !J'UWͮ1y.U#eu-9$3  )q='0LiLvƻjbT#V%"z<%*_ .v(u$frFa}<]$̃l?Űp"E i AX*|!T 9AҒ0(A..Iaf]/ٔ嗴ER"|EdZ_c 3rD=&Bg>0&brܿ".Jb .إng$1 fBD1SrÃ?**&S RY>LH ACK8}ef A#!4Z?ٮ cv oAd6ZP6A _gM͆m8(,XqL&OIkvP@YU zDhH̽E +{vYcC4d`%^¼a$_ 3&aB'dB̍*dT$3$UbB&XM(#TF5XX @kЂ4PD/Y̟kc6~Кߠ" U$\+2o"LACIHA}ffϐMjAMIrd|?JClO!$Oŧ 5r q;w;^g)\05`dmV`Pkl {u;Mh8 eqCU:[dNc 6[ N])c00Hd:]Sá,@+(iY2k#(9kj5p|_?!CH|$8jlsqц+p^)'lZg?f9V;fMtbQXIC](Mԇͼ$"$!D\@bL 0P  }ȝ֫e1"YJ8}湻;d@26_=(ett-! mx-y8K5d\^ݧ|t+2K=+[WM[fI ī^>|˿vՖGnW[r SF̽t1WF88>:ǖ :}n}}Sq?ؠW</-nw .4;a yO(A SƮ7Qu4"AcAN'p)O[%qD p ?l* ƄLXᦁ*fy#{YM ұw}RF77jeQy&~4kn b  YYRpd,$RvCoKHYA-` |<@r#tuJӗ*aPyh7] b;6_ƮA@L[]3~nYnp`@ 8CQ2lV}&4RgI\ duEF VۣkEw" @i(qeh O?> 3-x1k& a1r6odQS)+:e `eU̳a0셭&x{>wD憥ZBDDj\5QjxB`KaW F ?vH !\:J)dg>g!K] [[0!D0Jҫ*}IgF=Ԫ?/ɬSS:׮ Ta0.{#u+ŽTˣJRJʌѿ#inW/S2x9*ր!`%-c,fTgv\oOVhg4` #0PThkd"vJujq`uOH^y߳ 1Vw  wӢ*V"2dɀ9]Wp7%;0b O]UHؔxAĴL~?W~Sr& \9gZ(#;ÿfQ^e{zfG Úo(B`viCx7l:Ni ݃Rj[Sb|?܊%jEߦyգ<@ @^ܰD@,}6 ,jJ*K$wR>9]8K-X2kyʆ7WLPC *SPs.i2M| ՙ-jVwUS)sUw[xщv1gc}CBuTIYci)Ct6!^M/?j+1VP"dSV/p.a[<&M[ xĉ6lDdCf}C%zvz <8A6?@!\,0d[6]I}T5)m~7ZPQfZU^<M'Y{ IRMytkN ^Θb42rkFT>;VM^:1s>;-¸Pb~әH6P uR0$"!0.۰Y@_-xUX%q^- KjG?bFB%O-ʦ+j[#jIebdOSW 9"]="8WW 0n ucɐd/3,K?!A {6ހ*(!Q 4Y{u /InW*>h(dejA ڰDE\"R` ER@"jNtR_!GtWX vk^*nƾqKm|E;+95tDbIuS]EgSHDضd?7`w 8L>P򀼏$h#LCtPZmL}-{a`HQ̮$_bCb,{ސKO\^"`^3R,X3HŘ?,;iLvf^d?ZZXkU:s  aQ;"ڠ;CRU(e$lIZK9y *-.G@0X%"bVb"6H$5T2ۄ*sI$-|pXFMqF'o}vA*!Y+(?JBT] <%>TfF*+_z4(aL)ߤNQH:Sk%J%K {bjwrZ  XI3im}}iI 7#Xd?P7JzF]S Td3W[&0+,yq],&(kRoqded #8A%jbjݭE$zAQ~@o/GMGtQBW !LmF=8`U`fEQ7;Xݎ+=D[I;;}yn)Eݺ˄Yl[w1L 9-i?!x9@ZuzAG| H֮Yc%&v!fR,YZ 9ᦔԭLw替PiXLZ_ (Ɔg}Z!d؀C1c,6!-  'a (& ԓfBKZܮ,^u<|jG i=:3yBH]^-@)K&b,K3Ntx*˰cR ^VJNⅼPCh;A9 G$aG& I]SqD(- *HIf  ^-/T6ñH5H膎:T8%ryd7y%D2˛(b M;$TsԾ>ַ{>[SHi䁶] [?zf};Nr|JEk׺,@H &ō;d/HYK,6[Z #X A1Z DbYsjm << 謀7`0xdZ>WL3`6c a#:dY-0H l@ea~Q)Ho*Öl!}\ t%ɑVؕIAdKʉHH!]!I,P UX'A>#I@*(q$\VA:b9!#M2k']bDeR"޸JB$mCfZv=(p޿ &4&l:9O廂K4jRzaGGHO\<'L \(;XSWG$!µnu/ՒS85SFlKWhB4_qBazⱤF&2:oP^Ք۴G&dؕQș&Ҥif4.5 ֺdkWkLC2fjEBJ<@cǠiRniO25$FM'`Y3Vq>3YJ2]Pq$CP@ S?^}r IA‘iDɖK" $$HQćޅ-ҨBr-']%1QXz*YڄTlujy Tبe勒%PE4H)ZdGVK)@:<-=--]OH0fcB#cg<@! !v0v2W@[|C[YɂL/J%o=8],ا~nl(Ͽ[7kv>L$,agGAXX.jt_7ЕfXBU $ _j (pl/nSin- .$uQQz4Nn$ uZb@KcH@]%8 SK3ϔѢNj1e{:ݼf;A}H6[R̩C̖:~ǹduWWS <;=-b=] ;,u0gB+3ٍ(`̇U9'h&^J='ƓzeU-GAb[{Mww&4QUp@bK/ƛvr6CUM9|S $$W+ZREQkE%o.y٧DHonV KlBS *+c66(,聀@qðoqH@poXMQɍq鳓 t&M @0v~g@ KuV۰՗pȼi-(=@``X`ZdXA)(mXͰfܚ4BJgJVkW{P@ LsԕwR̫d܀WWK,b4c=&H k[,Qč vy6.8!PAiL' \ +anpGy;u^TJ*9b8KI@ *DE tԮVa1dmX.|.wi&˂-4*m;9A$):y\czKӯV<{-, =:" f#{3Ll" s@P k9n҂'-quj llp1VA'x.:?ad P,NGΧpnQ3MP_R 4px!\Q$xBH)PdZ,B9;m=&muaË0/96%k"aUi)0(4J8SuY0@,P NEAIԷ s g-CfTQ( 2W*/ha[!pj`f?]zߌc9\vbf'ܬerfGpxYd?K)-[-= L-wV̥ ,$fWKm‘ɹשmZ9Q0< X F~ U.^\RWgA/*6IU$ 8 p(Di["B+ QÏA:cC\4ZGWl}^{6ߒF5_eR#YJFܒaêIuK-2=#()yhBnK~ BOPzhVD `Y('gX",Q&d K&f#0YBi9SBypK.U*9$p^hzអD&6S(o=mTZ$q(ؙ%}4C; 'c1^;sv{gu(Q8 μP#JpdoX+`3$1"J ]YrВk PHB(U`ak}N7A<zətA׃=if7"ƅ2Y_MF:_?bHJ.t"B(M,R+gp|B%Q>x ͊n Tid=ܑЕқeay6"bЋpPp$"XzDm: ֻH6C so(h܌3*cd}!u@X"yxȿH@6gHKV+)eEmm@KM$$C!HiTB!B2MY옖'U˫T4;%jӝkAndfKT+CNAcKM=f8 ], qĈgyFDzEOT|> [ôh<^~ @HHE @!.X5¤I b$ZD A D @ EHDj$-}@܈,f& i}nU%' x̘FBO_yqʽ ߈U yG'ߔA0I(x5-wݥB U(bP.]tS4;16`x8Pݛ*l\%QLQKqv7%dss:ŪHZd-nӆlF3!*{ ^ME`A`(.$%؊͔dւ\bE-_:.FZ `|nS115Y||-JThfԄd\ŌZXUӘHAF#a2{J:j9f/XrԚDd+SY7<& a$͊p ZbBVE@B2'ÁXe-DDxcY QBd*:(]b}2t.̖UR$NalWx;jfX FYZT9K:9t|43CrY X&0`[Ad dHC CR7km="al$m5MU!19 G \V&J hZHkB3GO{;I](BV#.b,^eg`|l$/y)#&M0Xi白Npt!2Ҕm\@/GSXfd\oxt2 5DN2hw';Ӛh|2&l(Bϟn4:hM`eG aZG%B]uY݋DbJB(*b[4Ө)1M<^PB]x Bdh'܃Ş~W՚dzRXBDZ=[Y +$Hd'VvWAP( wkHJ:ѧ_D$"U}T @ٱȢRF(qxTCxwPZj.*D:ބFl Џd55Т,% 'S }RrH%"*"7eQ"HVI6a%!Jp3!mS&ВtnwW|e;MAtٔѝ8ր}U$tQ2qX㌇Qr>q'^ EA+\ORﵛlymǺxmǩק5Qf몫KJ8zْFio2  dMG r5$:<‚Z0 RV~UXHx V":T[y9WS`jlvF;Dŏߣ $CJe)F90D*P]ܜ6t~͙]D9T#|Yheژ2][6OwK<=]]Գ!F Hֽ$25$- ݠEjB!89:y+$7!MVD{LQ_ R' [ޑ- [L{1TZi4j!Q/,TE2:qj4*pOS*o>FAЗdG)4@<{9=LQSis݉,48*zL)\@ٱmNhoc V#I0w*Yݪ~3b$'xi m"q{YH.YJ.(h.y ,0:R0DHhMYqȈ  d2{T[ɍZH;Gk.[%'6POI@Ӏ jR1S;*Ӈ,锥(`ݿgu1ȭf8@JE_nzWGOq]\]S:qffIS&.!:,A"9纻3^%a/aK[Tn.l)HktvmFJf{Kku5dy9[AB@;|LDž8hh!49_\vc]zeFeW_@ mXBNkC >U)W`* SE, ag9Uwх|kW ^|\1m[[P.+ ԫy-8 $hˀ? WY7}IHI;Uh+,n{vZhvPA$'}Jz$LBNXJNx`lf"rߔ_a(I:Uܫk#i ԍ O[*1EÙCeQQbov`wN#d]VC)C=ZߜOTņEQUC,)N4rCgX*C R3mueC"2!ܧ)ӱƙJR+\}+ 4pO:!3g=7U)" VcKsK >z5 \$\Le0Hƴ6 sYK^"nC @lR #~,sO?YRHApE.Mݪ:fH_.@ƹBud^V)ACHHN՜VP3iZ?g߹7yGw'GCEW LA*hrTG 0}YBV|[] ?5[{:xRy2DR}DHP5~`%akY| bLEdWZ5= UmOH0 oQRW,qL(|ӈ.}IJ?]( > ]EZEyF?nGcS ȇ 9:za PISܿ>(VVZEVdv"%Apᙟx»d+[i223= f)/omǰ"Aa `!08 c xj}R0)-C9bZ&svQTdcTy x( dda4C^a>IU*I3(m$JQoIA +x bg4Ns~mIǗ~I++# H|@`4Ư:6.Bqmc'|(tQܯ+!J_3D| p™hvC;eX9 ߑJ\k8ĻE$RL&t3W5e 3#9nA0@hL A*ߨx4cp]@d H M72C-]K 5QM˵aiPAQ (0!KT($a1 9cG6MpTuS*Q):ɿ ,nRfodwLdo) PjT(-qe6P8T&bdTWk ;?a#V Q^ S@ > ! _E@\2C ֋L(px*}+)E&(JɢBzz{&[epʻDtRo<ԄS\`PVx&UMikeq+B&8]7]ڕ 6R#F% F-H` :mn{NlM#^zSY>2!j 3LklZ\h>Yz'27_;̡-ļ/jZ@ Ir؂1gdy@z!AzvQQVi}|ŋ4^"u.o}}>s^U Ks?ػog!DdeLUC/K?d+ a"\ #_,0ސp|FO˸lRyLGb̑u  }M^sȰ?@J ԛXHXTK?NuC$@/ C@PL$GKWz“AII $!%|١޽-nė H3'6ƎxZܮ L"QOșfrQbA <,Ybх.m,Ӧ :BǓlEQU xPFVhQA6s{O|: X H QI ͠c* {-2& |234ei  %q`   |[Sj"1P1|1` *3+Y c3g*=e=S'No5El ptCB3 -b)2W-nOe@bMEX?$a=*r`{w $Ba9O=Uo>!$ U|P&9LVM%(MR~0ܣЭ֑_ݿ+]gV;p6j,"K,J=KT*d_ D7+^e#VL[̱= p-44Twh}>ںӿ56%1 ʩDlZJ[,[+{:3[U<_߈ ,D: 0 E,@՝xt@+CD1|ӓ 2EWl=V+1jV)YQ{V{jgJ\Kjb8^AQ+Ԫ9?aACfƓA6 ~R'IGCG?%rNAyy?TzROuy7_QIwUՋ~ȥc2PevuQV{ .R *5}MCi7?QpTfj{5T&xftt}edۀ_ֳ4D+\`wS =s -}? 9~rKMmu-4m+=;I[ê+lftw?gcQ Eq&ٰ(T<\ :2. Q#5V\+rnH1"b2dp&4ŀ!J8ƒ(=3*Af2|[^-ȵf{ 1LEaxfdS& "qs;kWoMZ826R׼]Go.Ui&]x鞫{K襨H@hA4a(܍H¦WҤ3)Ƞ &YΟ&mcԿ[J%5^7L"{V5-I9Σnd![U,-8$K=>́[L ߊ" ˞\\O;I*]JbTթ,|MxnzoT[gKNtad2'5H \Kɦ HUZB0tr]0Im;i/Ž(2@'dŸ}5C`\qA 1Q5ԂVB&D*w"'h'\Bl *PĄUi#2bV+(:f6mͭ|ƛs>{fܬZAGPX;$;<qg鐻@S# uAXLS(Z"#: @0a ajI6ZNdTV+/L`4cK="L _,$Јm1 `ҭSPQB3[%tw.b8tE$%@R{J 9>  {mwSKFqWГnЌqّmhRd a7pH2?h˴*9%;5g`vVȄ?0Bw\,ߕSN?4-DP/>>XofPfTx$K]$2>rf^w 6p&YDƐcͪbxXZ+-JDB&T%B"dҮU P4p+-l(S!OuLʮqjnXP0DϿN KL)LzS okda1Xk "1Ml3d8 Gq]6iiE#A v+3A(a ?K=J-f-~ޏK s E;]}DAG|(@ D=æ%ߓ2e*8Dg22'NxPuU9QqRY :V:J'Κ̦ٽ;A!]%ΫI2-< US6sfWlĉ%1{ ڸjzߍ̕~j_@d 3 D 5K=TЄ0zA B`~,-LO]bON`έOցoFbTa% XE(8 Y4J*P20%mcNa"- BM:}AܜxmC^%ȸ ˣbylTw4e>$G}Y]LАdh.zv&V\l}dHV 4B>${:a%:-#[,Бn|%R0a^l_;c"3ﻈg6^~^yU"0tJ/X UyE!#//Q][MK< )S{rk T8phtb: mQ'cdD*'{ˡ:Y#yJ=uUL(HԖ(aA1B[ꭁ|=5zU ӫH-LͲzV#.#އ_Ͽ6w̓M#9"Ed墺eXp8dIU,D;D+91#? #_0qВ-x 6  61vTAc8N<`G<d e!h<&mԺ=-*NPSS+|W|Op,ܛve~|-E9՜i4|ZO}/?Sr5,. dA5Ө rB` !>^7NE-ܼA7H*0tY-Th(FR?>SIPwH,Zf#Uh SWb9@[/= 1_o#R˻!+2vwQ~w[O*^H0<Pdw[XS B0 ="JO+W k`(qlE4Ok,LIXo' UٕHʙseM80;0VZ2\|[3EeCDjoE2*6 RSbtaaTDlIK|i rb)ڥ{q D"]+}zN@+hTN&H\{y(ȍk)'%A$KGi6p͋aho9 7x㟻~ O4I)yǍ}eW^49 ˗&#c5!qG KBzu@⮞66{P=6~^T:m`@H\)ydSXS @6$*abN!X̘Z 쩇2 , DZ INWGY;ێ禲"C)Hb&"* U deiKxx |.> vрtka l3)f1'Ggfu!i-QT%Ohi56xx1aXj4^槺9SĆA 6:,Eʙfm:*l`#{n!J/bZnQ` +v{^}*@ Ha`L^gŀ#Hub0u&BzCPظ(rTR+ΙRʯ׵6ԐVs0%9}+FNfגNd.`8ja"LTL n߲x @x:y画hg]G̡^N ~!^W(}e8UK#3Ѣ-Z=MnR8TVG?ϡdNafp}qْC(2/H>S+=.l@W{fD3 qRp?KOμۥ2C6i4Uj0#+InBS(7:%3–/uMKAIA`Fȋ)XԶIXd@-!t%C$#Ԯ S6ҪG9??cPJ ja݋;4*-}*2aʏdCl6V3,b3z=< XaL$oH+(>'8($&{_J4a&!.F,0l~v,%Ʀ*$^6TT.#LfA< blh; тWEAbSG,DͷRuIRMI*lE gN i_Ml)B"3I]2"4aj:0B& qLXؘ֟ A;|id:sؔVWg{= : ,i9`>I%(r <C*( aR.۴/TF06E ,gp/Ĭ0΂f ͖G`F,B(? >NFLϝ/?;Z="^* *$@D Q4ۮ 9FHݶOchC}EN.GDfԾ/=[l^Mkw)J^lnd*r6YOa 1$ h`$o ,(pqڋ׿հ)LL4g+:B, ;Nq+5Hϯ'jN ەl,h*2סE;Ex.)*bޛ(`,*Rr[u(@YDa =lܧV3RjF9(Q7:( Qfc:+tU܇baHZcN۱qh>I)쎼~'7 Lk[^XRUćOE¡&L%hvSaǞrH@FRq$ҘK3#uM\I&ME6JYTMaRr7[d:EWC)31a(* pa,0mʉ,q .8BEJDD(Sռsx| :AJ%1[Q2-vв214tv,W'>ĄUT4ڱ y,,l/t^~~~ؑd"6 yi.xT ?D8}(EB& hS~NL@P?EZLQH%5i@qlbSҍtƲfNp):.#IYVa. ]{5>%(n.94E` PY+d?gCK r51 ![ VK]ڞPFCz}x2  VPZ_륄#C3G5MwUͤxn2 Y i<^֖|Z b=ͧz(.Q FʲnJQՀzjfM!rˌ1yJcr6NHMNgt@-`[gwST[p+ٓ&),!N廲DOЙU,HVbDQuXzvYS_LdPV]WK3"K<)Aai[Lm7tBE(md}Xd=A_'  \iQ@-t bi Z$+M#DJ ':%Hh8<mƂe!,aF}?e*jMh/BeL NTKtj>x3+y >Ap0*Jf{姵^ͽIU2]@AIFd ٬aIt b %!Q?CLQ2 tg'MsivuKt0""^`#aW ֑:Fruv V1FC q܋i>'iD ZE@%oOh@B&w?>(!&!#mpUd8`P5A]a kpۈT($-H̬Z,w3 0’{ N5Omzr*<~ o!iip޴u P2|MuԤ+JHW*fc(u+}?q0P (e"(xh:׽jJ+4m0V'zj%lVcVE,fl"mUQ 0@ մK ]b*g+(7` ׽6ۤ4d.{6dL N;B\WMW;^9L@m"J8'@R7 .Tᮙ\ ^; `\i(;C42J .,Ӫ]ֺZťr'fk751*4J@9"3)д 爜ڹyʂ%SFtK.6О{ܔEj*A19@KQFRgC]D;oXāW#ߦUʣH+bNV0A/ |u[nYޚ"R0 !up5.)n;ٵ*&d3Zk@0K}1&6 %kGl%s3{N'tEX}KNb-c33Χp U J֒,oa+w_0m pÇ|:C]|}enٹjKUIr`̽K-srm*OeD0e>/xtޟ@t@(p `1э Y4X.Rɘ 2,' [Z`JjRzPj}o_XyW+e4GNuvG*_YԂ+pq>\N`Y =B %$Z!f̕v3^/8z$),ҾEZv=b|ނdܘ==9hR'֗{W읮-եmkJ^Q{hd`3OL0K=%8 -!g$o@׋,*WaS"j]Uֱ%*0eyU*IBs$Al,P0xx/\aV+OiN&zxDM+륝)>ޕ,?#rkVj >J2tyG:VI?_Q߾ ۻ-4%af4$1 dPLDm)YĽyp@( /jʈBV@,0A%bHXYPa6?t++cZwt%gzxexuwHUwh#sDt2\c`=?nM5ןTuV1RLAl= |\H[Jl6.z~wYI ҂:#f H+ b-~V8n% ?jڒ_5nIS͠rC#{ƣڹN^44ӜS$ d`Ճop>Jp2)I2*˝:E,/[X]9l/+9yv )Q5[;x੊|cfp*yCOs92P*J0LKZxQ%jFNn7RٶqD$wߗӡ'S WH8J/s[v *hh>t[:u*wGw~J.g55Qd'1;-!p"Kc;9wdYcB[cɚDEAEVAdmMWK0dK`:SsS-<ሬpֆ_'qsQ: s} `KgCBy"NFa\a à ߯[n"dRj9I(pSV!Iմ%-;= 8 T{tiYj*=9F4qVG5X)JԪtv9՗f\w\Œ Kl=J?ZHQDcykŭLH͜BcP'B(8HmU@(OG攞QD'А;mNfq֘#QXAt$?!c3nUW HC,gg!"9t}=i+fd{<_Bq{ Rc3?:_X7 dVDVC,3 ԟ—!ljSwwA02h b('͂*FJѬd6{8OֈDuݱfF캲FZ]A[^{l2;yQIMֹC5zQVϯc"Kvݎkx# @#@?tHBcRpJSFUn7-]_?R@g{Lܧ$Σ;;Z<[hGUF@PÚ]镍 B m,h˕0T4 _[Ufdfs;VIȹJ{7mN3 ;7)[MáK8l KQdn\W 7*a#>Ns[,ml51} XׅSx352z%uz4?~މ<axI Q- Pz/*fR&KԺmX5)R&;n|8Eg\;$3 e*ĺ3TE(wQz P=* ?̊qΛ% E>} rI=`ES;Ik 谶ֈ:Bx{DLpcO0#T}H\/C.̝1v]Kd{[ɘ[.2ZI90Pj4s\vNpKTp@`8D?9/“?kU M4Ǚi* @T ٽ%odkڐpNa<4,/ȃD@or!* j="dE{fi2>`.Xjd1VcOCR) =H P1 R e.+13w\{xiL_zrv 2˪6/2BO+#,L&`bU?D*$͑=.bh=9L!;A^܌w[BdGZ4[Dcry m '3Ca#%V2/GIDA"7R(P$`^dwcV$'U1-8R&쌰pb ԺObBdNR`_hE_do w/cVLY>1#$U3-pYDÖ`neaWB͊o4 h9Y[CgOvdۀ*W 3b3a"GYkL=ȸs$-mc,%'+(ŪEZ%eνŊeetdq @HV"™(ӌDg N(AK~sj\xc;w fsgVE_jfXcb`AރBCJ" ,@ N bzL#J@;]~T0H4$Qܾ.ag[ZeM!(6{iD #̳uFS㊎-` z0!م1#FO:!gI]f$q5 B ( XM6s:jɐ k eۥ_lEK 6$9""d_^ #l0@Lk !P?a\5ޡ&@tHsŨ9}ca;-mk\$+TCjPL>4]3:D%501eڤO o&а Iie~X톥I\!2$VP?#(uT|Ѝ1&OxDK(p+b3|V0PV;sUR⮡JJ {ѸdrSdR`BBrM4nH&!/ J @4/O,d>0eYxaE 6PO0<r-V'K<>vpS50:de4[ap1b J X@~]Vv؃7e)lB R뤍Ƙ@ˌG.IRb"ԙ&eML~VI!JW]jOM>em10^~AƇ~K/U1=Hm2U򁉠`81>Abdـ9LXk ,@.㻼abSM1a ɎlQ\9ZL݅̐ێ]Ѥo2H[,H! {.40QXVaiȐ`~R@8S3ǂP=: pʬkcǬO%*6M+L Ñ}͢jbYY]*%eS60yc6_ItrYHvhhl$`|¡R.X(paƇ6 b`da<  $*#JMKjm<z B1R'uŐe=gYoKrR/n'kAX)ǂD\:Bރ:qHތHǃp8)oCPdIVP=C l<V dFՋ/D= 2 2 kaL$Qm4l9(HЃQpu`%зaH4~[8-ZG})o-Ru'kYa@u.QSOFAz݉kEUkธ/,ܬ*CH ;ںL-=ZDjUjnvv:1jVA #ږ?'׺Q@NKQQdNZ |14ґum j XwlFr BY08͢ 2@7[=Usğp ,k/E 8C#GL5moU\.~^ AlW|8I!WBd AՋ/DDC#a\L1 h@@u15 0Jѯgpo>jȺ5*Vlϊff2./?_T8sg#G: )C*}"{MQE*:9Y`{(WM96*TN˗7qʶZwhcy[k"_RI^nHheJQԈ)I&8Z?F|tX`k 1: 9c =,`rō$OR$ E,I"Ȍ6)(5!L;w?/JNM;._ꖥG7',ؑX!g3=tX& q?M2B;W|\p0Y C"=+v$@EM #Rml* zt1.vWUKisHjvfmx@<ϱh7AiC8d9y!7l޾e"4(rΜK]Ce78ű!}}F܌L\!ke3jsfUrD$pq\w;1pbܳ i?փ&,AakBn,: q C[[)=1&WJS„j#4FGz%?KՉ?og"LtÉb  ?V3&[E;6ud?`XK)=h Mg$mω,%8,ٞRr$Y )FIHbb8ሤꟁӺGʷaoTW pXǣXP64Y-+976i4Z:By cSe)*2ѿ`cLDk b3W˫}U;\dhAFN:wG0# 39''XqY=DZ G0j ƼK)g٢u[i֋s č>dW6fwLۤRKКaJ*E>9hJ3jmd" dHIYI1k$=#:Uib$s M59\ PA2 fHmn% a/ h<~A7")I r#@B=4SװUc5)ۥhn@O;dOP2.뜂DRB$F4s(Lv:~@ȥqëV`#0@ z#"7}G <^נ}B!uHc Kb@HR)uETeA꽓~8/7 %yh*5s4 Q@KPdE} W(j< e/*b>摶\`l+L1$^qA)(sCcADQNm#ld UX 5[,,[] 0S䎬`!hE4uXx4Zd<\H Կ]lƣ\X(@&BJLKXWc)*7zZ99XEI]e9v2R5Tp=$Ă"uv/qG}ۿqVϗMP/g\\li,MJAD#,@iP (UK巔Yd<4H9 `%N;v1)xW54U=‰R>u܄%TfFr bFD,.$ ?-ɲ{_f\ Z!@,2dKX +r@ 9e$K0 x`#dJ9A |d1 LqS]]M,0.xKZ V pi@;Pn<64Z$3@"*]t`mF@إ8'0 \U1 {:-Fe!ŵ9r25" D-v D=O-!vU,: fADrj' p\@* Նq6)@H&)Zɥ`.#NvC>o:id-d(@;#44ӓ"i8uNΓWΖڍ"CF3u_}ٿݾ-DW¸'"j6rdj[c JC =#v -a,0K0=aU\}74wx_X^x`)]fh54ͫF_0f7KoHA6UO٬aERVWwq djВDOE+z*\4_ֽ=CQL zlBnhg /zJuE䗇:#6ƈFqh"BQrE!% J !4Rm-`6orOUTWvҍUE#O_,2 t@T֊TU5AJ:V9@$N42d VW p>;,ߊ)C 8ĺdC1 BbLZ= \,0k0w m!h dFW39(? WW &$YD`hȘPYa:#9W:L^\aB5 `f(Ri+e*fùxou-,CZpR":"N͙K]p㲎yc"nw-a;0K14bL$BZJ !ᡐц DBD I9Xh2/h&I=JL!r`0Kd,2r܅irh&*p^ԋ&! Σ'BNj9*Ii:9 ̓(=Pc "@G dZK 1a^=4|kp' #9M 9B|bkþO<_xFͽ0qF 6`TP2qn<=0,Nd#\[yb2B>0rma$q)^!~(MD Qd!A P&nQ:cCBqaS:ϴ(UVX<"N g32 0*{j2lNKBDY*1vK zqEMcOUWsX(91W(>(fJMRLGTgk{R)?.dkR1TAu ,yɋmQJ}ܗ 2W wJ͒'h l`6SQnjR* ;0-HJLq*k{:kPiRBTl(e"?8 E~7E*,VUy/bKH:X U,*[{_ks6v27da2Y 1;C=zie,m ,A[F bj]X^ hLD$ҽ;Ȯ*8 €@C{5vf<#܁YeecODJl,HŻ%HNmR'CAxg56y,UF7뮏OjBr#H wRpi@ <%@#N7FĤb e]ãfK[K SCtUEOwSx̧ jz8e^m}_K^QL#_;E07xh' bK^2w-LwnQ3&Tj2+#";YaťݾxE$C@=d\ b9[}% \c,T$ñM Qi+JN (p 5kyJ: Mj'j<7pFѝܬ8~ʚ(1Ua=34 .ê~HkM)"@zD^]}N9ORVBp6M ^_Į~*bbtZȸ+276P #r+ Pä@K#"P3,T:svo n(y]W *^(?L$Y('f+cTXbK+v~eN0ޣ)6HnJmSsL~vs{1!=H֦Ucz?dWud]KL- K a8 ],k%Dv&da4I)ϾgHCB1ϰd.ҦL,U؅%F;sA a} (DG"\8C,G3݉JsulJt/d8[-#"mY6n]jh[牚FTā0H!GċE ;wWJ ZDP` N#DdBq^)1+5޽,sVE3= "]/NƲ5ubG+@MҼlSE#/\Z#3Aؙh tX=E2'{^tNY*dҷj .vkSz9 IdSWcO>  X[, XVzFKq?ChQ)N,{_[$^!&V9C%F<ЍUFĝ+B4I+erq^p鼥 F*eM&2͂6JT򀊰ѯJT74en欆^ $=ƬsS5(vu_ސ_ݫwj *&5AѤs8"DƉJJE<+܌)#>}?@.*AoIG`FC)WU{ղY =a)hl`3^h)ԫ4f}hR:iS9UV̱z>):8$XdI[WSP7K_=&aVL,LjhTHX'8CdETM)W $BTq0kB} o== @PGt2aa)Kq-)sQYfIN:A̧!ꝖjPx[Q@<ʛ]""1LʥՐFuZ&?ƭv7]ndSüf  ݤiaG$kRTnBr /;ՠשe">t?j$* aF-9.F Z0!> $dj0VdC8 |M7Re@RǦRUE?doHgel􌍌!E25 ɐɜgdqHW@5"[=0h e]L0Q@,((܊ Ō L鄞Y`smu0ϙ(yh/hk `%t 4k@(2fmI gPx|MF QW1~<Ț"9HZP\ŌD1L+Ȭ*T:zR IqxǸP )@P4T;F3[z*1I{mRdCþ-Z\+ sHWJK"GC &J|3:Mi,g~01f/noJ}4FUU͆2:9dB#dWճ,0:d[a%.ZL<̋-( *f*.bSN?z,iDsS݃,6υI+7zۢ 4T-PpK Ә"0J;ݻ3-u<Q|*')pKѪU4s?F#gic3B/q7z"u#RXe"fsp@2y}dFd5j@&)G@ w(񓊳{f`JOow Š!-΂ ` J$0%bγ)"$"Ҁ[V̀L3npnܒ% ){(΢B W7aT}jԡ!4N߉ΈTWd,E r#R"ԧYOuyd9V@2d[a?Ω/Z0q,4􉠽9!'@>dD%𛏝 3 PɣSL96{Dgo@/H_ CXQD^4͒'_JGZb%Y1C}mH&ħ>yHmU'cE7зz ΂M*E~FvhOK__<.5ǩYyR DT0j46 ex}Sw8IVPus\x)*jE$"T)^5Jֻ/VŽemȮ͚i|'KOևёU.]d4Uh``Ka}RJ'Dz-8ٝYbJJ~>B]4JW+IdZV 01D{<`LAZ̰t(` `":J EmoAG[me2i $*b$ h^mN'%)LhmQ=l-:4$V,dwXgTF_iU/= DD:q60c ~ @&|ـbՄPBWPbEU#zii 7Ǔ--B#/J-,9a]Z 1OP044x<R@Bгi>H9~*HP>~yȘ+-1D I-\ss 0+Oa"f \F HPT J CI:x$@M):Q˾*tMOs}?[ptcP[w("}q+W3+^4M)[>bY͘bst\}Kw+VɦNlb3 g8~nUn~A0X%םIsB^pOkzp(,}< _I@x7DRPy D][Sn;ڭtH=ymt@mzcdN\W/Lr2 NeLl\$*ﴌ0v R/k q̌&+."?M<|RJ#)Pv$KtuHxb}[w Gp/c|5}d߹weZTu@^7HM > ,B2C3+]]iĻ*CSXm+ե~Gႎ!*F"ΒtR`bB<q@J 1>G'ukQU3Ѩf MxIg[}b+QF<},f3% R02tyu?^Mد|%)xhy~ 8您CX*?kęsZ֟ZJmW*&b u h:Xn!C8P2ϨQRYJߤF٥2N圙s/W")i+!Bdߴ:R5jߑ!;ƚ싣';%տ\d؀~]W)B8=a$aL 0+l+ 8*ݜe`qsԑ&sV#JT^ p ϰ $ b^MCCY/I\NK^"A@*Q*e|% hAb*Fq #7JM":eG@?$muC \]'ZuIl%f+.=o"-H֪Jzt A҆ Dz^51F ؄foX`&@ 805c(C7)@!;We%a.+39L~Uѐ/Ht,[ >d`IYk&"5;=(F ]L$q@Α. pd`;𐵔T繪g}EsӜ3+Ӕ:|Z +#T*yX낣Z!dLM2؃#?n>Τrh?_,Zx0)=^M"5 Yfp1 <"7_( \\q4C&00DH:(&Fs@,Cs DB0>9ptn`,VܸL3vD7R7n Y(&*E7^r dIV)<*<†\lV (g9-d20&X>DUWiC= Rd[B ,EUB$c|ę)`1/@CPFexH.Cc- H2ѸKZH*;*M6s Mo'M&S!ceMv.W,v(E!, YQ%E+<FD{62_NNAR# *Ht FL1#,J?akJ )47fRӜLM9V~:Dlf.FB*%pt4%D(d|? A:=#)Z$t $\%-hArR,:oFi)CHٞdb V@ecM D1CD*"EF ͉4C Ǖ(Κ(aFHxPLRgKY5HEk$bWOͼtح3V[+5M UPAVC]O֩@ 'F&3H#"'IjDdIݞu@3#*z.$uby hY G! 6fk;Ҡu\<>UjwM83+59dk] lǯִd F =dMa%:M a^lS~ E_iF*pb?yxgpF=!HeGO? ,/J#=ݸe-P1iiY0nb/snU2:*6֙jEʎ'I0Ln!BY>q*G[@D%cLL~$$F6#]-5#@,8u/DxgG!R@BB;#K\dQ7\] Cx"wRVE^S6VY 5;UKRȆc,2/mof$d|H3 =+=b%XLt&!.~x 9p0 HE[\˛$xRrE*]έy?$V{Li\_QT TS+%2/iT< {lK.Hp$앚❉ݿP9udƒY֒_"9ȕHc54p+ Vs0-۲°htVWy&aeV3'xuɹ芋vbZbWe|Y-+"YjRw-ʌdhS,R3=&HiVLo ,$xe8SN~K$Xjp: |dZh(3 b#lE-G y'xEix!usoV)Ef(P= I4Opݧ s!4ebXMI!0L@hz(-8vrb5_KG)Bd\U+O3= a$fgR, ];7û˚gY<$JiZ} i DT'b1#*~s6LH2 //J.>`$f(B, 4*:pCD!%lY0!KN4BEgti|\ g(Ǜ¨\:!3:\u$T dh590ʝٔ >4Q**`KҤa4W66EJMCALYF!P]CFWz Rr۲Z d:Zcr2B+<&p`e, 4b""nu ȗxvY|%6WfmOʇ 7%54cM 4 xl~r3zp/J\FGs"u4C'G|T`p7u'ɭZwTI?'y* H ]_^LXz;`sH7zW@@nAP9[C s3+(+(\),t_.ؽks6p| } $ > ɐ 7]~Ѿ3@D  m)HH#})d;Wf =NؿC !<8!:EtdF B5;a"GaL=)HpMU% jC=V.ޫ^Zv9:F"2v}Kz+s9C,T>GN`Lm*8B,+Qz(H(_+F꤅#q3>yJR``3ɐ-(di||Z\pI J77 bguYTKQd7huxz~̿f@c *`OoFRM@Fb`UQ*+?1f/}-HOdc4Zk1R3b[=De0˕,$0௰aN[CVjƀCc&>65ҭ,rePaZаEyZ-ͨ(G#=υNO7U@.^P]Be4@2  }gFGűX*CB47-(- Eo rB{*.Fcܭ;atx-tˣ$+F d (T<?0gAɎ!!fAţ`d́RX`R8[,=F mc=(x,'.6= ŠNi(ydRQ\5Fw6)[CWbKV4u%ǘ+QAq! j2>ֳHſCBT퇌)q.UH4"A)1Q-  J.ջT2:]jfz\.yV2"B81@)=$ȫ]Qo!nDW1UṛZ91U 3]=贈E% ĞtDh63JѪbʠck[KEyZQn!2u 4,G(1,IH.b=aK-: d؀Gk@8d;aIM)aL Sd FDfJpYL3#s<}2B ĹS$hP04LEKˌ:0PwPDBءdnZthў3P 4jq\(GY#^S{Lm'Ub9}D6'#gj;tofޞ+gt%Q@ʰʲ]h`s"ғk:w 2$lͨJ70O^#]~|e_]UP $UvAHHA%K[KYB[Z fU ~;\x|v-j7JEsU˥Z P3 nOI Pdֳ |4a]L0qHIm9#ɱ.zA΀%IHZ& N!DEYC2یmk@ ӍUTyAI()ssE J2b(QĎ59=]Nwt9#8* dyHk"_w5F5JT_?{F K hHJgB>'zl'Lln "L(LJf]R ,\sR2?.^\i!  >Z\.?5U_EfM5!T ZϽd3 DB4!K<%P)]aL$q ԛnw-E7z+X לҦ(qrڐij֪F, V\%WLeFq3d]Ԁ dpbAyRC!oFd%U^SX`>C;]0J gQl$ou8?1ӂ96AFA PDz/҈Z+[Gjh6 A2aP7u?؛MMGQ-4ŘS-":B9LY|`,OL.+p) ]t_Ir\q#rkVt޶778I. h^L1Baarx A))hŸF.]f$֖iWdW|'#Y^w:HB$CP⪳DzX YNϊ 6_pHr+G'@^!`@ZꮠU8-i4ĠV#~W]05+oϖh}pBQ}U !P`$@E_pYQRɯڌ0AL MޡWgsZkxfGUQU8ߙGc9k?WPE#U~ed%].|NYek%UUr]Tfcw"z N,Iro5+4 =Q^i׆~Zo& ¶SK5'1_ߏ䢯pX0'X$!bDf2Ч \"0q<[*+%XOĚJcW&$h`mU*@:QZEW%V4Vfhc(@(! J9|ðjb8,"ڍS9gu]VPH뫨eC79]$Z?kdF8 18YLm6$27<2II޻ HDdӼ$8h` V$V}H,vv8x"aP>kZZ谺-d ,v c9 %hs \ХA5 N<:nc$_n@!öO <}rʃ\h 8 4HM)dfU @;+],FIMZ̼ otV@%-j.{WN]zrm Ãq0\ URolSюtCvZM^!nަVYc-٪'f@$5dTПrvE]i|AuRPbqg:ݙ޸ $Ɋ"cU$f>HFƄ>7)955c2Mmm= <ɠƟ\ddJ)=DJ="|X̼ l *!0 NkLm^`28J0wi ? 9$KɰB3Tk' ua,ե+67zţ+Ώמ@9-^b uzVAJi} «'b 0`ȅ 2A#"0VeW* /K*jա`a8}!$ċMtb4 s'Jٌnr^~1qYY4̄d+TV DB:L=GY}\1mt,^b}Om.u@hDMS\PpcŮV1gpB hQȻ4Ns5O"E* k Т G[ 7 0E\[ NP"¹EzN:ƗH, ~ȕt+4 uOv@NZe,㢗Q;oZB,;R{S;*W$TkVlhRdр6X 25B:="HQc,R،+/(rl#( FgCofmx3W }{}I!C@](n%2?nwZ@K > wYWkw}(·^v#m#w;[qѷ>|,l?(ȰT"+ z9'gp=^bZ N)U9(ݫ|*hʊ,j @jK¶,% r orFgqdA@('jW}8,ַP|!4/ҩ5w{!Xtwhp!q*P@XtXy/@Dd݀eWX,8K{)ҡ#, ~- :4.銗t6fE`9tϲcU:TB]ݽzgJ*RvQ/TQY)]dEl`Th¢S !%%ؤb^"Q.g#héǴmTKJF|""? hFHpG݃Hr@>k)nGPȞS@;McNL[)䐊O#e d9<-"p+T .ՆeٺRVC%YSASPC4E| 4t%; -n욠!#U6*`i P+Jҽk"k+;u\%3/+XWG 﫣$ ]c&lNe59$:S+\]PԐИVR?'z?,7R\X MZKaa 5UEuo`sLBLZchaĚB~@k*!Y ((:Wk[`ّDkQuroX^I `7!iu"''$)\]b%*G  )>f45H"rd!\V/CB1< k<> V!{ޤG-.: *?0ӳ&ť~5'dKyx׻6,< f;OhrgT- Kenp:+@%b;h]K#`܁~q  cʏRj :ͬ9ð:X~6.KcV*D &?9h ȆE U!*n P&xAQXpu.`apYAtCd!͡*ufqӓ1*cB!Lv2)4$Cn0_oJh[) d1LR2\N_L$Q -kG0@;DŤH,l fXTT*py7pYM2F0@ 0@*IaM$RRQJX>Z4@skP ja G 喭MٱWuvWGN=үmjMe%WvCJC 4W! V-JGY@CU56fg̋S̲o"a )r_eH(d],,8A,\0qHmp'enHl,5L!nHi13Yi:C𱒚{fi}ڌZMn=_E{Ա4 $KEԣA֑m[uژȐ?5FOe 6yjSj)Zi‰jE3݊g[UBYd0[e"ph@a0s DAH);UhO]nX>{# ǺT!f< yj!!J$LK4Tq6w@x"uV.q-fc7Ȍ_r NJ3ZyUsM+h ^r{B0,-t2Z!d6]UOR9{za&(%_0q@ ,&8 BfҸ:d}壉2 ,j-[FJ+Qn0K N-` IpU Z:dp"C;䕟vVvt{UM%$ԅRA@eRxc~/DQm[hs&vU X%2Gh1 HCF@:/p0@h``R9U-^)3f,i-7'VzU07"YpR"34Br@1aC!Ne9!sX׍R S:GgѬ9mRieWT2ѣhFBׇCb\̤-E_}:;d7gIHrXל(lag&ed)ҺAD H57|h .yzC:Z k(-~*D!Iڵd%2!JC|6%U@dujiVЭu&^#qޛҪ컍]4r͏5~')6Yǹpbjq{QK Aqd\YU/DB<=> _A )0P2d@K/4P9[OFCHQPHr:g4K/M)G}T;ʁJY 2+rj%VADw`VIRdHKٚdN ;1NQhV"Z9Na>~_»Ce!+kd .ڣU ^㯴N4[֖V PaFfOEqdHR0œ=+]0qoqgA#\].!ق3C V@Y :;Wc.u_4X.XF hBgm~ AndІLXsp0;~ a,qȈ,,(v@.{Y=:Hշ$),/QCC9)yJ@i1M(a2F_DQbpp0막`\VV:PjW]J艆RJQV”~Jqj4$nو\2O {QUԘ3JRN䗹x  h?Fi9D. 1/hKٌ-hYc"R\ 8gˌM869T%6 8`tJ*^mB \!2ZR9@+t1v:M%v\ 4%8dd#R<_RqUWV5}m67N¬3`d X/Hr3 MdIWkC.;/ %a0qӋd^} P."1w#3ӯvsĞ\LڸP2{"0pDBx=y.N&ࠈȝ2pbhc`CIҠnT !ԣyiP}jfWM* tsFTa bvSv0l$znk+L SE:Ckd;w''V A/!@ $P9u$K^~Gz~ad3ǣ[/tۚ;ίniP0;70!D"EW Tr(ԨyL!F$$IV!eN J&Jfe16錜Il ȇ}yBQ/B7\ dHS RAeK==#J!T-4je$$0=0pta6h̩pP-0о"psA-Pr[SG jGtáV+!(CNV l65 z_i vmF12l' A4ME0AVKѨO/gyKR30FN-ܷ/UƐE A ,{! Р :'qtYfTYF_Ns0K*1MPDF;h&vAj yy,$^`xt!cYDΗqi9%CL?QYpg|L(s]"ުf`^,},*F4!գ]S,Eä32*+EDU؀oCd3W R5BkJ% #a$qmt#H (Fp'&X@)XUYdx.㎆GQ\Qv0$Yg]r(m" QTSΨ }DEbH1iN1Uavq=?q v3)(`2c:ʋvOdOe$Q"I Ă$س!BA8 Y3cw2B+4? K[_bt#(Xu`XcTh4 rʦI9gLC@]qJzN_g,EQK)hjįr?dgN]d)TV/B0 nz8 b#l `P8\t.ʏ@Juo{t&V,*D@ )؉0/R6"x6 = _YdPa?F.aAW}Hie5!m\Ahx1&S#0O13R:WJ1A446y=P=Ǡb1!G*d1)N0N9d\Wk)R3*pFwKJW1SwQ92Sg1Jf1z5ѡ䤉 D!Snԝa!FaLo1 ҈9FDr/yis?A#!(;* T:0rqjE6FhU7)g%}1i5= <Dc=<+X:UgVjғ_yG!D4jշdCnW 1=#8Y%R-<Ϙ йCD(_EpHF``s5?'kzL$7cfDgu{NXaIXY\ C.ёU*|'#B w $|+NdSHWk /=#r Z̼o@̑k P=kW1Ń`<c;k2QjXq|A/71tG<`d]$"vfUk$۶v11U B9Fx%{nIFhL#dĺR qp(Y;9/lB-scun!)BXJݝ4 1,[, !Nyf`儅#_!܁P #xM!l7&BDF㺓3$y(fI︠k:X \ /ْ9daʈ;.ޯکe3j(wJ#%> (#0dCc R<ë LMoB9\;!.  $>At*'(밐b3K $j-?MFDΗC#Lq,賎chFӦ ϴXVIHTSi nD1ͤغ Imqh:vQϒ™E/ J3*dqQ t/A8.vpӌSy@[0kxl_y ^LC1ʟGBG * $v9dc`K)r1& ĭoOm0"`HVA8fuV} IPҏ$IfR-Ax'ami\ ǐt1[DUD '?ve s(edd A$-Jr\,Jp"#8$g۩ ALpO"X>Gլ5)|r K&vcaaZgj }|@$dxbӪOb ȕaJhP`#CL+Ef¬$:B @&Ƶ+{5>)cal'~a, $c:U ,b *"(s9܊-8&ʨpKF9MsmQ56 /(z+aDN7߯$@f70єiA R 1 C|U2 YƎAG2;PĄÂpstN,h%ț'b.YЄd#W{R3;=$ i0QeG$" 8-=:" wV̓YgIz!Flޡ" Hn uK @h0h#Ƿ-YH7lLLH@"_E ,q`LSeؠ2k h<Ϳe7x (Mh}VfoiE!wQڐ)#7 :u# #|BfD_!\SK"r,S#4j1&HXPMM@@!42pJ# xekkO!dJћ(؝Qo"9g ^sƿN !I8:҄%^Bݵ-dиRA52rP[.xk|]lbaF!8{OvzSm79--ig・'bw;d; 7;] _L0@촖$`:(1TL`PEpŀ #MuʺBOEdU6uDB3!aq^c9 6].GL"(f'j|[{pTt{E- 9Fw_RuV!-7we(Љ B got 7j^qUwŽ̑4o7~~y4ebUXhh (<ȥ<0K\#M<e>w7dIW 4B=Al gt405 REZP6'0:* gN=G&#AqF z*BSx<<\̨ JG`|!AjuT$\ŔzwnqT_,#e>LԤդxd*+*4]UbtUOn3kzN[EL{3Xdbrݥ#wzϮ*LlʟouG0} AxPXk-a<'?8R2!E ƣM 6I?\3E8+23t@W$p9D*4 fl79 ow]،FFǼ|/[^o  #lmT*', jm( 86H6i+:ר Ia!@(1Fh}f5f*c𑭇FTgyr =Vk+*sVv.xKlZ>kI͑"!Ԙ궵9ߗʂ[dr[K)$_d WSI|2CJ=&8 u`L  \@g5x!moN(Ѫ0lD8G1$Z͆W<ЗkeਭNWHJ8²jŻewn`"YK*zgiGXn~b>cMdČ 0mj&c~:,]Gi0&|٠# L:@g#txe@(y,M1Rjsm,PJZ+ ht"Z͞},qs?\˃DŽê}~: O-GLVKx>C!Z* F䠠@r.{wdi@cLP>[MyɩwrwGkIu:tdijd;ĸ] Bs_!c V"X(lK-0>gfQSWD@H ?c.* "SG֤a JРR#tKc8j2r^7\$ytV6X;w=dG=L֩zjjygy弆2ZdGWcLR>d*1"H _M0H8;XṞTc-e&C| ~_C2P*JuơbaS11* Z@p2h'5W`#Pyмky&2%O0Q/LWģ23Lxҵ|=LcaZMl.I!iOA *"3@Btf>дnE+bٜݍnVPUi[*pc/X! QAѳ16W,HK{(5vXĘfHƑ!#Sn=s?/gZjjCn#Gm8k(dFK/4P9 =&F#W,d(.d룔T\;E(8#pPt1NӄsЕO1mE;@A ,}Z<S[z9,Ydĺ& Y_Ax/jz>:pNhĤyX`"}N0X,*"8|c87kcal~pqh߂$ȭHmکY;*oǪU !vA9Ұ3+~ I`ANJi_Vn#̊&Dx4 M"IՏAvvK0e#Kyy4EJPꂊ/]FdGW 4 _0qpt6;#3*3 ;*'3:mecyjѶ{0/ayҏd JnfV_O P4!4*CJN4;L6,$nub@,ae_k>Z!KA (#Bw]4sa0#F%ͳyp TF"/Y X0!>UseɻֽiV-lL0Y^=ٻB s;Qͨy>y>O3_??ڈ`@`=`06K݂Ph0`QO``96@#VvauBBJ:;.no@y?i =#–ivdSUC/5v8> M_ 0q-f\zO>Pq$ʥX M]R;  4l@R 1$ˊdw5$"ӣVS ܁.3X<)LJQd+/,m#,i5jUOZSJ;)iܗJ]j@u'2mL tPTi5J<^Rt 2DR.V'b.dzndg65*a-P-ST/;p$H` /!q(lB04Q 4|`p 0'/ZT-s1A< )q`)tX^7DFoKS&Sǯ'E>;&)kdHWp,+M=%,%Zb&I"mv9GY #EJt0(UysC#& CXׂ9ag tGm * S ڷnja &R(Uhv6}r 8iSnga't%_@Sȼ=1N,qZJ{$sW <+5OlwV+F+96ҽxjhkר>-/nPkדT&Eģ1 |*O=6P1$ݫa­RE*G!ҦjF ւVu{쀘2΁pH!ׂ-%`I\B`1`Uy{ؤMCBbb=ލrRZ~̼"@R : enmOu[\ith8(D.r: j^Q 0bs7zmfSsBS(ۯ<0\Ɍ֚2 Wڣwf4Sv`vC4@i~5Doi@Xh"Ebo#KL"FQZ 9l K"*qe2F1xʯxįRR;ELW^dЗD;FA`rAZ "#@_4 PBdӄtHk 1!;,= 5'a,q@ ΡfmY٪  1Ä cYFNDYk$HJ 84 ޲qa3i$mHpŁ%P2 )$ _K>4IJcCK- 5,` ns(1P@Hމ*S!#d9̅WA"5 `r#$(4ROu06po2Zr}DOA~j;kbApd nK~vRp51|v)ok!`fǺfks#kUfR,Ƭ)$sJJ9JJ3m։wFaweMsqd?UOv6k=<)P-'_QH-[WDU +8p,w,VRAbp̡%{rzs@Kр(TcB=)&0[D zYhBIfsϘUɛxЪkLp ,PrE4 fMq5-6Gܠ(M,۝8W@Aф8>\S'H4T `2A[ġYV˅Q?m*4GqgtF KRE ,r޴*@CA ÆD;H)b0u?L߮p+˃X'ݵLeQ[iLo[eS|gR޳{7W&--/dQWO*4kmfPY-*A;Ea@A=E&Nץ"ǁ%DY@P /aӾ,&섕²e+As-"Wi4t ug8.w{3O-V35ks ;o7&3 );zeVy{}Q˅@b2bj@l> c zZbd_/"։Ap(.+dGVK/1|-=f'], % B'ԷJfC^eM4]zMD+%&ji$B(Bqw#I>GIRcrBeUf{X]18,*@ Dc%8"uxIRy[!a |"HE`s:Qι`>pA,DnI.W;RFjV#gx~~uLUBjs ިINUR1Zxj)ċ=JmIjɰ d#S/3:J=T51]$ShWA P60,7*v!VBR}ЂQı:dh*(<`zGRQ4pP v]:ES`^:@{Yit/4f^<~y|x:`4txw]ꡣj~Uo|wo+-ǫٗ'}Jf@#ABtlS#9,2Ow8LQѲ(zO* 5 m`TiZGQ@LeհhB (oѐ W:;?~:V 5# ro.\߿~TZdGUor4!k-I'Y4z=w  ;xW\ A b!@pʒ#4 gԻN""n)x'*Fi$XR8)ˀ(!@d= #1~rjA80 DZrޞ@([L`j+]`M \-V 8LJti+RYSU!4Zs-B^7#*DSDd;Qïd݅ZUo,R,= aQ ȷm&vaBg^eb b  = FPxEDI Aqh#jH:e]?A3n;d>:#) wҷ%jetK3O˕]R)ʪ]ŽH NR'$X^ws*DJ1sX(p5G$MX|[9E ّo,eu[]5~w. P1ǹ3MbU41xဴR:ڎR<|+N暞y75jJphtmŬ)6yZT>M_6_6_oa㌇d\ToR.J!U-q@m0歗OMq6kk֖'sDHT dOB՚Pw fB2$6E 42;QN^9sln]dWģz,rSsϫq_#E‘CDOiT"sR"#v+vZD@bBCLPx5JF>:If/mvrcR+9@L_M32""kC:UT-`?WبXwVb5zsn&h{VUggd1^L./Z,Uznkh"3Ϭetdå_*0.o˃býM@ɓzߔ?~Ğin*\ï{2ص$T?b L8FFVὙ#tBa$^4snyb3X_AIխQDjs+)}ȆȰ5e&0ÔkǍD!sa.E !LRaճa<+JIݖ7_p3k<طL0AqO9f٩(\T3?p#r4CyyE+XϺ8dՄY(0ۭ)-Q <8Pn&Nim.]v'?{qp`px/  *VϿԐJ8]I+*4..gʽ_޿@ͮHM'yiqIݸ0mAyCe0DO&5a%C~:_gG_3lY.o%Wdc.AALvZNHqQm}TJXjȬA3T&a&GNS}_`jSℭAתB@(ǚ,mȴHbEqH h@%keiHQObe4x->ϵdJTOD@/Bj&-}P=+4'xi翮z H+Hm2U24CD\%RM/n3+&ΙӁ\z3(7O4bscGѫG"LOJ(fKBeN.CɳXx蠕(M*\=L Ms#X`ʂ {wj|_W2>_|Tgtμ+;f}wþw^{#+K!b;PĕuC0^H饣~S?5!Ke?ok렐+Ss ym5;PC{1d0_d._OE.:,"}Qm5vp֋ qr$==VB36n\ASiAL 1;l!yI_wra6ld0$n/`U<׌}c]o s߂`*5sE`z:IttRd4mFWMB@q>dA6FiR 2;oMy &EwDZ$!\dV$.BGzi*"ÀB!}z{d4$aK+-bj5b}T=Zg8?'\;#U5ǐ({#c >ѱU"eT0EXQK9H]Y{2Q}Zm?=C_WCR!D)G怵sxzR2>2_Omo^ [dHU{eZUO%;mv1^ ^M˻R"E4-omߏW=Z%gtݨI+"7Z@b#YM]ADsT-vjv4:ۿ*uW8VEoU~%<BX0s .=1)QN.d!QThǩqeGLؑ?\gF<%oizƎgJy_*pP)hj#aD3w_v/U*p @ EȕNzSmnѠ,o>dDVC%N.b:-%V[<ȴ(;',uZϏݢVy^GDd!U^ڳDl@tph E@2lY*ٶ>+yEp$i++ʷ_DWz-gxgQ WHX1|853Ϋ GL>v,:eaYv:DU,Uzr`I}xt烵O-gd)5IH\Yq]7_9׫U:6/&PII-Vv/VbUFo;ɵɱsJr5pEd;sB.!K <&[=,4JOШB } TEnkGVL5_mLR9+h lCD޻\*Q&Wpz' HHc!b (ҫ_ /ؤ P0 ,tU,:*Ql|jli{bp [Տm|m:_(K6BŮldz2Z׭e.]'VqW/RQ3}z{S kz=@PM/W4ǪN$-$,K4v6q׻^vUVzZ/i\^.݋Ÿa̳TB{K5ײadBW{ 40j$ߌXL֤Ś]CŢO NW(Kn;wd,X(V-Ak<-Ae[ Ⱥm4f׭IUC]X`P$*ogبM.e X 8@j%K1X]/H/M_gWU`#*QEj_bf g}lm_M!{/*rfZ{L'-?KdFZ0@&Uf | 2 $)lkPΏ޺uT1 U6>HCR*L$}yPyO%-B-t\R\`z}_֫ʹ)w8d[V,/*H ]< CI+1E~{zyyXKa9"#&!h$(AH.>I& 'gU@ @-^/R0S.5k?9I_Z`ż[əUSqm]i̼HG&4Ab΅)eT$"*$XCN 8Be*1~>v-WV9}*oAYz2@9`953s{kw~},ѠvŐKdL_hZAG^(4UC{ۼ1M<.2" 1\:LR(yВrEMis@KZ #ӆ\"%N:Ɩ'KLD:\,Kbqm9"~E7#j}6m'j^f봀&\UWzaP8O"TxQ.#>ZɸHK0e**Tnuj L<\ݬ BKyLĸ6p_6#̡03xAA1]AOX4?) )0PXb$)P~@d1ZV/D-C F^([1ȩ<68KeUnz(xN6pZLM fh چз gjTӟJ"Ϋb$#lh!)Eu%V y"h ?|Z|9DWy1,5:1TO]D7,ηC4^M(g` M15,4G|>;#-EŻ^tq1kPa МFIr  ,LH7m:+bAM3_t{rt/QA` %LVEN-Uh3WRU" zw75וRǭ# >ZTiJ%w*ҤF7 ABDc M%dGk D/-,&aSlȹ+PTQ"IU8Z?7?&G߽` ^pEJrV *@Y" u<ٙ]g&O @(p&_dP$ !N+('w(2pv Ѩ_^V@ ]-te_oolQ;Ŗr.xWFP|XCBܕFowrO6*-b5JFGI73Tq|THzd oN*J`D`RBeDUE3+'#y-/qVJTh$/ŵd~2\{Kݻb"1opژd)MTD,CJx5 NQl*Z}k_ K8:Ȓ2}ԎݮG(9i>.x=t MV0Jj^..BZOrXg$s%+Fmʯ XHư*?\kL}I9ilWSЃC!0X[) ͒,[$<,M~<_X HMBj*%J=\r9%خ0e'B SS)g`W"6=̻}ֶРRZKI^02 2@ќ>n8Z5,|Ċz.K]C>oxUܰXԪvd8YD@,A' N= +40Rnf~??Cr)PaW0K-& #Z)~5u_4d?]DVj @ i0Gao<$d+y./:a˼Q yi*<k@[Vim {4'xz] !C5ֹ_TpL]q2ǘ;] qvoM}t-L|ɵ%SU[ U@ Ł hOjU!?^x-=Պ b]lts>Ձ*jǧq$te$hX2"9{I-O0YRt!YY確p`r[0~tU@Ib8B# C,ˎ{!Ҍ)ᮇ&n>a!ұ@q0Y$uQI ̂ŻIXD`I*Wk,942/p/R;ES:!d́Mb CB2!' iyW%H)T[ctHfosr&lK|g+E:Ru%kzkxxHWGr2םZHQ%8GVj)4 J+69A6Ew`LJa(DuN5;}=N. Bz^ pGkZ[$c3#EF=l  kj0{+3*"]S*tG.Pi?]/ YDQɸäM'GhmZbMOc˓۵ە$x$iwq8벓:>p cӨ#F8W.iԮU+%@{dZr-m$f6-wYǠȋ*8c}*Y0y vQM.7 e5g8@'Mӌ&s[_STi\~;}izxT[)#D9!Vt\P $h}kK'{MT#h8$~׎v7FVŒkC괪3Yd4Mv.~Ӵxw.}/234@ 6eLj.IP6`o8n@~$w|gsǗd39Ca:i:1I+][Էϗ{Gܢ1fO(w[/I3iT{ܿJ Ba`&$ T=Q6`h3rCS0@!?uSol,@29 aa]! nE^znH{3ߤ\j (JgpC̏M P$[A\-o9Y aOjw%j*aDˎ)EQOE oD1~.nWjfB",I)SGR/ETe:sÐÔф(ƦmʡDT+dŀLU 22'!%5N-]QǘMj-J?n3m󆶖회d F!=RvDVNJX7;2օ]9RwGG;-mreLELOf#(e5Bcb-;T X`4g +t0A((tP75)SMٿ܊v{v!5Jpc: B+,$2-c."$Q?b{"!_V_uGu4PqºT Pn1='6:Vϳ``N v";XƛUoWRV%^z,YbdҁvTq8^`"\ {EǤj*.|#"Ɋ]MU4Q!N{_b*Αw'g>aXeLoJmlDx+1eQPQ!(-{JzZu˱  SZ g/ ur 9Fe*a;bPj6¥咵bkWKh"?glj¼W)ѵָӂN~'6b#oJ1aApyFIh]jH'N  FtiKй?TH&ǃhr-ϚEk1VrOC%"*Tdҳ3tZ8Td9Os `L/aX ;1_vC,c4\av*jp+!.-cG.Q*k0#s w 7 *DQAƢke)e +6K!Sm9P{Ϸ - $Wfj_XH H%.KhPVdyJZՐlؑ6[}gن)=+'ghӉ:#ڸiZT.!ce#Uq9DY艶*>ӑ7 LJdp34`gHl2 iR"m`Z'2yrD#HZyk-#>Kl-au 'iLBS` Xgu)1BaԴ'T*S7z-K=YC窐ꈜ|.'tz^Pr IC.K%D%V{0|_'}|rBą"Jk<@U莒1.Lv Y~qKuVTn[߻n5?ߏQTȱyZb8*[r4E9 #/s$ @,qW,Gddz%ĆĬ:i6Pe8wbգ+)hF3/e,P ](J<uQ,ɩ. Rdfq&2'ߦn>,dFJX,g(1JE% ɇd ZI^m;G$y$ swڗuzt-&XuJ[Pgc/V͍DTƏv-^W16 [gQBBTݺ{,d(5KW"EjP:TB{f1ebQ$E]U򉅉Z@ӑ# &0C>³* 1P"* ,6r C_~"tO)ԟ!P/M@-J$y/h$d7UTo[nL;&+w'Ѧ97 ܅U,?Dă2TfX%LMɔq<P|$cx,*gW̌.I4BcpC <C Rs0<9;GE>.EI0Q I#{rߕRfWٵ@L:v0Ʌ, Qo-VVE]yjF#TvƨZR#-a,B*SOrOX {~{ sf)2ު7CTDXk  z6yQ8HIdnI@aiJ^KY[\|$ &yKޑ O@N? u5Mnjp!cA0160>VO'΁ps:Xx %cS&7R ۬uY)=)wUm'5 &P&A!Y#sd2`GH 2hnuQɚ#s%r4 $&1tFQ"/yj?U+$,ӽ+ k:tZHX ȩL%&R o>01&OG~!.cIĿI"RM:sHJI=9[el1[fJ4wݚ`4L䖢P%+DHA. `XYdLJŒʥͮDMB1^Ɇ٪BhHN NK>mRդ vdl_2ā!ݦA$:OwW5QTِ֜brd\if{v #3d[EGca xN '! m-#s%_井+Y2J" a"=M9hO+,*5P+\X;znE6VFWwb3!BF;~\rpx*թ(l P,`J2&1e^m%A)L48tT0y.ҹ^8a-#] Vv;]dx@FCM^9{C=7b`SRb,)"<[U屿9h,`` ^76}i8UwZV 9y̮SR ]тVX&V<J9 "Ҍ& Wd{MHH 0\i( O#`$gAy&$`P ].17 W8k]U6 /Iӆ2> TMg;m=ySe1vΎuB|_n9hQMlfF gAl.)S؈e!MjCL:KбFJ)H i"ڧ>]ȶMjf@p!J!ZP٠r ݣ6b^*Ǥ>T\;@'!x߷j K;A]g% P<#X"SvVpR6ЍdH7BGU*{d HIX BV);XI5'!` kQ$+T:Tٰ|eBE:ġ}$͍:94d:b a8y߇.pN30f=[q'S =h=Lg\]'&i5 oQg1=V;3)/P2>|Kcg6ECY+ʊWf-q&qRPH 9*wϧ✎MPew3v6FLƌ'#4/ce+˕Da >AZqt{RKVe9rx{XCSc-{5 -n86r9!bMtdOH@ 2XɉfL E%a fdpP 'oa#phz7Kg9iH݀ [W!0%/ aǷ~R(3aU`2G `җ v0θ U.7ۛBh3q}WuI !d%QaZxpC-iT}?a%HK#ħR@d#?7F*@3%#. cn h2&)#ں3;nP^O#P+5|m'Y0X0d;;b#3|hx3_y}DC>j0p(Q_logX* 2:Ƣ8YT*ld`9Z䃀ȧd?I@S+f %! an$ P 1u2QL,Ȋ2 VʾⶬqD@KᆘP9ve\X>CftJJGj*A3tlgH2-Rq@G&KYgtTZl2t!!}N CZNiҞI%K!QVxx,0L x. "NlQ7lDꉃUF0`ci) '#!aA1#p!4LXcV|es̿Y 'GޛވJ{T"B~#2 d '躆Ȉ^Զr:|ݏSE, Q|)NhGwҾss+Z嬾Yeݤ/{&YU ߲L2edK3Vwو8[PgKﳸ׮(4jv׻]Uu_݉^)H8EI4_2AGh2L2S_O|w[}WPbn޾1/rņc<~8hp`qhRR x<鑮-P;+ E98%z 0FP0Pal'odSL&2`Ɏ e3 SS"8TNTڊkE uZ-= k `W@V@A BOh׽f)4ME=ɢ3I3]V>ÅU$\WJKlc^խH ̈́JJ\oM?bcQR#1RRd,Qnx2*Uir=J5!35h8.b&;]?-h31^f|o !60! T(UKfRTsQG|8qڡK+e"@;W'-YR l!b '?Z ;C4ad쉒L@&Dg)̘H!a E&d Q Pnpvu:S™9J$MEEKZ: UFU~"C-AL˹Ԇވ"$"xRgEg^ .J 60c+Qnj{f0>`dhdB V6"Y}:L(w|7ap"/{ٶqLOwKińNs$ڌu?xB?@e^3=-%)n1097BM]`dHfTW͖ùsd&Qj$&ٓ>3{(+j!d2Vj x Ɍ -gS&yOcoGL悒1TzjnR*+ZPPAwbcm[vŶfCh?6+Qj, :.PLv$ 0\M5Q@hL] 32W >zE:W`kg2H pvCTy!y8lC UYc% suR ڇ<<ᐔ-:)UDwgK !&lR^cL Kd '`z?;>ib1!m5DatY~aKJ=<`qO𳖀R8.2qnIŰ:xίr*ܳ 1eFΒVK0?kO<9"q+zcnJ'x&KJyG;i [ ) z>K>H$LQCROPHx-ȡgY(p慵AEȧbӎQ jVTG%/!lhmתVljBѮrsU%/MUz]nt*UoIj8v˫VϓT}4(0T `ƹć*qAcjMĉ Djwkd]W:mEEd_v3/ 1"00) O+SdTJSPL3lEL)Q!JbXt͜)SjQKGD$U`oK<+TU{c6E!Q ++ b)0D FOXHo ;`cS| ԋ09"6}HW>꧑OsgUx] A9j2izQV@u]2@=#  Nx:6 40iT+HyĤW6;6~HE84 $g/}>R' 9dM (P*UFBuR0@XtKU 5srZ%Ul ʕXSC05 +tĄnDtoMP7P Hfsnz~./Sy4Kkޥ%F}mR͢lWˮS\`2f;4 \yqY!$d E,0;*)3ǘm"0s3P9vjCg3j(ISދy$5g΁IrV?[J]d0idFg 8JH? rBj  E ޯWXRo(Є8Ib479$ʭHa9ȩ3nJUPd#ܧS||{sD@;MAcLQtDF$&M$1[32iڅ#+i_V|R2|WJ ښ Vɧ1c2g͘21RU$x|ɘGʌS`w_$/9}D%^-K/E0b 4/O#-fn?,bDR%Bn_ea]1 w1ӯ6' 'QGaDq 9 a!ZzUb112C'hEWM4}gsoGA0ȌP@P_3(i^=lf1ΏV谜#ZpaRh B@oZ6aT7?d;4$p; |<V>f"{9ڍ۔Ȁ dTxĞaQ%3XP1NІ@D!XVF& DA+FJH`G|0" E)dA-#&p $Px3Y XBdZfJfjҵr9Q ?)T55i.:΀A'T:]?V\k4?ݪ4Q 4Lusz9ؤ#vd7xB3DBTVt&NFxhgM &1\sHNѦd IǨ#.0H)9LŤS*#e fcf*#}y9=yNcv2=TVwYEkoAvYT!rO.zKmqa xDxf}ĦTT!xj,Mvfr3Lﷻ3"<"ԞZ;Wbb,A?\tN rrJ*sf4C$+|"<~[j˥`!6HmHE $Q} H(Q„oԦXȜ\Wi[{ds~اy_OnD__Zq;-5#l CG8 @T]J SiRR5bEDݿKYJ zԄP-HځO3,h0]դ MϐZ/.}YNn)T),mDQOYGbxo0$!?pbT$^Vu~Jd_~DfK`g72KylŒICT1.%2PUZoh5R$̛M'r 2" S\ %T[:l͓'?nDK/@t]?^ I-\ܼ S(PtOъ$r@Pꠌݮ NET4&S_5讞8aMp)Qb&&g/_IѾYˣD1l#gl͑VF}Ad._cdlvl͙6UΧ4)cKwFƿ4 p]cPsL͇=K nm+ T@_ODa^.?Jtk_.jw1CCHX;B^C.*IUʆL-DaѴ9Tt*8!Y{/o!+,P"7xYiWТFlqb]ͭζjOmkmpk &t~GVQ}q3`ʙs,@`m`gTz3p^o,Gz.7CNqz(i LVhe9K71yE-ח%#qbԆb9RsL~vOf~XSŌ;ep~J?n/?-rI"Ì+m؎ DD ,^0XH| ITk€4 m0I`D &Aus6ugUPbaVg-"a,LQ]s0(vGL#>~M gNLV,w- e",Wydf $d%(a$Bt4$ru 1c՜ٞW2[Ub}ǽ޻b#@KrIC҆)W5 8ZCkpcJO2s&o+S6vgytE?JMxՆE"! $IdFYQ̚, SJ]qD# WbG=Z QH?}w<46q%s#P]EP6xT *-;~2`›$  PP]O:6j2 Lr"1RU u3r` H@kGRLCGy9Rϒ(-T~wI*in9&QvY9]FC\ \[zQyࠌTd <` I!0R#5%Jѭv4g]3(=KkR)_rU*5g5EVϢc /y;Gwh@IP, |{ .TdD @QiRo0bJQAfh)ig/D7M9d0K>gJY-ϛ*\ɴudf5*3k[ϫ@SAУQL"$=@ GR0ѨD2!cV_86ݘju2GDXj+L1[ֶ}Zt/O$jҙn@"&ɂ[!x (AP~mC4NYLѫ -5C,y/dZV'C@\E+#Zd02'Ʊ|9X),% n=@c]Dν7$aUrb˜ƄjݻtH>믯I1ǸfmZ GJdSDݰ“8WD 6NaUz 0bJ?aZ m˴o; 5%R˛^σf5aOcp`'c<I?B Dl??E`u%9wb1OaKmWk^BJLy("Hȟ j DvsVa6P-v qQ+jƹ VFruD@@܉1=k:"B4EKrF[3JX6<͛d}8eqĘ By$.K-9q,z m6bY%"֬'0J0BrMrj%UFSz^fkY`BN⁁( aq i(In5fHL"G\,= @p4@Q]׷f/>=Rt !< vjS4h5yc7$^@R 4{ 3iP~I'TB>|&xڥؖ!FebQj t`qp>TQF.Y1 @6` G2.cȒ%@c e ]D !Ɉ>D 'ų9HҠE  u,FT8-ZR:}¯(jKL{tM%ÿBA2dRWDE?,F$ǭuWQ{9Z2luLiTm3*2D*/t8ߪQ49 pC.WCs9jd^[k}r ΃[Xן(-$q㉔)E*]s96US"*6%^=-z3S蹕RVIGsI p w`3"PD:b˜;!br[wۙꇙp3-'n5L .o*Y-|pbX5r[klWJ:LeszQjC] 8$KdhTE Gȹ}b~ +C1% a|$~*+)F9 'HӦ"Jā̑wKpzG}۽orl[O_PIX\PRB,9hZ2 ]BД)7)f9f_+޻N IlNdQ:E\*ęitёɮ}e1pzݨcfNṺ߯z:|S{̳_|'han\NUse⍴CiClJA -*I3qY饧vi?o6۞Y,JHV >"APM!+ӀPƹR1.+i"ӚQ}ye^^(,fgr%bt@dH `H9 # /0% H!Vx} 3%6 +PH@큙JǨ@׈E,{"BMD̏7 [H *w'7-ܝL[٭AO#퓊=wqF]ScHLHT!*$3 [Duy =Ĕ $bk%RmmmpGl5W$\)&fq B@1pPC%!HĒP=dCX30DC+n1#ao@m"Kݮuh$fjNImQn#T..Ae׌4yD J*$6j].Ɠm'G h cmC2g4y,eR7~\$[%"t&LӤy}Z=bd@>O09AjM& #0s'p j)Q H)eKzBL#/=~ßu.Jj*FȢ!q`O{0D(g됰ZpO rVrf9ܯm[ kWrx_nhdi{Bdffp0V* L#b65Ɋw{d1Пa$PQǃ6ڠC* ;N=EG[ ^Z5UV`Gyb.à~fq5kVflZ3{jL.#B$fL3G0d"Faa0?4 M+3{ e# SS*"$j.Y8 CCЍYޘDŽBT8FJlVpIZ<*dޚeI" xȖ;J  !>~>х*j~`ZKd kMS 2+D:X4JY#4pJh0bE Pep;NŦI;8O-uiYo}JKl8$:zr\pG [ReDo|d'N;}CFGK"R l">fuҀ1&uIz# NYIs*GYM9cD9#ߕr_Kc654}P=wHlTr)#UʌCDS2)j,}Jő˜6G"Mdz#tjQnz.0&\Wǵ űc#ׄmPy!H9.jj{%7 !mdk TKHA5PHF= =! 4!8)o]I%AGx3*Z!oH8LDv5ixw7Z>G'N{XKȴFdw<,L{#UV.Jo3rb?aHLJjZ)DdH@1!w"{k"G5->˒i(d@3d1jvu.S]6f~g}Dċ_ P8xVٱNn`üH7E LlȚd]O둪"IS%|DDE *~"@f=m4!d s.$%S1sHSY̋S2E^u2)RsF%|TuGfd 9?¡c1d OMGrK I<fI%kI%t# `x#cTA`w#3sNu29sVCf"R4*">5H-UKj+"%$"?n\;Mg5`BsR0PD,L`wYeEAB`R.XWoCl\2E+TÁQbMr' i)XGq] tDa5n? H%O{&Ԁ7^0(7r^f=?Sm\.=ֆX4=#m40]NB B1FFLR$Cf1.2+10ƊCADTIU`LiiLZ{E;75W,*5'%iJaVáHTM6ZSgvo ! SW;}'4dm[Wa *Lmo8ĉҺ@)IGIR@ͶAL BծG,>|efzg[?K))kKe~̈BD "q_~}KMhN5RTͦW[9v7s38[R##t66,2`AF5?zʻ:E ̤PP!4x~\<`BH0)7  i쒀H뜏^MPJNRvw380!{LẦe(W*e\Ԏ  8|Ϟ&ޖ/'JNl~7lS KEd(FLPeZMPQ<Ł j6 0uGC&3zSsps(*=1q q^DuYZ4"آQ-Mѫ "| ,n1؄-rԡ->lQ9it1@³R'cB}ؘV0TWSݚ?ۇ{>w£(@Ρh%b%a&c`G.ߘY2+iL +gyܓ66f~י;QL .aWzNЮVS#2@o ʚ *!4y;PBAگǕ,*g"HlZc"dA@C\y-$8M,I^^ d4IRF(J;0b;&$zqc!LIBNT*TcюJY`0yVka^)⃮$޻}*#qiD+ +5wfD3ڭچ~ѥ$ʕ̫׽{ >I%wA, HϕHar@ Ix?Ht2GQf].YQPu>`+FPHlPiqMk0($v,(:h%]UhdDbt1*Od PDA40B) " u-o6 HO&HÛ6U8LYroMV6gj_-. HpGD mk#L`T v a`cc|9.(TEv1N"aF¯w{y,ݫ"!JB[+\j@"#DDUQM}Юd Vtu1s} {fH}OBߎum "+z0:rEX]bSY;RiӅ}ҭsXs86w-i =۩d>7jP"ByRpb(#i'g?&Oˢ,:K~<ԇ+ LwRdPgFI` @H )< q!!؁/( 1BDE=C[Kh9 ot2QBjo/<,۝y3bMW+7Lx&/"XEd4 OVxOdh AJG PHiI!pI (m?yN`n^v0i2 e{71f/!MжaFش2V t?z^]0'VDR؀׭8PZѩ+|ϙ̲_OfO/JCi%YNpJ.}&ؒ)9K<2MKr6L7Ӿ/ 9Qp_ʢ( Cd+oEQ#)"jo25Oթ"#pγhD)1_dd]J@ L#9\Ip3)*#gqȡB$uiLJi-Gan A|z\u4?tPvP/< [kiygX(+Т՝]6m<]H1Tfǡ/DZK4F,8$Ww}a$zAßB$ ,0)V Gk$ #*йD``IЀڮSvWhPD-FY Cda\Uk <׍{eY/71QDYd…:R6X !+qL+UG#qC"8h+.VVQ٢$7ӻ蒶|{6mōX9$W5ħ.gsyz寍߬)2rF۩B-"G,,kr֛[q-e nMʙ{iգy7#2]cKݻ56W3K,[;T2iv@GADj*+}"%80@I-Ŕ"'d"T0:J-9#0F$c (!2d$|&B|]  zǿd%V[<`DCj k0m ,oHHdZt$m#S0ಲ%%h~R  "F)I'ai$MQt^i*ð:-vN,lziEtU$eeMIWjnC%!Pga~'a3夞1:䢨O A vPa(z&|0@.A@e.aU B@1wBw{0 %0al}O9.)r2&xk?O&%E9V'Pki}'X K\.Aw6 }#0!I^})%`n#Tˤ?wU`!zhsPuR "eKhЗd D,^ !&փ^H٬^ ZF ֛D:* -Xz Q ҡ{, =Q%t >pKϓ#\ȗ%{j?Ȳ~%VY@)c d2>[p:B^=RK@mpY%-a^iCnQf.KjSfhPk#4@ wډ;b1 aބ6amJ2m#6}hpwcrVhL{"Ft\<(tM6O>8eDAR!v7]X!;K'#@Ace#9#HEd%b c)}(:̊*)me3㬄\!*NjTU1c"PAAP9U0wI}$q$DT lYޠ 0ȋkpЈ@=~4 o]kCfdJi@ak#4sn XॳV Z+(GBUD~hQi_dVd*AY!kDfuRk9B֧)9BR¡/KE4? 4#[7NȤd2raD[bW=a &NzBaN `?VZxeDR$)-cԆg~8i\D+G萎31aܐa&=B]"Тfwy(bF Eqzq#a]U 0V6!^ v"@4=B9Ϗ ~* hu l>Z62*麪Vq'N𧥫d`%^H?{l=d \kGq@ .4s 4셱'_ BIJ+Id&hr~}?@gP=0  cw"he&H2%)Ib4 ^UDMIFZtPrnO} fA%F  AԌ4KPTXPisZ47tQ3{p(tX6q  FXtPpsTTaE)s{x!b6!*YT~reik q  ew}:=ElmNg\LYn;z$I.`nɀ|tH<CkM=#J Mu Qt *Ee[qtU;"qR;ʅ l{ e5 'VE=Cַev3HjHI)PQ0z9< l?%_z 1" 8Hfê*6v.<0)a 8+`C~iI!g`HXfW[Ǒ(GoEF&HP<0pӭ%/eD3 bQX 5d\@ձgLf'%k *d<Am& u$jHn8P̉.)EYY452kRw45,9 !?3X8aŋ$d\&m rU`D*4ȨH9䅕'hmXUqvk2Qn SO*ru![rʵLnCFPe1.nk8ٲ- ͽ#> @( V A h>"&P\$SӦ7U9l& ^;[ba{Pî0P0pb`;aeKN nE=*Rm*R*P؆x7tXv;3)<& J5HxvhhNHdd/^{ 09[& KZ5k6?4!1|/ݵ+ǝi6OέrfT)y?Ǔidu(V5.t2~UXVjwkk5bqk@Ѓzi՚HJ6cnvIJo2@޾ %(ǵcl(2unQL=O$T|:ODŽf\ fXՉaTF@eOhӼ-4+ 5-yRZh x"/, +rݲAvH]d:c 9;|`F%hx9 x {BVo R{N(^\i(>tS׳ X HA`@#gnrO: UU..Eё{DWHF!+3:A6LUҽJeYwjȾ 9m7QEoݱTq1\. 4IFqWC%XHNjk|Y'Lg@B%";<|Hxp"R9>ń Mt]rN|ZE,+3o$P'#ҙQ>)"id׃$G@[Cr7\,t^=--, qYu8:pH+23=[[DBa%9&IjbDJt5R5SMjJ])qڔ@ĦȆ| >mÞ4ted.ՙ.;>%˻:g@NA 1)yE-Y-Ahm\')ʍUضCљgNL^ AҘ*A>K*E~`)ÄI33X.e \/`R"md>Z ) ZX/&tS2u>`e Mk/zksŕr}Ͳ2 <0nđ ĥwOЦ 3c JdǂH`U+LG"<{~=b.A/YlmxƜGFX@ρ!V*U X**+ʭQ(C1@o  s0!T^`-nreut!hԿT j` ȸEN2%q\QJ;TgG<22tWע܉*!*o\nMāytSUJ8a<ȴ"=?Q(֣UA8 4=QOJh4%+xQ-1,Xm(ԡS B wxQlyLUBR^bARiŖ* Lj.nأȬX.qy-PdlYV,E4ێڠg}d(<^ޛpMî&4A\{8oxE)Pk*֓@E}Wn#Y/.ܩI fg13Q>CwJp`4hCFQ,*> ˩{wLJpY[ 0oC@-+HcBM+G$R(DPTS$xɎ::$d~$;EW[M"/{N<7 웿^PvYa_=n<`&@B N i"g'F|>5A+}:uBMyd+WަW @ARbYHA.y38)B+ǃzdIQ/.AO8E. 8O#nKJ141EN(ѝ:"28d_;c 3K^0Zgv 0A6UF|U]8CQs?&j Tc8 q.PH )ĝ(gxaBX(|?BzcA![g/% 2sWykM9}NT`zYޑDxlwh?@!Pb :ǛS BtR֐7@zAoGF6t*JjKdEmY D-{j?`i%RL7軰cdhGYq:k^*YzPR&yrK܊5)TrDV=M~20&O2t>" d+YJJȧWH#bpDb~CM}u[Hwfi\S3UT&=\XFA@E-ddE h78eEr{&Ej?~0u3=qUwTFބBr4 "`(Y7[ݯU͊DNF Qi"u:L "X`J"u#JQBd2ZsMg:="h (_ǤL4k$g%l>UDxBkO QB\Aka#tWTpą®S9V (EhF:'ĉ@Qk979VA,VDx(sIa6<6LhNh>-,(QvX\gg>Jp YAA[k̍}(b&H&BJJ*DNwB Q3It ީbug$t#V|u#zD{x;2)M hXhnjf& (iaF$ 1D*#0a՜d( :P@ @mV@,_DefQЩDxnNڸ2N~eӠ o ܮ,fTmv˔T,|^DRG@Zy?n3+نҔ.):d&{T ʄFAB! j̔y %4d'd7&]k,8{$"DLk,Q-`Mø÷P5=M$(ʓA80rA./aR+T'e@ߎFItn 0X4عqR*ѧAlV%wp):VC6)d-*Rc)}?2$ 8l)FrD.DXb!@ HKjX2 nϭgGv!ѵ"40`(!~齹VvMy*eF) ^! p9mY\K U{5㿦vNZ: 532P ^j< >M\O,"xf}"1#N5d3o733Û=", ugQ+ hsw3A8+z܁'ˌ~z52dƭTs6rrVƨG$2L6#Lyn!լEqi%YHTpܷ>_& t˃r6^Wք!+$߱ڵt%9ۦG<өf oP#\6uPc.)PB2" g:kB^lr(ˮȫ{֚r]7Q% Jp1TIj.1ӨmA&=ƾhg#kd*Ti3SД q9+#3d&4,pbdƀW@[y25,=N yg< 8q̡ta=0D"tE!Cy+*;JV;AƣllfK;r"G}U(cj)u7)eQ'gsngQH{ee"Vk9HۥKM! .N,jU LZT"SUTg*y76(9 ;fo'8V&"6e~J 8Բ'% ?,Y ؚxU_'ңE'c heE̐TeS>7/e myf ,9Gp1n]BǢFuSs>P]g_dրoHc2[<<:U%]l tF'& .AAȗm5DX戹P iϣ (DTB>eع]RVA5\Kv: PvPmނW_in] W2η ƤB, _V\YaAh `%&Pd&PCrɟB-3-D,~"tO5o= Ѕ U3 (`ٱ$$&CEi:\IP.3:OAwO#OElccHe:!{l绺ꀑR8[ܸ*VuaA\V#uj ^;,d܀?YsJ? se~ RoY}:l:Z1`!2pܜ#jVm?X>@ h{DyO'M$!h܌ͨdv  ƒmһ0J*iL6esFv#\3!`RDE־k Ne"[S8 )>*YCoVp0H+<ΐi@f'39 -y>fGvJx}3e ^;ю ۜP)ZL~d ,PE29 xQdG)#nX\]Yu*ldV\\ 9{1 {u$l y\r(LxfV )Bn 0&D,\ H؂fe^vь}av>^uE4Yh؝(Ug+lEA=%Bkn>I4Q|% *g;LDŽqxɴ6>'vjb'>ffe~j>}"yt̒Ɔq @Qܢr8Hp/$daXUCJUz[* թxzUB:=ZL2NVDUn0ۏsvDLz'*&gS$ e^ozI$ܔhȩT}.E'0ؿ-p0dZ,<,/==ay̤lم|[&֑DD0vv4nXI t¨S̞s'vQ:򑔖v(T`faB]AJ͙ݛkq Y2UE'fӬL^i%Z* "%/_/I4<j1.+$Y85c7 x !7\+ P``m#zk{#tOjgFڿ/蚜XdkX!'7@" \{kK 5S|@gKcG Czk8(HCKa B= (^3WJ<ˇgZdfBZK8۟`Chi̙/Ime})XFWlƏ֤Dkf;` 0vC*` ӏG<@SRDC"Y@&?f~(l̝ cBxr53`5 .&;?=s)HH?i,.3!CҢ)vڊVlB wWeWag x HL=j7| $4+PX04Q=eɆ*Q "tz GWWgшMdwOYi`3Ë1 %Me0mXl43L1cɊx4^B.]\.A&`%]bg(vMjQTơ1p@HdT!Zk 090f ioH ҄)޾N-}S}X0}ExY>g=LkE0D!!SdqT \ݠq]1`oF.iM$. <&~Q+,}m`YIbJ`.eąE?FVNFafqAxdmJ,~xV.cg9;!)x!VEmL ycQǮ9BCdP>HsFL7:0J<YN<,mI*9Z81v-ZEYrRN"t?+5#rVeBT&q,T'O#ey7dʀ1[k R8L& ol -0 H.9q`k񩋁 `s0y]^:CwA$QR5H AJړiNI t5@d&¯~>&;4V$WBjTJ0]gu"wclli-1 5u>V9\˳#iy%%;χMa! 1&Uu!XP uQ0kfW#uff" @~:<&!MGб18EaV]o \` %0Ԭ=Js}QH.U zðn`bI,8CCoφG?9~JZ.;do1S r: =%U lh$H։,l+DCB$I3msj( M"03r%jqŭFa:g^ -k*f@@@ #!y 6 4T8ЃA 0.:m@')uNN(*E.StĈZX+qQ&+.S<|[UG{g!c3C^_c,fYLJOPԞ8Œ2KV m4`A޻I=ΙYܦC%t 6~ 3ݹLt:X< XL8?~6z)M. 5(8 ~Ү݆LBP~R!;DL .8=6u&Аܮy"#2{'7_]]"Gud]a7!;}=ueGkl "iXuM a,d,G4Be i@,z_c9%qqSIZ-9L2.|8 ( K zN#uet#)Gk8Hh^gӬig4%̺O$ %@&!h5mO 猒ߑkAx.xLz8d6OD^[\2!yWSQOO)ME{DzfxqTEԍ1fn/P'q1pahЬX I]m+yEdOSB:"+| uo H0 `{:cha Nlb\6%k7Q@~aChHT::KM@XC$8+'%P.j<4M9,LyIG ZM0uG'/矟-Fr3ZW2&Ū畋L\ȁ DFL&+M0OL\WAf{9c3M-: Bq a*<øqbǜP}BQeQ8Yp\> 7؊SZ@C|YFԾNr.zVarȁBx;F"/JFU`zhd:W3r?»(mmgGxH҉4 lڅ\dHb=z_D@%ʒWFH 8d@ +{d9D{y!@W&Wh`ZoCIʃRMQ:$@ڽs[aEyoŎP40V5fl@AqOZ(@SbAARZg( h2!#pzpYFا **¢&3ٻ;uEg5PHF̅w~oB\Y ]@`/dnd\[ir5!$& }kꆭg4a?G~1zuud8Hq yA ?*%tKo\db5ui]+\qFUx`S釂N)+h X c$& )X Eޤ* d2善tY Huq! >:*k JA ,qбExfT.4G3q Żw XPHrp0Z!mӝ_CAq6Sz(o]e,HTL# Œar# rYOpFz^)f1:'{ \bl6xH<0 edsXX@EK,=N)McL$M 4&$ s" # ]9 uJ8)6#FsY0GXP]P:,&":gjOǣubȌ4.Wp4)Y=e܍??{in~TmXtΎ6+3w۫C) 5Ѡ6{unPdC־l[%CXj' Ŭ3ަL4'Rr/6E@)Z,n.iS҉AU(F|8=mmvnUDpP 2.KnBPd + 8ScoFر(d61X =c[GD' R dȢd1i`AM<"Nw]0i#,p b ֮&I ˟'48m). Xvl]p >PEaUeJa% @ϝ` )_5O+| wϬi:fM/9ȞVɔ"qPEFnd8p^21*6r fk+àu`#<<T342Kʋ4 9'HAFFtu$j)X` ł[< QvɬtlډjIi;2 ⡵@Mí3&[%F2%|̢ IC&|wװ>ʉ< ~Xj & Bş :aQ 쑇8d.RJ<=F!_,0p%,T dkID Q)̫:7=^v^`],aEeVmE o-ݜϭ}UJ冔8|X`WXmeR (uGMӻ4s?= teQ.:9dIH aR l v8㣱3$)dqD>Oa0DͽT=5'kِ !4f2{Z)msJ@ ݾ.M Z?;#a2MۯyEH:}HpxT%{WK5 C2P; )ڑ(vy[Lb`Qo-,0~adDM:8`E >N9jY>*=jmORukFAX`,PІYB$--$k[|^Ȕ8S]{? ;GA-@0MC’/]b͚HM5>kiy2EΨ|ѕcdH3YK)r9c0M$ɂ/E(l'C@.1=4b{"< |ĝc7Qv*P.A\6Z} p"#{0lDPCiNM!%OUcߋU*({qT_ ̇KNSaY5C<4)>kA#N-݃iRgٖ*|GД<ܡȢ4Z 6S=%]`J,@$iP(3"&6# h+WwX\دK(-0@f:6O)w韷kE7*-Ol-P#>gJ˚ g&,〃ٳqw-Ā}'Sdӆ9X 1<" a,0QH nAO*8r# Y2eTVsK[L.8N)zSܑ.*[jYDQVajZ0ޏ;zqTBϕ. ]ǒNj@ @Ȉ9fSJR Uȟ ʋTғ&~ӿ&@R(T ]ةYRa5*9+v[tDP2-_O@`Ǻ}GWD;9W*Ɍ9=!= M[d3XSO+r2 +=&`^$ppB&:BH A;ҕ%:XAJ#CŤY_ԗ hk5,G镵t=_%%VCX^"F1#:Z+Y;00߿8gnW EW"F] GcJ}`"6j$Nք@'($E`LmzoUb@d19׈Qqw*/z[!!M(%YjA!lo E8:Z YbX]C0VH;gpѺZhk< )i8,s},>4HЭPI;e)! D=P8,6O}?6d@;X ,P2B;($H #`0Q@ ,` (TNIN^Q=V.lI^pSXB;# qD OJ }N{/f#F)~+-ITڈ);/XO,f`A/ϸ|q88-}`2 ;YjB%\s}w_K֦1%]O&6E*Li20!q#`0ōāMN| !]=< TxdB4IJ @"fN0y'NOs !~"Z((p)ʴv]vnMh%RE'FHdj3`d49V/CrCDk =J b=( (UTey=ү:ڱMQwBŌ}?P q(AD9-<<u/DԌ:lt"`)s+\ `0m) l&8uCg/鮸dKlikN?dS ̤h5k d1POPsY륉(cǀB@lOڔ4ԨeѶbw4.=g6-a z IwZ܉_Eؔ癙'>q (!(Z9 >"\PSDX;sD0DnYNv'ӊV@.Ą.|L6U@ɫ )H,Pm* d53Yj,J69/q/BdI,^qrKB4*{fJf8E,hS7."e]'Kts3 diHK rG=%>$VEGBg)K.'2~BQ(Dա Silv\Iu.O[%;c'pPnI ڨ!~ ,ƀ4?N+TEPㄠx$,'&ŧcQ" "Fa>Qd͕29ɫ CŦGf8g5&lSqe.9Vk Ʊ`@sث/c)KҖ\N{40q괐R[#,%hH?` 9dIi:e[|0t qpH㇭(üJ ?8P 0*J.53}֝(bO53# h̖_pu.u(UJi!ͱX T8QݙFHA4eWFLQvVeWh5nO$`U-&doڈ#6m6fDbt4k.\sT]f_ؒQ̦wZqs=Jh"ت U!& be2j9> 4]'&DBqdd!(H$@\BD % !D$ofJ^STTɓ'Ԣɐ,9A$,dܰ2zdPEYC09b=GNa#g -7HR8c]$x8(XECh%$ JˍW~e)]שRcf08y@ ,򁼄` 68&nNpK&|oK NF@ (쮒DJ*ۥe5e&duj$ނ. APHdHD!4r6Ai1 Ԅpf .D_Al^XR2r;^6dِ_LЉ8U"y41)La?ً0ڀ2Hdk6Mv{]8^{[hHgLδ C~Zl"_Bҗ5&)uDT> hIsX*DU"F:rz"3Ocqu,C.!S?*fBt@6bmSz!Tj ڒ{"4 H2kBL0Y;$o'7mNPI(H#*LHƭw"j$('HdހVB[q 9#=#gƉ- bS%li}݇SI"iLe &;D0;NoΟ&-羵6v͕:w4e[&ѱd#Q@$e.-3jf ydjlɪ`7<c%e/,x)JQbL*’3"0RU &e^%9 @~3aCWt ?ĊJ8\JM6E.zQª}`z/N \L@@I} OK- JSH!@@,TUtveSYɈ1U(N#M"/od@XX p:B B kqHӄ(PoQ;Ol@KrlB9e)CA:"0uEu(6lg6e 0u70(!p>c8HfY\E& B,p&%^*ŪVDD[%r]KMe6}_/fj* !p8*݉ś5Su[gDY&r6@=c#0г+4li/~=T@PHhHdP7iyʪ>tM /F8^T(bH$hVpBL#Jݧ#f,dvܓgrUqg`TZok RXBHD@XOL$B/3A/.aYة V\oiOo@1P=)N(2e]\igǨpp͞Mږջ) )^~|a1D'/Z=^,*s%S5QRy5CZ? Jd9U1:{nBmQ$TmsML05,V'G]2G"In.Kt&H.5۸D'4>3w8*6ÿQWtUA!qjFJA Dt^ng ʏ̼{i/l0R2XͲ`|[G+ẗ`UbH VE9Ms{C=ͰWsW#}[um[>ݗc[>ƶ#,v Y[bٳZ fL;s4`=*n3jOG OLJ#Jִu}!^U PmVѓa ^B>$ }(̦0B`=<Et<8HZN\ǪZP%D-]"];#S]qMm{yk26Bp5 FGnÙ+¬i:m_zVFwC"AI'Ef>d #Y<`OEj _cP,|njhGYdru * h\U+_hnD 4JDl>A "Wlos9zm]j` #X@&C%KL +BTf߮yFg )n}w!/zכ,M*9o]@ JA*`4"#E,tJ!9& Ct*P ۞2\RVk^aЉHLE6G.en BH`k~ |{Vy 5Ԍ6XyH"q ^qA"(d?wa 乎xWb@' sKš&J"d!V@U=#F W瘯4*!9%Mh HKqa0GP8T馌SlDX \yNCHq*S+~t5KIWsW̄2>11dc%2RnF6V2"dllu0 ŗ5Zo񉲼#m5Y%aѴ ^mDs1tBYbX/MRO+$)ͬ7[ͺcvj%V9L.[oU\@Z JR+Ov O.tJt ?Kv$v @Q0I`*!7 [l6UsB,]h+oʡsMsbA!;rd\.#E\d07rLj_oTՑX[d`HI+h)JK.U/s^yY,~-fQB \$#t:9gw -xznylЄQ, +C\ ̈ &JZǜL@`]L{;#F]uvgu;I $$2 Ej?+JwDfm2qD >U<`ZGzoJy3_ dkJj)e!|21* U?<lYv'ʌKY q@3!UUD % ʡ;EFKۇ"1S;QWl0Lp M ٴc^_B"}<*v&[Ǡom" B;a3YtCxݎWkbbz'>L&>MɲpM 1dP ?Jt w&YՓg܍9%9D⠚{U;yqB~6OrzPe=%,4 Ȕum6A@2sD On$WE۝Ǡ,mq8Ā^w `È"Q" <;QA"%-^*J)eUoULC"I-l8m 幛|s!j+Avn ǚ} ,|l#0A"8@ P+,/tStEg7%rݝPJ{{ @<,֑8L`Έ gNxר(;~S[a"Q)5 j9`]4U?aQI0E"QR){ PDf1%I.*(˾dsg3rQoswu=d1! '-<9l'3d ()w= ;KnoSnxIi"#$86֥+iBGSUꔎ{w'dF ,"\|v EiAsyC*s !dY1PZ+(qF4WVDAh@C qeo0ۉmK) ~*~ 9J878p"Hr\flR A Y[GQ(>)VRH|\xϚߕ5mGmz~C E ų! G0"@h@G`  XQBG,d0 qTD3c'hjAa%AQh{Xr@d'#{lb5=% ol-".$k|%+SS*x!a}k~0rKQpRlzD~)aFK@ ʠM׸iF8Ia+j}?lt,c֫v5HQ1]*2\ќ@c8m1vZ<8 'kYO|C?@Lix8GX?38ZDX~e&jAS@Dr@L9є @ ΈOIJը70/q\%t7x'5 : aVq+0#^4sW*Pڨfd^ JZy3*m0 lceыi(¦S PeFJ%L3!ғ3) Eh-Uω(.9-4& @胒s=@( Eh3~(xd@UJ!*gpFc*\"Rƿq` ;-_d(3ӝbutdl=_luy䲣E:+\lD \pC\̉_C!Ƞ8Wd"3XP5+/1 }m M/4pB5gy24 &hP0›" SAz.\prW|ώz̯9e-) J X:X,pk )O.:.d\܀fa9 GE@>?_oӡ¥IB\&$"ss Ok4UI <YR{!2I¥ c**|>߿ACFz݌džь".-xw{̮,T3Pń,# M`{~۰ĭ+cq7pdDu(JfyɱYZW]n&{;'չ8O[ dEBꭑ3lұ,IV3ǀ@dxLX*u p@4Z!9X#sZsJ aћk)NyNPww~[5@`$R0YL,(O-$`ZXb `ksEЀp A8*PN.DX&$ud[R4]0"}Lhip S7I2}bYo !b†WOGtRcj[W3R،⣊YE25a,>M,*={7ۥnGٯ{#n1=g{-uQ"˕gL&qpC@JWH ` 6xC ]h:4*KSXiܹG=nG*it9 BS:Q(t ,!{͊(E:`IJqX~I$ڝCd%UkI0c0bd[a U..Uk-Bka䠌B&#fGzw r}1t+FO7RMf( Jpce1H2 kZtÄ6J$!Ʉ I+gWѦLg͚Iʵ_8xZ "fYXUi@^CEĨ< ip&R;/2UJѤ 393dvsgw`Z];bh8ij%95mlbҠHeKQ;* 0.D`&),mΔ+zT26*Z}ñdqXP$ORĂŊ5v PrO\d7Rf0A#=#rF*rp#RTRuHaֽ>=k.]WNu[ek'j=:491'? %.3m*`.➋H۱NZֱ@³ :Vt(0 (*A#S"zġ{+!eEU9[9/~ M9=/~\I{۳PP^se9,iCiv0=5' ǠK{~ Z)jL>ܸiw^4ל]S:z] 5a?t/=ie|,$ ^gJX`( D^cK]`S90*70C* z$sbJ-,ew*9xsGT1D9K+n ߱fͩOmM4jIGL$$'?~mĉ͕^V n~U5;75 CC.me|4 @ f@Rd  "[DtRxe e+Ԃ0ĸ] оo&'b@O QGȁ/&W?psRw׼ib>]ݺͫ=pCmXسgx,*6mxrXu,;cB|_vMu 1}  .Ju[Y+d bS]%`@ oݓ $ 3PThGHz%i]RW,:zMc˻]_1}/s]q_١ȥ.!n/ɮY?{9 5 SSD`b[V9Ƥ5CFI-lm;?HLRʰ"-_E33ְo9AUQ@4eQgԶ1vzh^3x,#:@z0`zZPNoƹ4(A".zX̬C+D^,AfwJE8  *p:@&3E1Z&`A Ip8d Lq9# *+PMpХIq/ /ZmW4%╠`  _jSpѤb³@x ! i.*(BaM)XEb7nIQZX!V߂LuLTQhS-- Py&@rA 13 :Cܴ@ /1\I"6IR$iP,mLrf|}`!Sz ;9B&q R/*oEj_vRPHAf.) tdYc p=#K1.g_0⍫'h 4&_|l4jU`#fJy$C굍tp&DW3wبzƫe esT)˨ 02$x^+ fkn:Jd4/NHLUK <"Cat!4TXD$],ķ-7De10:)Ѽn7ՔVVDVJaMQT_cL캕m{)IԻI􏬈P j T\1/)%h|r C 67wy55(~[,8nZd(uY~Jd^W ,8!? _,܅𰰎Sd ɦݢDKDjŽ:(zBܑ)C9Uc3)W0E#\#{zIQ l6LA " I)9B\0.7qxyH!Ed @Vhoo V nD@Y "qe$pKes3CTbJ#D9܋~2T AF~h W4ɭ洍1e!` Q6eCը/d+X|Av&tB`dZ}FGsZ$ PD( $^3!dp?W 2?1$mg[0tgRV @,?!$"#lMي;!1w9苆##tvvC ! "*Dx&3@ AC6|X: j^#q0M$$w`B̰Rk(& x"'#N>ZgClhx6=fl"2ޓll/$tGW{QQBJe>BLu:%4d#,|eTh ]>#I˹!p3nbTuS;rWJpCnIiP#ciKek"C(1Ji!ʤMA2adZXs 4#k-A. -yo0IXPD%ϑ@7}__̌:)HW"߼zѿ諹FNH8C" *"b,fA#H% NIXDp.y2+ Qq,͒y%̡vry Țjdu3M5z2G^]Qis)gC!(7(`{bHx=RTmVjԮg٠k7<1eao%^F_UE| -&9'Is5k_R;`aTXYhqwGEqg+1G$@`\4^^|qJvu*d1?Ya4!{o<1 qeυ/p!FӕPXQk%"O8XgEps`p VHI%Bah*Rda\=;y5 Ȉ0ChT'/ {Ŝ~0b )k0iH 0KӰ2}NNpT J*Ĭ)n:n؟(yՇذk-pmhŔi`cT`<$G ZF2@R+qdAc%lA+Q/y]甡R'Y8V SbCPࣜG <ivڷ#@̊).Wg[Avԥe`Eu1d :5ޙeR#(BD WJ.jh|?F ۍa fB[Ba#" 7iYՀPTLIђ}<::&וL@ dx…GUbd8<19mgǘl-= PCD,&BG@C4 1phKFA5#̤π[Ƶr%; ]thUMI̫|h\( Y2~"L 4+hB QzEHOM0MD_am`Y"-94PhJHEʚ=F Tk`B;%MMd XNLÜ%פ,2 CIN.nR2Ouɭv_ms77{Yd!Y=j=X o[T r_(%tWj|$Fde)iRѭAW9,TmS4iO$4G0Y@G2!V3sj4%ds ]f>͏j[7YsN_N]Ʒpb208+(G53j1;[U iX%{j|qeFP!@O\Pt2G7Ʀ"Ɋ8\JeP&`A0#F*D̶pOOz3RTLmK&k+_uw( е$Kwg1$ˆeIe]Pg`i9 T1~d.d1^aoڭ_Y7 0JR!`(ڄRB :t#מmif+r5k8j765x,Yyg*4U NqZf[ŏۏ9 iuq |3_Vg̀M7p@J/r0̀ y!SbZ1bxN&„U ƯUҸk/MDK6B;EO@upG΅")q N Nij`t293qC"\(ro'$՘4YxQ!Q2:B.Stvvdd?#"Y=3:}sn8_DZӼ6$?`+:sOqY@+ D0Ѿr u.g[x8L~4 Rg=7IUnBVq +C\W\:l䯶QB>֭Ui)?d>^c-dR'ݜZg5)RٺܨwSRaAp;"`x/R2t ԟ!_`%x|:v%ňz 1dε-GƦg^~e 2"'KC! &[b֍TX=4pBG`ܢJX7 ,+<uY86dA(aYYp0{^C-O8c0),,q]mO"(Dm['(DXYv1iI =BSx.e- IZ2э9 #|*FPA أC" P#bd:#QYqbG{l8F@"rR'fvuZ B <*N)$axfѭ4Mb.V*Y66#dz7z@F (nŶ +|apG0R x4H,1.HdBɛBEPl?31(Gbh#;Y"ajCdHأ B(k.<"Pqia0sȷp A0ǫZ$::P[8,R)f^Vݑ^;*:īDc#8}T/j޶G1`=p[q{&@-)QGdR [ 0\D-먌٣FK+sdSSE0Hljշ8լLSCـAv:k^/3޶^Ԧo{Qs+W/e-LC)"X[!NJu?_ДTErY^`fUNE͓TUO(@mV mP@Q0/[%f+"G˵6)V< \`j5!d; e-͒pÕHfsTH ,0QV%̙^㷎cenMP@*50Qs#&\9_rpXp[0)$7Xwswp+zׅ0H43]jȇ _jB9N8 J 26@r-k <2 Y`n4/+#"tCp1/u~VG(=?j:?p`5N'z:n1+dc>TD+)w%<9謭b!,j?hMvkU`eQD* 1EYŐ8L2t,d.;Zs +2k.`+R @eǬ 8@ WdDb L,ai6(A^P1L/ISޖ 1@'WŁ0x]e!qrտok*svz@"8=6SdU*' UB~֩3R >*c…RlCH)LX`%Ԉy$7nie!{4"Kdꀁ;&I0]&*Kяe ÷="/[%2K ԭE*Q`T (ӤU.bb>P w C:`Go| sGB9AFNST~_#ǐGdG [y[ 4k>$]7Eф4x 9LC"Rw.+\)TU3bF%jak7,3(mkwyᬚV—OğٻVHw6ڴ|lo)S+Idz&_y2k. eyS*9n.Ryo>Dmr6 (rp"_Okd:q4k.=j, 1kO @l?\K '풊k)+ܔ SM/(>L d:^Tljd9g3HJs Q4ثG[N0tBk p/7&uidnlZw i\sO߳ݳ%]N*HNaDZA;n3R/~`D]XIBj^r 5L;+cVz @A2 AuL ـuUGl\U3;ځrJ?U<ݢo MN`q&e0KQr cL"d!2\{P4{<" du<ۍmȁ/:ƶo#0d.0P9Ńb^S\q׼z.=ˡ dItEHH+(n1akDtHFv$0=4~!+&rnAO \"(8s8f`go*X2,  -l1 /cuo/P (lv w&fJ)8L㤅dJ{6;,jB=Y+ ^˻Qr3‹ vrW%=UCZ[ $6 cp&۴klxˆ WVHѵ䝗DʀS*<kH XYmK *#4Zad`8e7P3qopTm,z@&̅PNـaեf 9/p!*lLTs=iub ]O6[TzAioF0Qt᭫ޙc0QdY-eD&LQ.A[^lbH4vj_w-Ig M) ,وDZ5m]IN_lLr {|6 1/d7FIqZ~[2&<'#jbd\lQR ޙk'.BJӑA{F9^f|?"W^dG^efG IǔMtwi=ސlט xq;px~y>7+QWC"i#1cVMS?50I(Dzr-eEqcN ** geQ[naMխn!^Ȭ8L{CETqRyʔwr7.W2,^UۄDxre D]:I}4v &2EY^2ǩcլo#D< P Cq#i" @.AܾWճa7RCoDb3WŐܴk導4,(Fe9JY1 $z!oނA~9m{AMD@ځ5gG(XV oyoj׵{;Y#%\ H(!J.YǗC.r+|Ӿa ua]Eϖ^j'X0 -BR1sRAkZDB0P$l'zD=/Rn*Pv1tYӖ>LFj\ܚ!zMd^VL"8![ a @Bra"9 A B8F[hG#ED4[܅a%`-CmL; V{L*"8dD :wl_NݮFZ֕ޘ4@ͤ,!j~p=MЂ+Xp:<U28x\#1:15%è)CWH*7ɧ?L@,0ŨM.T{HAmT샖:hd#+Y p99<8 ԅkL0HڎvjqArMi&|:5`8~h`sia4Q)U*SHl?t z`ެd@p? 4LrmbTϥ(KF̗M._੢b9='eǷ}j+ww 0bv-&F EP ģIX3q'5#l-7M$YTcg_2 0((@)CF)NSyHYົb i|ϠEZr0:mpfzw&e 9rbG~`'-U{,"HAc({1䎻d7KQ 8+L8 omLm& 0t?E̝N  Sԩc6Lr* M+?OН/7w9)G*nwM:c. #0V'U.X#uo=_M~d.;|>bZ0I my>4X@ G}hQ4 J}d芺/F2gm}F_Qg05(;ttlFa .<3󱫬v$RsgdK=w3;\4zVX$,Q8@OjW>|l s^ -38T*`VL-CiZJP% %3jSZ)IŁD@) ,Ksw JLU!U$D ` =|P畗.k"l+cޕqp nJ(l!jA6!|&§VdRΏS86b׊\D\ddaE5;; sGЇm73vJQYU)zb*WRǰ kE=pR hP N %Dfkx]8 @QORMDJ,,ё1g{BP焈U`)J< 4s0ag\,mݔJF gˈ8,)G>Ʋ%U;][IQ|כNļ2ֽۀ-,Xύ" LZo.<&=ܧt^V>s_7Ll-y(Hj(!Rk4&Wt%PjQ :ې%Gؚ(=w)z]-32F=g*]ҕDN+Ց$dy G[iB3a[0VKOs@ĘPA @\\p|#3SEdDoG40&B@T2u~v =1U9 Ql ^"hNkdf#yL)Er@գg3vrX0YR ~6{ؽa DH ;'UBd`a?(| ٠wfRxc8`;QW˷dMCI5nu 1 qksG-">(|J҅ΊBkDBFhJPˆ"zp}rO D=U"4}e m;I(d0YK p0L qtԈZ:-<˪'QY?] 6lON|`̜_٩5#{ꛗ2l[glīhN ?@1 W1VI!C"uH֯md 6+$ ?@"r<"R)VF([sZ8m]+>0dͰw:PL"?rxu q8"O>JG ߔԴn  A81PbG94~7DUMHDRnxF70xEu@XA+Q#dG\i3K.:j䬦(dȔ˙I"hkAа0x,26^oEՐQ81a|^/[ӏ-\d5G$< ]BSbX]PP"$A~h$}QkoTH;^r}Cc-L:4ۻ'xFM HAjEzDȧ_c R-!mnG*dh9A,ܬp}_=hKQHO  rLy4dIB[R>e=TLmy`|Jǂ{ԷTÄ;HnI*|ݢ$OeBcb]&c( g5֒P$Y p_-`[!ѕd!sf AA]t4ųC$N6f!eX~HNȘYA&0fzȕ`~Ne]UIlB$CjIA-. T2?2EY1jD>F,trnHqhaV{apA#`d0*QgݶM:s}"mBdC0[8k)0t!b=ȿ.$P aeWb5$a0ʵJhH5#M$ q+8C8tĪɜӰ}ˇp4i%K"DV 2t$稂UHbv2E}e(ӻSb}mII,nU J|pl5c; @j%!,&zР][v%qqyInnfFz=$:~ҘkcnדODd}ZBڠ4n2KCm異!,W9޿PŻ r3CKu(2Z蠈%ayZ: . S.2 CѢYdE2Z$: Pil1'̄o<0FDxWo MMI S 4H |˒'胣I7k'}&_9@C,~8_ %b0 tlloɴ}y(X/0ƶ:O95R/<"V_JUv0c1N"K! lYh$$#@H^Cmٙwͥ[fT1Y9QSof2B6Fe"X:VJT(@@\  '{'x~{iEcLB_u$o9Xp)Jz%]M)c8e-夏r/J\1'Z&Pțd˂5KYc /[ =i$s藘Jqy2jQt%I?鑡@'F'Pjjr3&ܗy ܅uR#R\4q "B@[OB7~W *e  _΢br*%-Af.W/))~U"8qT4t0E[N49l8hXՓs L( v̡qeX)jqON.i :_"Q/1\Y3n0\ddž0Dc .}e$Q+ ֥C6tTM8lQP;C! u,p9.B]L`BAנbsg@ұ9|M]I" %gBS"U8R1c,u_@懵ENdHc v[0@S0$!PpMbQHD .\FEҼ&{zI_Fk TfV"[$@&'^YVSD텫px`UͲz[+T-Ŕ00# >b~rg,6$SUҌ{UU"I,haZ/PJ,]dςFW DB-!M =M[,H,X*I9% )Nd)Hp:ٵgjfLmU؟>U$O`M-WW" ", j8/*@ZYI3g&rįg ӝZVDdmcIl3XJwn~mQh.^1*K>5He\FTSї )͌>z ffҁ/Y@P/G%` )BOw3vD>* X,(ƥ%A ((eDUlgE6/l|zU /DX{V6`!vtkqdцI[OD@+ =OW-=̝ c62Lbc -hpXjtFK #7~`AeκP`y I0( PMAvC­Bzӛ@1kb{}e ݰ ' X#o(){E-v!̭`Q.hHEzy\B}?A]Az- .x"B!Kv7@MA1'D{}Ӡ__Y"$gBk+l$ "p$xzA%AY0qG7),:dYBb;Z4Eu6EL_;5R/E6fAwC5[֊fqd; R/{1"F ^0m׌+@rt>goܳ>:ñ% ~"^J𢒭E(I(tk?( VhD,`>̽"``-oݘ6xi#,T]U\|*lMC =b\PTZu<uL2$<&B9'yLfȅenb)e$ Hz( ,n[GXP3&̸?5 N6(L6j×2& :`ZByw?ù+Aٟ+5;V_M `L* PPH&N&g *eGdڀ\XI8a#d iQ g'SAwQ੖7n@meCkX ԩ24P&Ě 2 jk=tVhhiJl:D?nُwmR w 2R+,  (8iC00Nuo`0–$ )|&У hVk7zNAn.2 ˅1Y XKɗ QJb`q}7'YLYJ !J9Cif%5Jw7uO8bA aX6k .,d,k:ێ%Nʥ 6QÚnޝa6;-ꦧnCaY`.l75ŁB a0qC0D\}HByY>M2x10rF c 'fҘ0/\PdNMBhfH*"CD=͕I _ cbRa?*F^m`0}:_AP uT)7%Ur|ѷ5=d߀q1 ra\`;dc1E/_o}9 Г #P $ I H0); 3 #^z!as-8[6Z=mJw=6m@# _peMrLMl, `udh\ D-N70"#k` FUbIǚA)ZG }f0).E”.d2XK r?-%& a,0M,p R@ Sv : DIӞ4O+>/)GץMkڄޏ'KIzo-[!$GǡDh[QlD,N"Ԭ8ն:=8"4Q7޷ty6Q/+4Zd+z0܌mI+ $`C[Ӳx"Pk}0$J(e3ݤcSB4 $"Ziꀈ2 7涋fU`T*hpB2 `l`@bCpѡq愶UGr @id# 3>A- ,c,=#`gA"kxk֡lLwP[@q(o1 ;E}#KQà0R|p -K\C%!ԍV$rmb]|",j%qlz*(+IɁN8oc@K1 ۷%lLAa4 ,@$ "6C6dRFYlh:|\pyD<>SkO+҇N*(ˌ[q=/R_4JJH:~@7"j>>'{Z"?p{۷(qZ@ɇ suC:kd-X̱d|$c,3@+T0"(^,1lf 0%`%X^Yd 4J'qpAWxd՝\, m4iw_,*=p  *$$Mp#,t*77RJ'< 8}RFc_B\F\!tekXÁa}#GlM*T92R= chÈM]K8t4zTUQ omTr$ܤ \`w-K?-YdP 2oUJꆺIID,nK4\W6byB@g@eɕ LAXa5? KVɏ\ !0y2*:dUX b:\Ŝl^ݔS9*γ.`NP&M!_R6.\*Kb4,rT\kD9J#&H+qpMLCMJيvcpjb4~+}*MVbV$.$v=_lCՄ:,3.G(Q8i PF;qN $qZe'w()WJTS J aZC`桖h+}3PH(&Er$;VƐu0VڵCj&"]%5*{M9hꬷ `^0:73%d [9 P9[,HX̼шn™\}c -@zTQ[ShpmeWoJjGR$3DT61w.FĮ\ZOQƔ,2^_7_P\HS 6R-`%zZTj%tF Fm }nsZP M PӠ.8:dK=q5j$D ^MC E:mHR h30aFA "]3SK{vvQXŊ'N ]8GEj23mS'e1.XJ4y3`<AL.a-ttejϫ2A% ;L,;GC#d=yb8%k,< D_,0Vыl 9H/Y/טIZ<͇N&ppSM50`7ޥyZqJȼXF A,y[0Eg@`5q+C R,{\ QYlHEP׆x`v7HOB\ECAd:֫,`@;,# ] 0@X Zb!O梽GpaL!Ld2!Q. a?_F<tN-ӥN`]he:@V?RزPDz\PҀc <7U #6kNXBΝ)rD`ELFM,UQcpFC}<%D4;'P?_du6oq-m6d[Fϭ7M[Z~F0hLd5W RAekm<&`^$Q:U(I@IĞ"PQ%&*Q+@'@7+(kILP؄Vb(Є FJe.3q~lh҂:K&5]WZK?*PCrS L%cSWtZXs}A矆f'kS{&!%w0> ъW>{]|fUɭy xJH, kTT! aj!ڌ o5[*FТXR~ MТB G/j1cq~RuN,7ѭ6NK*ViQ$7bpj~+9Zf]&tjy.Lfݶ2: rd\W R3Cj$# }]],$QHk"@"|qD|f LB> Q~J"v%^Rm SuڻPwr/BZv @IB?NܚbPۡO,RͳM)(bMpW3Xޮs&i.~_"5+s25dqPҢffNOi5( CT8l)2 I>AE pJdYW2Bc cjEVrBV!h| rL!@l U _^ zngG_ͩ1IǢ;KbZS~ @RM[*H<ۿ#uI^3A9h6>2͔47'8hg7wUZ˖˶j]˺ BqCXu`I oaNw,Â$G(&Q2+yhL:5"xK;]'en0Kd\9tH2mX0[qOhXAr& (4N"hU&k*ʼn0g,_ܗ c$H2˅d`G ZO`E] c,$͈(7giåcCcM)lQTd`p%h.E){1JA:fe E運p$Jc@lJ9|7'+&bʀhP&|sYF61j 2l嶑/?ʩJ4$_dB#΍ 4ocׯq;u%?asjep?߻if+ n;j|%A !*'  Y IblM<̀XQzɑ I$KiDc)(!l4mdb;+YK)E"[ a 0H扭tV A܇GdvjN'"g֢)Yqh;.2hs-0ptD#M̒'X`1 ]6"^V3zB3K$U ArbF%wAqP`dX0  v3pVmqgGh "5mGc( d͎75[WE#T qeلGD$۬w mb*9 ޸33y/@8]Q yMD~{qD 472Y,,("(CA0K>& .b*q͵b }*⺘7Ӡb~@#kEivVd04FNk>j^4x?z !uL:IwXgl;(`#RvfGf==Uzݶ:<$=hGh GXt#Vy bUiʣ^ n dyh4: ef&®xL ,I2Xk]~me!ij1dh: 7C[J$C \g=! &lw6efk q+.Z b+8`wDA&HhXV5ZIi"o\  ,e{3$*4 NB[]V!doO[ZR77=&&Qg,$܈-&!a;ZE6}rÐ<*hk o-$"M".68@:P;ui߳(0bO·"u(Ls {D)\-f(-RME=.9ƭeXJ#3aDA!"|Es&rpaBD*hR4|OnC.mr^)ÇЧ똇8^Ȣ"DwgMmճת8'!M7J4 0o~3GQْtc4%8eY9َƈ~L9C2ٴF&aO^;3^Nڜeo0 $Pd5\i/#Y^ aln(=EU4#ܚ*^le\;0AZ .CR]z2 .r_IN}&+YK?ܴm_-sCPV˵i\&(g?<oN ܜա 4{넠 WUF< (F{ =\L5{`-Lّj }a@Ϲ5ri"/%jk)øƋP77Oif%1Ã8.W;m~lN̋b,?~l@t!"t<$P$$2W/.cCB/s+dـ)\0?|hxgNxaR&i e{k/zsk;*?) D9!~&F! a2@vyn iJ_84eR @:J!C#X*0(N`Y ;Ƙ;(<$yB'b~g^4KHƪXA8Tc'uh]_sb:T-*g;'Ny}C @/n{$!0#*G } *dDW\i5M#l1 m4 P`|-"`nY%etF9MZS⑂xmu }WL3 6(@ PmȓBc.ۊݥ x*H>>$/t` UDTm^/Qa튲ljv:\I֨"[8%Fła6c@s1 IO P`RQ^ |qcŐp"c^P=/xd(ck;WH$->C/ˡzk`AiD8Jl>2Er=Js`44"Xs"%#HxW=$%SugJ݉)V=?St7ڳ*! rdCB@K0reggQ҈-1ˀm&B@ =řfK;V/Z2y {'ڀ(EmdmdJA^41';%u%r.q|LTsffA+rc2ZSfJwfR#jՒpfU s}u9"@-__|L[a$iLmU_/(8˼1$'jvpUԠAΧTk U@4_>UV!MHh`TԔduY X;fjt{ RjvבTmա~/ZF ]yqn"\ itߵfMZ%>c${U~2RHq(C4lfe78/OW.N@IQ ?i\q*;7*B+ QIR3D)Wi Mb>15T\`T-=@/nd*D[Cp=dK: oŋ6el2SH,l2I>dT'*7:8d̓7_cطUzCsF+OC!:L2(Tɵp6Tf ~9uXw'YK o' lb qӄRLEul*,UiJ󍌂2 Oŷۓ&L+KMܥUP 1:|G}x`d 5XS :+<=h yZ1*I!4%# 5GB3?XPE$EpI4db8`5Zc?-, A\桶m`N@@2; 1G@9ܳ`9q2J-)&d}"S27"sglq夈GN&oH"fٚG"J S *ʱO(T= kP,$@Yr"Jr^F..0iIzd֝wM( ;vCT\μ2Wr`KB+j,XytM'VT# AC d.@\\`!b$w0FHq*'RʌLN8cX@+"Jl^dԀ)Z3JD;}0 \sS>, uHW:gIUKZͭ@@!ɓ\O3]+ Ib(ގo#`O+ޣrB9g]*EzuTBo_HC樤Fpi--æH\O٬ȣaqOPg!\+ub5iZ]eIbe dWr3D6 DLXD(6v*7otL4S{#"1܏ KeiTD? 1aOΜJ6]4&_E}a5ldTL:̖>eM=_*FY^ئ:dр[8H‹]=6 dcp4ĉ ʶA=KXc.ATD=DV%BaO6 pի M-RC(t4R-uպ7ݻbT%$mh<t[iA_UEuT"\'&{LCNzàU iF"De`a pԌQj/ˡfRp~V"T hQ:ul:<[?!tSRO%}#lٖ8&, zH.P5KKmub nZ$#\Uh: (BH@':XO}տI<NZt&"?#Km7"(ܭLe%,"8`aX1 yTƒk>i\"D2WL"/ڟjk[4b6rf3cR=Y K`.d ja>D)y*)_hTd!WSL2BNz=#9aUVQ&!$ܸh *._6̉ɇ 171D-G; UK?<%,IG2b>yfg먾V{awyE$m&Rf3/FO%MцJ(K<"!H'Fbez`e$QJ(R&ZH{ &QC)O5)妦ꪏ_Z_Ͼ{뤮R6 Dmҋ'Od?¨k מ9_yPt.A ).>1Un@N؀+ p qϿSWBUOp9_k SFS$re^~d Ho=9#_E5uqΌ.pǕ@! JT,E. `4. %@F PD:1{b.rHxb,;.$zwO}\qiWQ?[;HA`.! QۍC0Nh=V"94-FsLW9yϷl(q!"wH]Jseլs6!Jt_l V:)1iD{ZdfĂ":ҺFu~XO C2cE g!oCT+v6ARBZ,"k5jJŰi# |&̈`ihC\d 3IZ Ar.+4.(~T_~OqDB *t'3i rޱ+,pz_np[e2p&D8,04PjMKTp~oT*zc^SiFSC潫])"^EâFLA&wm7D j+,P76E 9H@E61= u^N¤ PpQf4d4Z2,!_ LIg@?gVYZ/mP\xm52# X 4 2>iGD?]~H$U&?CK=mim%~u?_J 9p-0,p>_eЩAtxR$>b%%)nH< dMFW5=4 W̘m,$0!/a-'CHRVLm}/7+2cdZft7 $Abwvy,ͪ@( È#!( 3(Xiln1pJ@`(qT* @Ll::IjZ aF EIHNʼnw&xKb/kB9ӹi9[ʛ TA4SP牬AH) .} _c>G@ P@@d wL,BܠНmB08ZT"bcdj24ma4ΧqK٧n0,pN%+jq=LJ'H{gw(_Ua՜{lyDVH{bZLgLOf[I$^y_n,*7ճ]6I$_?.wmj${SBI bB]wV[k0LyTU!$i`xA썄 $v\X>0N*:o84@8%)Lаh*9 ͓zj@D@XރkM/9,9qT(Q5QxTHD"[s~$)x@V]?cl4?[ qKZ^6ad5;^a[;%Ę Wa҉k0@wⱢ[#c ˗Ib DIРr_q$ڴ "6BR|V5y316ți4<+-=@}kuScop,xt4(tS^gb("P ܂A2|;4GPW<$R0>PO)5=߄_ ds/2BSl|D=ZPY:hſ_ʸ0@j8e@GT2S*gvY8@ XHJ`#"N'dN!K, 0$x~đߓ] oSuWzTr dc&0V 4* # MSeN%KnS6g CS!uIANMmh 7j\3޳p2P)j81 $ $Ta E G$ewPFD"nI;H p$b=zcD?]V觪ƄVe1UpHet{VpbnhT[:gB'{6& |02 (Mu ~ }4ix̥dI$tPa y$r0 a#C%D$iRԗqgb3}˽LytOIx,GI,xd2XXQ,,b:u0#ăKM0mхmtm[ tu@m 1&ΞWHJ 1ҦJsnyWA;`r>B{A2!l*)H1) ((ά:,]=juT`@T|k4/eJd*M~#E!K^ujKr@90b)b>ʚae.;>Ӡ߂dr8Zx]JVBr3'rMj%JDtt'ҝKm=]HrB1d5&?^ַA qpT6Y }`)p.dOCG#3:Z0C 'Q0Iˈ(Ǡy #%j'CLj0<5-~uqA7>cU7&= (L\,H&- 3aF5s*`1k3!G`HmQ$wdUEmT\N5!.JR3?| 0@IH8I[(rvPkt\ޯ BCh4Jxc*,kE( I$nDiRa4$h&mD.b(R y6+wU]di:c+3⊊<8Y,ƌ${f!9.Ήgqy @O!@o0l" gvYAE[y\Mv$W^\PDN㊦pT)Ly@\)kEm30\L 5.fU5c޸?öw[@o֓Y xV~kh'5B2$tU+[2 @٬MM$W~b ¢ Qq :v`p!d #*.3 ́lp߃dr,R}DEL9":Idj2m2B M:y-i7 pQA S!㨼bp[X͐PӦEQ+`s e`$THR&KXA0SO7d4C>0 OB D|_"WEA+X:8Kc3S2 Cj@.v"tTM2e1vs73iܸb_327E&R%c0xh -PGy>l0gb\8M0I$v"2@ (Jd5$F_A~| qQ BDTC<Q# E'm:-A!uyqh4bi}+e;;moO*%U} ΗC*H(9?7ADҕF d YC 8b- cMݓk`nlUBf9]P<8" @A"sCb P\Zk@*ơH5#S>bixDſlH/1v h,5Y'^vhZpJ?h?PTWq VS&:4D+*bfb =NydCp>PPhyc"DžC?LqC#^(_Kפ<(&Qё B!S?Utõ h@ "O5!fi, f-5f4/k@]?h'5ֶ*Zjjo)N[v%d.VW?=6 tYǘS݈!R2E|Y"`%:›RWٛY a:954MU| B1$jڃLPQf mc+"ngqM*sֲ朵~RWJWAFgqWDU*Hv,ÿ0"C=Y[X롿I-NaRrrzK{V@eoC`nbQ4G"rdW-_ڷv85\u| !ێR995NnPac֜` XYi%H*7eٳ|ޗ;?VD:ޞL*Ǘb . T$m<'ęck]ArMdW}T(^8PS}F#+]c3F%5I$3Ix["8ZWTPB枱V/d\ kI 4c 4="v LWE`c A$&s@(M1DkDi/j,a?ǥnŽ578hN Gۍ9jѳQpq7LUm 3sKi]xޭd `(OD".gtkBOrCrY$M2=߷)MVK@@YH-UN(1UyM,6B)):٧ۍKToߺN!AxTP$F樓0)˂ 0?I?.d  019" haj ,|0z!\)ɖ'8$5̂IciIń65JvTבW][Lj<-JJjvG5[rwBRTP-ZI&n  7akzNܗgb>d1{;AoGڟnedPBk/*n4 =3 & ]?Hψjp`~Y !`\QZC>Ca^mӯI…,!gnw3g{Xr61;P~@eC:h~m@ J.V=?P:N9_JϒQ#^cxe'-Vr=y_<4jMqr~6H;DEb(4Q=ߕz믐 9e*2 n@kj`ɦ> [!±GMrnV@nۀ[mApIu% CXU( ][Fceʾdg+8Vk14=a"< /b\Teq _Ə1푐 gpLlP CٔJg+fQZ.+ L_`ydhmh-zyFCM ,3#LDLQ|f`"`Q`|a` .Pr=y>"kUUS]W ai}$?}_d6IRK.*2ܮ˙Ե rqa(jm@a.`EnݼĀ^j..ZUX3B [5H7w)Uln,cqd~>;s`G`cq ;k`SkDr]?wzyn6;rXCK+agGI*Z XQ)YEr_gL~Qo* @ cX&|_ȇH&OYy'-*#8l$R[+O%x=QC, l/慷W7 H&ze_; ?o o]f=vѷ1WY@26"Dxb[3J.P 1Ά\=Ӻsi[QŸ\<r({w.OݭQI:z)p'X d (^o<` 9"k o0g߈/|TR7#*7FN÷fHI-.`dG/_zY]0!f'sh2A P|0'#X+!`7ΒX ^)&*(4偗Vuß*5Bԡ99cL )ʢġtA[aPIR(aSJ.Vϳ,h h58)~@)t墳RFQ4դ (хKd+UZM:U<AP*VwX3Ugݧ&Xio\Pr\H PN5px}PusHhR,,>L.t 6RPD=H$$FKgYWL.N2T2lX4p9Pd.f#oI =D)1dP"k0?a- ,g,$mt'*ZRWZrqҔ⺢r=9}RR&4r9UwHx$ zKA9LU9O}rL (4!4ci-t03DmJlFV$$4 yQ3ZoKJ&[I!r^BV jE0=^)8,BP}co߇+.J/قk,x<6*mR/Mq|! ˔o7ZEI@(#KGkD[ 5aIF$}-6Gɶ1[ A#F J"R%bϣ7-B7\M/پHNO2df#HP=»Z="hYSkME EGz.}6-O^m?yal\HIJ T#(Z3JR*ZTu@$$;8Nm kfRA 7.-<"f'Mfj8؞1Lts/0~'90*ʋڢ˫" DZV GJj8D QV H]^&#ks?Dqb"xk es[_N%d!Jmf&5ML9?/ܖ3r_CMiD_ T{d}7k p>+La kq pؤ;y0\ 810:ش; ,5Dq1Kf6ĵ}7_)Ο1HO^:0"1M_p1qȊ-K} F uHToQk+3g>􎩈ݮJkjY>_"RWOK'#O?ϫZ@%*/DI6唔gp"4m$ V%3)80Vip|)lɩpMkw*U-Tɑ`@IA& sq6N^ NE']}_M0g9dRQx%\d8X BB=&h hmm@ l\SB]e  C( hx"?J+e0k7=A54%y^%ƞ2Kr:^@C.b.G|t\͈M̺P"ȟqs[8cWUЋ*ިH2BS?Slgv9ksQƅKx}@%Z3Xfv-D )%.2L5A:n1[Fu5jC)(ttϟqHZx9"N%sjzۊ"|ʧ_ ` ^st KsG8c?)\P`H<Ⱥ{_-& d$b1hV1!F1Ek]J29dnlݝcP2#5abuw\rQI:(<:ttd7X p:{\=#d e,0 bnU̓HQ1Er`\:^E*:Pi>|/D/X[XNhXqfEp,<$Bh ug j4fHHuHe4J< b:u&JR94; (,x n4ҩOmwXR:$%xL$ @$  c48wJ; ͒ aSr(N@i8$)iҜ*9D_J#D}lD1D. -f:VC^4W?qUvbx[4.H*B6n,>?݃kZ > I"PMvJRd Z2A[M e,0@0ĉ&SQ8 j'!crsgRa* d^lXbTB5X_IMfA7! +p2UG#Ĵµ4yx_˓5eڷN7m &vjg@傈X}.פ&dWůkgS3 >O+fg$!YT\Zьm|dh@ jrSgI'8w ř2}FB& (i|zAY茡JdA<5n\82k;VSusJ1ES{i &fl~ڰ,t&Bf.Ÿddp8X pO*ɋ'RZq3* r~˓mZ\ԡ?dH 0<(_c-ERHW1CYCs+Mrkn8W_w؛q[AQ pz80U`U2ew"' *إޮOiTi&xXdM <ôz W܀Y9̏MJN28#zA>{&#E@M$y!7*o6Y׬C6ZYʯ$+߃ck?}d/:"}op+,l 7VjxE̞p80P'4,ВT@[g;=B\ɽ @* iIGmTG"z\kLʙ@&~I&zZnÝ춙hE+x q*g?X2+9 {*r%޿ԥ1SJ /82)jAJsU07 ]u=jGFKQg4@hE6(%@0Ev Dګq-j(W6j4q91&y3`(&o_ 1$rX+ KQ2\Hƣ5#;٩5ԇIU!ϸ: Fg=7!M77|dHء@L0& ac,0T Č`<2£LEw2@ ̳) *&<i|jN]HSfSM!Oc* П8lDH8Xh,|PTfXWseAIAs?큯#Q"ĐĘ%s\wu3?s_[?M37TU Z:P*gs. Ir6GTU@c9?==H@gɦ ub]L 6=4JAaĿ2*k+ۍ[~pmr-wɯ+mD,*ʐءo[۶+dV)2@bkN= Td 3`;=Ϣ D6ZI{K=}PB 3La[M:,iG[eʹ0fufJo2o J-e >RJPQ\*uaCBWC 36(F&,.{ިh I\ kj- uE^ !T߫ a?hd QR _cI)xЁ=}[#(B+pqȈ4:ln,#Ƃek16l@F}."Sde3dWC)06`YuoT@ԉ+rm7&EdswE/M"/cNNƈcfgtuGUSR468@M DG tO(eL@8eN{k"`!#,xՑbpsk1҃ bXHqP@0NSz}LIO$,<\X8]ŗwV6HĪu3yKKP.hFt\Pdh6Y7}1`_lȣ-@% ,2M_u -,7Z%NUKefjUtkY6Pi"!LJA P9bWRE@9ͺ*tx" 0 @@ԣ#--ŭ`OY_ei1UdiEXc ,0!N%&Ama17:sM;vkr,3djDW[\-[>=#du],zʎxho+F8 wN[-CνO>ԽvZ!,*Fptm۾"p(H2 Bɕԉ)3B|JO0rS!RF$#;TpP10))bL)(,qe=^(*Kк/͘+HOѷ5ō=R&Y22yOnk +߭ YAHiM6N!Q'<ibFXv慊϶ڒwEƳQt4P1 |? DZj 01I_@ Ziԓ\)!@/:m\uu~^Sծp {:Sl䀻^WQO-#B/{chD O$Yp+[ >TyZw9k>}jwU(84Hc|#Sla"AHbP6ۘ,$@~bCVǀ؎@J 4'Vdw2 Wa? 0qYo5zWog j'*=@$ vY@8iY,ڜwcEDQL]` nL|;Tx!G^իjeuSbRpdQMKq SR&N+.bګ82prD"Mn3B"^7VPEF:q-2 *W#y6_UX'pOVUc㡜CLvw!VtMW C\f l'ApTdx{;S,8@@F2C&gxWjͩΝ @chU $/JHI e0ڐ8~* 9c=EWPqd^K{=m2l(,,Ɖdɗ17? J(;Z \i`5b Y·r$E̥+aHȗÿ輱Q[z!`44*dL:&=A0hUz?td:2b,*[m.0+@J%cDRJ=*ҧS7O$Q/Mv)'c&A4)=Pw1bO%xϿQ/0[J`]u5 }ά!aуgLN:M +i)1F)Y펹:ŘNrR*ð_F0vV6Sg&Rduuahum Aʅ qԀnVjbUR?ÿ4\Pޥ A*)z Ԑ+4Sۭ .K~W-㟊 Acwjkd UZk-r5; ^O`9Zoou%SFzaU{CXUɻ./o) ئ ?zSɆS3(yCpOУ_sbȚXeGuk+~0RP7XtR6QA :ޅ7Ľ  4\qmOo bbl,F٥57}m/-E|d XBB5z? 6 %c1+0Cw64zZ*㩩le_?C]~>/{]+pi#T@ų:X@;7K侮Rll_MӡxnӧJ#XQԑ 8ew]6*%ϬK &#}Io%DKf0m'7޿qz}}}w~q&cҮ&sC$hi0~j` E mٰJE*E|=B(kE| 0P Oɻr>]a#]EK\⤌nd}P'1]L-(tyÏ2wOu'8@BP@d%23[q0Aacrk$ox ͆'E߅H /hE+`@1(&T2/W„BtZp Q-HC/ q1)(e P`UP""806(AAOO̦motŚtҵ# $b!wng- 8GPgOڀdo%KP9i,Lf ΁,kWK8^팳r݉9G4wR#"@z0`\_wt[ "YX4I^p7PH*=o,&t؋OVgjl [%{ږmAraowL".(҄Z^ d-] 0?KoT",)Q0)gW=o04(dН:1TG`zk 8# :cI*H*,D"f& hm16?:Б$tc %Ŗ牊KE?;4зWjgGn•1K \>qɱq!{](v" $0hi}#ySݺz-J_x4gkg(,]{J@߅ Ap{k*ͽl]j9tldDAq`9+0X yfڏm 颡QNum+\E& Py )tbHzfcF"9,a";p#:O枦$N5!G8&=}5'-uZA 0:gLܗyu|BxLPI-sOoUYHk k((9c#W7H:gb(#lzt E)i]S51]]eT@#yΠq!ǫT:,Ew{P^FcM$l(Jp<1T<"f>N:ǥ܏^̨e+ Xd`3+P.{ hu P.Ϙ]^ 󟍙O߀"x O )TE XgD9dm'FJ)'@ t1N\+$| 'V!Q44ic)ˁ`. o~c5⺼v 1q ۨϑ{j&%@Iڭ%qEx/H OHFh17#.'" bHJfDS޹LV6{ Qai+ˤ-H&I \f: D"#f7 Yzm&w~C,t9<6\9̔DBXyRIO1#8 c9--tatcwҊN aLW%E }o/5Эh=zfcUO30x9֓-20aS ,XtFi ,4ѭf5cQ>*S6L8##P LCV8x#ۓK׉ R/]Y[-L~~ !st' ܴP +%iK1߿tƢzEH"L΅Zd2u'#ZyP$r*Q¹ZfCH syR`Z-~-X~ dЩN_I~Q>. i ֑- 9޽s, .AU;Ydf)D :@ΨIGJWsؼb7a^_X[jpfavW(1͡IĖ#\ʑt%)RPIfʌ>[7M.I QH;l8LY#iK.."[^ْms (ʺNg`#"Se֮0kzP`w (::D0b`z `jï9Ύ"c:G.p*QiA6 Jٲ؇Tfĕ$BQ˛2V6=N62Z-.pi֪o^6^{$"@d;X+,23a cl, $ yIcGt;NrÞ/*mlIA ءe25Q t,8qD),(?@#-Ae؏˸1AFa4Tshp=d" ¬M[4t됢3F,#UfǡCG]/؟ڛڟh:*^jJf4.EY5\\ u_H<&h`U[gKE0ǁE,h 9Ԧ*+tWJH >gh M*usoM}E5A'2/}vSeBo)v2Rdw\X,p5Ak  }meL0Mц& `T  F`@p"* P[ 5O XmxUgu? 9Us`R4b+dXFEFx<EMv3\Q!oFͣJWM*XC;"K4봻+t8;!< 2sRfef[*˶&i8@)fZ֪\q@Wd$]ד/B.aI ȧa-= ҆& 0cӆ/AhSD~?.9DYap`D & `i[yPZ@!6XjJ=JZaq9]^vg3.XҵkҼY.b5LHhd#h͢JZ*]$X1B0ym L8#F j7eZ i_ >hr6&H*VzJ  mLzTP1:0EIa!&֏g#9䯕 ai]P$YD`Ֆp@V2_8;.>}n][ϩtȽl.n{z49d}_oECiӂ)r[@bDpH( '0!O*Q]_6d?[y"6Lz##֑= ND #Yw0?(|^jTCŇl]9]nԷCY_ `$\̸`-L\%@,@Td:A- 4K(0b c,0*<= 4#c((Di0-^۳Ȑh@iq6+ľcͩ. 7s;WdWm[\k!ܗ[5yX{ΊRJeYdl X 2bBb< m_,$t݇lV 8@`qHQC#71blIv=` N![^eyARysJpul5F^]lǎb4LbG"[jxÛ:;nsk(ʤٛj#<"Rfe]y_gz{.ISqN;*]IP>Q+!Y4}& tNtCؓj!BEjuFbT7MVTS"0La@RɶnqZ-O+zEſ S+Ü.Vdޕ3ϙ}Ÿ{5YS3}LQ (e-hj<ʓed\Wc rD$Ð QeLO m /ڬ!gL#JchMyCGiyui ܋+.I˥j6PI"& Tqӕ"uj@;YK\ 5O589/) e*|QscSyشAC,V"`'[ݙ#a>鞐g^VF^2ĜgDĢrÑQO+*LVrp^E^ J-Atr:AjF3-rEt[(PI韓 dQ\x<UK;8'aMuads 0"@h$# B2C(,L#i[@]dy\ rL#;mHܶF6&&&Y_^^b2#Hy0AI䚕˖B EY@W{蔆VWn]zZۿP0vVMmHQE(W { ~ `(mgMJ@6 \yEM,DI3R 7U`5p&26aX%8% D|LHJ +1,fH%ݢ *&P1-6^S zSu|A@b0lP`0AɆ%-d  ̝s5C/U)5\_t<sNL0@ ` dpSZc<<Pe,$m) Ws`x.I 8~ иLF zaʊ B0!!KG%.j^"*F> H,8(< %  LΤ@ OEz*av 786ȯq .A-tBZ TW j'hZh6>%t H82nE M[^N; vEY)|O*#~OԨ^41i@=kb`h_aܽC̈3-J8j\E36Q$Bp)u&,ttԱJ*0~L 7[MdTX)R/| XcɈܐ-􌘰5^fe鋈h?]A9UX-Wg߰. USж!xǡ@ b5ikd@O#TL>\NTjwy݁4{ʹĶ9, M: J2M=l%s\HdvCHR T[bUDj6bmĠr* ǔ,5?É24ETګR@K`&+,ArH&(7]` !o d(1X B}0C mgsڅ̏igODPhLEp,!daHCS dAIb3"$R#(e=/$Dwn3oxlk'm`Q@9G[274"tGX_)N !T=,v Ta_W3ͪM0=uAm{IfYRitfj[I`J(h߶0 h9xgF!@H ĭED$-:$ 9gr#wLxKpL!D40d‚= L`9cI=%,N a,0Mɇm$(5 'U(,3WYF)?CO۵hȽnD QABw옹R}ݩF2%JGT*2 ݢWn v~e,`#͖qϱe(=)8xC8<5bTH}a}kDHoRvܩ-Ml-ҷgϘ]I: *E.FƻutYR$Uh_bRh LWO?8fFmD\[=L=TRIAezLvJ; 9٫LJ&'S,DԪ3Ulԉfby+tYZ/d*XHr6<+3g'q@֊/8 aAb7w0 z ,E T# |rst0p> xj6ʒL: 38$u5nP%Ki+"q<%gdj6:6-Ũ Qe#AFWQߡrN[2,ns;c.4nUaҨ$1|.N\>C"%T$ JXV|'X2Y^Nbn@sCQߧN4j`!V䍊ʿ.5u1vIYrVbspNPO/dԂM3Y`7G# u`0H ѿN0/1T' pV44`](1h ~A2`&L . )WhX8IҨ MH?]洂Nt6X,0zlc $RޞH9䎕7n?קn19 reIo%=Ƞjnt_ gq"G֢%DR`8,n8LUzP\GF@>5@ ԜSZrDsv_Bj'`fRm^23U <֜9FW:އԲ;)%m\^]|X^addC=ib;K1&D pa='鐯 (TCV&e:un VA 2p%[ƨ(w< 뜅``QKIo+cki%٬Kj e]ρJpB  H &M)"LŘ7<Μ񈞷3d=sNJر> H Yh$S4I.7.yY8(j yw?m2= PB/ ZBhk:=~g[\#҉j jguTמH9J.3`pV<?QD21LeÙs$vd#uXXpMË,=#X f$mÎ?@Ⱦt|2}kT sWn')ci'"'vBѰ1ϋƈ[u(*.^v@T8PK( l4:uFZ(NH*)ޛ43e|ɡBxy95s i (>_CŨp (Lp&E%ާx zKtAdJ" :QR˳ÿD)F*DGO :f3t휟B=Ċ}euH.,8y9I!D~_nxwYyHAw:Ttf |rXTXLqW8$tPH eAdKr=,=%W%ug.0@#Ĥ=C #4R2rxTmgdJ!U(CV6Xxd0u$\9Ɓ.(!d`b4EsDžOZ?~Rk.!{d9Dc iHnh^02_"#qk,xeL_S.VIfd"2,9misP&-A/sZT&H"\!W3OB|ќI<FFGh0ŵR&IB~ P\)~`V) 'kE|ſS .8a{96yR+1W֔'P}5 /~DM +s@غd=a3pG#8 ot؇mW &X`btZ b;o*o 8<;wnzi 7+Ie:r+0&n]}|/v]NYC l+&l (w'WR 8W_Vh[L*dZ!0a$>QȽ?F mxs!r“bP6UUg%̎KΨMchk5z_߻u (Ndd#4Z3H#M=8 !qqI'27 ' Q*OƧNI˚w?O֭l@E`V7(DHg^JMBbJ~z[_ZxKOGCf5Z5WM؟2R|:N_rEcUe'u_Bbq0,k4lj BJ!]c$鹡d m-3g:GXX?Ԁei ͏Xҕ 5gBuhvX)Iuϕy(Ъ T ScelE27OY 4灀b$"*'aP (&'xyj}Ϫv**jwl1BbxdU^y`<=;Yksx Xm'!s\R aEXJ"AW$%BpD0k E o{wn7W @e!a%=Z% IYNfO9gΤEMBTq͜T5,`4 c</20I8e, H P 4;e&䩵΀ E+:ނ@O<ъŻlg !oBbRr@'Psn4ќ-O.]M#4VڷL7/QGBGk6OS]NkTB [8$: )8ThWC>եL#d!y@6[0`el1*I mx^QX7ƌYYpjI}Sm ]ՔPQ2P,! زZpSwm YRq97#wnwnDh8rҤFv6a1QEm #< IԊQ s(&e6V1BV:n: ҲlMiP *DAh-XDi[!h` IHP>Yڼ:D6ibf; a6K[Gl$l8XɝQbs5;f HPW}XtpA^T=²X0;H`,;NIJ aam'W >`Pf~,H=6v~,NOf3Q:I8o@UE 23{8Bh :ƭv0 >qG54'RtJ>2G&ɼPr5z~(=d9qf߮fCt7IAXqeBy:2٘g:$At 5#*TKvϣ2% O ry.MN¸P0CGZ{+cGzdĀ[W` <:=#}Y- h/O_~%MɄj( Y6']~U7gpFAvQ7k-(sV[8/`q`cƃ/ɶnDV#$GVYLcBTMGTO:U,-jJ֭BSj8%ToUy_ PR5Q)T2cԶ/tY`,6Kuj #@Ʀ@>FA("=rܻ#CEKwK5`6c "TtUZC6e*yg& QEcʳт!\I:Tdw_Xs B CH,N E%mR>I.e7sWvdyխ+V+ar"O=%F)O%vY)9cljrRWr<̾c1\sZXx`VЈ=n1J%: .achP.hQ7.C.jb_Fۡ_8"fL  ZD+%uJ*@!A%L^s4.9{j^/ ECOEL6r}Mh/`vh7o0G%JH`o'd Yw=Y. _RTkhWGr:ǨU.)N~*6v5H$`ˣ ɐ@T.%E:tzsK6ߞZ,q#=I)+ܭɃ)pr*NW)4YɜVۿmJ}iЈ3ZC8vU ڒY)y1a3xʕpgRdp 'H)hDCUϥ*+ =@SnxqI[ ~FS+2 f b@JP0Մ5OŷdȒ̛c?enk{o)8K!zP6 $ڣ֔rQ`]IqhH[azx'.E@SE d !a2Pb $ Ͷ/DAzU[bvУu;QQtpxpQ1v.(ha&/_OEiI2JDbc}*]H)3Iɉ(qoi5O5l7R%pJA,!O$/i_p~bV\U$9T(D =ڮ-S *Ǡ wm#,P; '?W Sz>T$r 70E*zͤ(a B12-8]A`H{0r vz CRm0_[fO<[M ;@ Px|SNIP99',^pA0oO>6ɍy4D(0HsxMJuzX"£ s Ar p!oB_=HeB|&MѺ?sF 8$҆ > j2 '"bU)FDd #\y37b | q$@ŇPȬOsJ H2 dlד.NfC0aCsHWSPޡC |h=`RSB-7 ? 9R` Ǣ<F8qYݔwuDrLkUD*OfZ9#"3#o/7'235w^^`ixf՗Mc\4i6ɠ5V[1A1|tٱb;?Ly}  Qi \'rta RȐ..g) -` A4=s"3/5J:!F( ^ivŴ1n[Zd/B9[K0P4 s OHL= g!ԧc[:܈-dD8xoѯ:SͿ@#ATFv#7}XQ.H@HӇa*PjbE2;l}{d뫶;Z)A0D Ԯ )fpl5cXղXipK?U-bJ(/_? Q d\QZl͹`AMHMwRJMزblˏkD>[\kc%mqyt / Ch8@`s`y| Xv5VfZH ' -ݿ}" 5b!FXύq8v9Æ\Zbj-l<˳uT62$dc,Zi6"  enjSIt,n")(EEj"@@e(<D8UCJq@"Ьڃ}nZB"-Ttu)a<GՂhJ,`Y5v#rFq|/9$H0(2J067}XJX@,RT[N7f`X'! y-+R=A"u^~ԙ>ۧ4S WpHOv*WBO9@Q?gX2h%^ xpju> ]GT Óxȡ9LBIІHL}hr)m<י^7w{M ƣY `z9k nY+ֲŷQH"D"h"Wi0J0blUp) ):ӳ20RRp< wLu&E;liJ5GI$~ ֕X>͠&\!̆Qgz.$g3:)W*U?‘9YLKECfĦFNUa5sR; "x4u*@$S) J ZD*tNwe>6^s ޠX.HK_i ;T4}Dg2e$M'@EDa+{9- ):p@qhw!/0~BЂfe IF?D3iK 1 `^)-(-@Ø} W(Z!?, L({ RO3&Ir'4vAn u:߮d+?{&oYإr^%S0"A{YQle՝ZN 2M@(ALH$Hd$.8 %]OKd jaeeb(ݿ#A2T\f=|,'JCb2X;Cv|Ҽ4Q63gnnn0B, WnS!iL-UI>Oc/0KlMjN:ra+o6'7W#c^V!o$T1L% ,95Z]@Aa$&: ]痢,SP sSw+Z&H\_q[~)J-+ sae% -]cu!mEL.7N#3%oNY?h?kѽ .#4-X?^b iE/$HYH! Gd*:0pFGbrZKJ+T7]$d#N"Sy1p=ڂ=#, KM0p ⠮H>DnBqChjʮ_ϿcH">*2aaEC 0l+b7FPaPˊ@t4, Rq9W{(p:dŕvL!*UH,r!׃!l=/%lf&ءX=t4H>Y I0.>In(* :PxALM KW@m*E@U[(X`:6[ W7D#*="ZJa ҹ jHKOiegZ,h͇Mu B2suW΃UN Rdl(P`HFzaL @a)Y#(6 6T:0' H@9EȥYDh_q*Wܢ 䶡)66 ޻@#/ILR\Ϯ85Kbaw ;I~z͒86@$| =cN F s 2p z*Esj.ter1ݴ䵶w,epZ}[-} _L2'.֌z0 O10!0av˃51#H3 8c(0-6eB5&Ta 0coaDxVZ}ȁcțR;D'/9%=y+n}=Z }{,nS^Mn@"?HG{:3w}O(Eyq&$e&u*l$z BS0D_;Pax)jJ +*&K7a 00H`,LyvňQwN+CۇPKԽHf*(GJ.XjT8LMSB2gҥ709djPX=n^-PߠGbQ~6_I O!ɪ$q dZ1!Kt.IDqs-<^/, KBtShΪe&*i Fke0 2\Ń3m_Um4. ϔkؔrkrR* }ko_<Ʈk9xݢR y3KF2〪&d[W=O0 di[{0f5"W @( %Jv&k06<ŧDל #r8;Hb ;,D{] +Z8EՑ|M~؎Wi8އoft|lf*1z X00^ b܅8Y(.I #^3))>T2pa8a׉o&`E66:I/oP7մJqk|UR&CfjRJـPD{#6(eyhcN:'ZSի6P՘8zȌMK yF|5ϥ 6yV1襦QIE P$bph|dM!|D [rTd0f 8}aHF,9zWO5SK3Chlv )F tGcM.\1M>^IMgai, GN]uɇ<.#{*@yt Y(fz/iTR@ivIM^!D0݊̊sϝALJP:%sdJ;ND"Pi*%vU 0D0] *ep(Fasmr͇pvj_�_m)Ʉ_nD!-wJSR1)K˃pzHBa 2˂v (d "W0rWDڭ=#f `_OqK*t:} z_x >8sODnv9l_4~:j=O xt3Q T@yX?1zعE!)f iQP6 ȂiNL.'`hy6,=PBpXNbSKAUEcE })=_C &Dm5w)o婾 UdҦ*"ҟw-(}: 1ċte ˈsG0 ̪>ϖjHi& z$,rZ)@"ΛRl3eC!bU؀̊H X "aJpEܭi%RMk%:rxE$gYD +Wڍ=h DaGL) X7Ծȯ\.g! 1,5%4ux>|va Fh)C$2.DU e-gu/L H^!mrp7Fn'kPl0x 0H:T kwbPXpX(}`.LM(ŅM[gK@Fd#KHA"h]ba E"nezwʵQMUVF2&q}IAv I7cXRA's'Sv["0]" }F9TbCPNtoUoOjG)奀Rpi7*q􆊖NX9D!F0bU+IIc9DOa MEJ}9!h[0k@@$*,LSY܁N`uIL {S"؊* <"E"O5"ތH9#dn\pD%Wjń@*d2S1x8rDRvPp*I$BS*yN)h~KGpGUݭ&g6>[3=bY*B@ N(c<-W%YjzEN?];BbޡfdaS/% {uF ,[ٺ=+mqޱj/?Uga ьad aHXa?ěy 7kC0@Ѻw@s'\R(R501gE LP>> `R"(qǿ#C| aA;| ϭhEvPWȂvKEß!@'?9zSA"I cap & _@p4TKYd2Ay36 a&P W}$lm9B0G_A.a%K3qA%v ƃjTuJͅ?Z*2K.$Z:/q- nD3f|^Oh:4;3mI32dK,uz[K;ƹ{?{<"f9fDB v"p2#$hvN Zd}F(эgV|ygB vS Xhm^ pf9$!lY1#=qJ ޒ3+K\LYz=3[`$71 Ru]P@C%:Zn(`,08Aab@X-LL~A|t$o~!L^@MۿF-{?GDh 0dwo0r0K=& Hcuj܂x ^kFTF!WHtCbD).`B[܋y.w弙T E78D}ZCe?3+~F]uۆZdH &6X 5+_5 mMl٫ErNEeCPV2!jFzZA)5,dodΓ; 4 A >%bPQpp!D(lh}&'ᦌK%]jd5cLB4+ny4! ` ^yii=X@2`'? zfS;P7]tjhi0(Pa2ÞDU-AӖXI9ui.v.o8潃n+8 ÝIqB;]<u[JUvϭ,Ɋj%QC)(Xr?dڀFY 06>@ !i2N,S2*(Xo@3ʦaӛ`0| zd2`rpo^k1 ‰.>?T~2s,^nG2+8q e9fl!@(6; kmJkҎin~hN r-g]Ϧ9#J޺E0\)nA(q%&{ף&h{NZ8VYJNzĈL7wJ 3B} ++(o7:l_LA_/ 6m?U,idIXk)DB@za"> a$qH"`b4OhH6fxq>cZq_?"ɉ翨~4Fݚm 8i c RQ-I+h,\W1]#~9!yz hNhXW}5>]8ׁp3^]Ӣ1*WF:ûn{]I=  6KԁHYPGN(HAs&1 ^=ggJ 4Eӄrh}UUTGl.ӬVGz[}( 2]/o7zC=OmǤQ捬0 ŠG|ˊu&^=1 qrو+L:!wH XafI4ǚ8FrߒF[4M*TSԹ$1AwR tX@]8*1锲e/QыIǜ b}4/?SjFE=3R %K@, 4ta.#$ kT<PE_e7FDMd"DPAEm&⢦ڻm-ocg&^eB T5e"!jS!Q$c;Ӵ%5J@xF'4d=ͬF%d:[09=@kP:$pPA1s|,] =wXFdW 0 ovM#SNp`kE:?PvC%%ЇDqcC֩V",i)/9N;;^p򝵛9%raBPU1 2Ds;"S XF&ܸK\s&ZP)gKMq"6J![L|͠Uw*DJ2-82%]=tr 0.ӭ'2(1_ȘU)$f:ȘSVtV>:&G0DaT@I " ' (y{Hp0P$dXCYs 4r6`Tkш .e "EL<*aH 4¨lY옚ͦd *~*CT\nf>K= PI`5BTNR4aS@`0Y{,F=2iYMOfaNj @l '!"F2mb%BD%̓H2$fU 19R4, Ed4Ĉ e# ̼To(veT(rPCdNo{!QaU%A!#PJp:LL,̾ 9*5ec6g4fc8" :l`CT!rVզլdV{K;!ۏ= u11X<, cFP\p;>XP g?Iш$$K-A#[ w;Zצ;.Ё:K^{jhЂ@" 趏ظ0Qiv@ڈ ĭTQj|dnLXdB7Ka#FKaǍ.<nHVerTcYe+tV`%q4@H@ -osڵ٥,\N4:ve3j8nZHTAhl*R-eaY8qB@X>@GyKLxwvt@ ''FF$^WE(dže%;xPO]ɴRh]ӦUJNp;kTi-hglܤ/0xa~-hTxE :KADDD33 Ab@MX(C`"J@lւD_)}jUa "5 LpTh^nH;k?aڛq/@dB225a{o0&PI]gqm<8< GEncꁍ6tjA{Շ"@4ؖ@10 7m8CNS$Em+ jRdH >09~ϝJ',%1 3!XHxPe|عwxXƲ^I&4D'`OBёPL]MUz)EF$ɵIJw;*1hmne&S$h ` aJS*rVp="eCdB !A9类iH qDž2wuuqgVٱкԯ MqWYOq qQaOtUdnfa H1a˦eM-dsBM$[=#Y $́ +vhH^ZS9Bq92Şt]wE1g=Ot%[A!WXM3]4|ZrGUDc6J:ƗB`"mt(?6MoAF1pJ\uµ/$:Zpz#Sd;Js$DQzduWVK/59%k$b^Td̰ nt`Ժ3W9{%/vj>`F$PE͟bMs}93iUg\DO^WJ nNJ o Q,RN?g = 0#BtC.f*e;,7?8b3q2&g?d}FXDf`DgDfM8dE&ǃjEv tЂ@P LqT;vSn8Pywa`;®R\RmZ%@%afGI<[xwxN#)(8o{rA_6(i-1fK3ٍ$VԐdy]G3,6=qbq@m$PKE A Cy3#I' `' R d)@)ewaV M`iRH EŤcЙxdbP8rR23!E/M KySW$/''Mճl *@qG' "P.Qamp+BBKC=MCEg, !N:хS4K脇MHiŪڭWRR;6Zӝ{6*/6Ɋ1>=LuߝS|ϮȦO AX Mdp>YS)r7m$& 1iL0kȿč 1&\BKˑb`Zk)*bA,;NQ>jRX{e9}*kAPJ _2] eW"Fk:r5"QBD-ie wo7KmDO#+mo)QC DYÙ0xC[!2'#(;W/dzuέM><. !0U`.l,d3k 9%VXaL@Ѝn4M”i|zh? }F% %+E-mRz NP@xxC)6:`D29˖D"jR/b)0^zDp4'}aٱDϕlEzV\]\8<|>}>" LJFJbqƪ[ blHdvS[D(Ƚ:!e U\Ab(V+#e2bhf3tȐ%sAI^+ͥ̽Y:hxvYrB:TWB&wV~^@!u=r;`\9X.drFYQR2+!RKed  , ?IM, 9&f0Ȕz%RX[@ځGhe@_h_d*~],X2Py F !ARIo dЪ Ơq<> aH RX{R$ d53 +b4j0t c=#fh@ENj[l4:]PcC tYM{\bVh;YrF0] e>绻?qm{pa8| Z0^!J8bG.*\0 +2hE ($yv QmXO@MERe%ms/c%ˌ$+{ ]~)Z8h`[j3ɟ˿߿9UݡGls' P:LH%J0&Y+RWEELpqP 20(Y#'_\P%PI/ f  àz˃02~dX"Y36AI1R ic,$oHm(1q LT8Z9M$wII3 y7sSex 2_<$ZcB&t{F`.lL9rH({nRԋ5kCoz2D]m(&0L?_( /Etp<4۴j#E9@>'V|^ϽJA.#X5ŽĹ8' Кq4 ҩu(>QV_1 nXC2`D"1 "+rV BBtV84縸$2 YT Hu/utU 5Z p"ȨR0sF[qd8d1=`:+I%Del0Kl$@6nnkg{RjtC#ԬMt,^E;3RvC{ '2\PH@M:: F>)| zX:7owB>f*mdH] R*jfo< Y 6sgtȢ| &׽$5#e|e?.ARQOP*);i}2z5BUijdZN[B5;=. aoglm& {˳BWl$=!4(a8 r)h^I/!3~Q äFP"R}KHOՙnj^}Z܍) g0xWD(R ppO>V-(5r|ecYAԇo٦VDl{NUk\;1\2偃%iT_'eҿ M)娀!}>#8.Wt}̊QH(l]}%J W5㈨ak]= oE0 ! ^|$4;)D#zLsaSkoɞP-@/P(O"u#2]5A="(=B%!qr@;X/ۢUTգQ4I#ɀx$g$ VJhKMy4bt#qp(5Bj1/{uM)'Pg10s X Q'~ML8<:H3D̐i 8s RS鬏Sij=_nP"@dBz#9 WP#Ky:GO7U/w"Y_J ^5%Ɩ5߿_eaddWfFVeo:>ocݞO_#!"8[eVԽ~`qIlRdm/ c:d MhDӪ l1fĦ\&gU(;g1 ) YqQf'4P+I^@P Ԯ!^Ee?YaLJ?yl($@4g2DaLđY=_"SrH[D ?d>8*bsP&Hfܳ+oF9a@ AIE6A-C$ (z IRdۂk6[K02 ,0bv)-kqχlH8r_5v7.$p*Xka(& .D= :p8OeԂI2hL]7_9oʦT'6P; QiN)3n1PKHFfi uik?ז(E+M\L֤gwwhi{]8Pj;Ơˆl@LK6ɨ p~*2gg_`_֢X-?w%)!@[ y}Íp~"tt9>TxpX^' "ZDƖ`0qJ &k cޜ >g.QN,fkN,f+ʞdB r;ml %oq އZL\*@!G%%vB MbS,h.fY٩"T`[JG!\\&A6S_gt|2$RW}E.\140ܹ>!t*J)‘q.L)EGQ 톛@ *`O _pXFjW/m' 1@0yTt$>>0mȧѵֱTCiBOhBHw~A\AlXK׳6YZO@)HЃL2-H5j폗 Qd#@YLr8=f ܛi< ,4 zL  uCTo6m(hO4#g ^iU+Tp`u) ua`8ߩɖ)KƮ39)}IjL'ʉdX8lfULʏQ*+BHj8]Px3De \`hI!+QVsd,hb Eh5BAD+' did5(E:vZ<Ֆ2 8Pbw ˬO"22?/ #H\XMK:n745EB*xGrw|wRU*=dFW Dp=A M c$qĆkp@ PC,ZNTb;^u%.v"-dՉ ǒ"~/Jh0KNfb92E57!.SQ0*҄ n^ YYal8aC$"h;!Fo},%#FW?|7E.mrJ< /W "@ oա! D,{ܙx @d&tj1HM3Fܫ6:D^-Bp3KP59Fء~%07YHP5;ZHGx鴹f[dญ1ń!ߴ|b0N3Ejh:Td!D95JV}I d6B4B9ka(#]1̉t 1y2=̎>uZa ME$]6aF'Xj{FlOzPzBIBBLD(k{56Pp "88S(p8V08Ou{8*(oCŝ{Wrw8-"ר86ҝC%4oH2tN~呕H%ϒk(!5+ b` MoJcڧD\* l<` ,f*A7VXJ+ $p)DP"%_q~:t('RUSٻF`mwqVDoIy'Tvd[,5[<"^0q@Õ0yX!cyPWp!CadU-Ms;OW̹ 7V%+WH ~ ЭiQ =<$4v~kI⏒nGPM4:&Jg?i#hx;Iȉt::pTHd<&0xp|J>ZdBKeSg,t,cW-Z #]m )꺶zS Ċ:! :CILA :0 i@l:*m}q$N;}#aIyvt9D4xU-R*6yV\HxJ&$hfDx%7`d3cR41* H`L$Q@Ԋ `#Q1઀3U+>vtREi"x,pGz:Y=r)&9?ZYRM! Bit,zZؑ7 lX4zN#\@b"j%Y9*ImSʘ^ݑ&Db&/r}mz2CQGxMD7KbA(ߴprO@:WƖt{^6X6$tGԚ. "+N]CÊBPrTjT 벺pfj?#Y/G"*CLд93{BB[3h_~OڽV*3um?d#=WLCbC[͝ *&DxKX╪^ϳkH([.j*!#) ZV'jvFO`8&ԭ0Hi@D=#WVG$D ˲?[8+P _E  AӖefP 7qE $U(Ḏ겙0)F\UV.(Fb$nex n%`LC1T]P)v\jd_VC(9K | ~', La:%"GCDfTyh͓NC5#%dVS3 P4kie_L$QH-t؊Dx⹺ hx"EZ$eLh04R2k'.A5\~4]M"QDK1= CpNW-OF'20,x[d~X#UiJ2jZ_R;f=OyKz\N{"5l閄"J$V*$ @ 0 HG`HOǟcK[x4S  7ʪZRmhx!ؘ>WIb_eJɸCVL%KHrX@l> ZG{Df,d(]V,P>"k  TeGʈ-t$(h-/+HR$h"k٩ݬ,+OXhv.'^qc#䵛xԓU4J.>j9?(\H(Yf/27%Xݘ3_!fp{QfcWJ 1^?r1;*F`%[!Ng98"t`;[&kkkQLfRrh  \.}X,p} b-) DYZnvu. `s9a-tH'Hh^˶zF|FX MR6G7TFrPruo| j 6ߙT>[Rd߀BYK r<,=TNle$o؆f l-E!虴݉}ձf Q㑤?@݉Ձ  m~;getj3!}# /qȡ. x0(DIQ :=Z `d윶WRw(n+!Lg+J˟IrbM2/dC~D4ЎgV* QȜ<# :{ǰ(]-8P(FA3QFCTc-#JdKby/WY|Zi#E-K!U{ҨvIެjsFs&5!e^d%LrX~OB%ov/s~وf*`(1B |Ő*U&0= * =0>8񧰤y;Px2zG+h^b(݇ёxba,- nE2/6@o:*CjIs;ٌILO'qd=W D=  _gGT ʪcib=f aO5`֟ڨ5w[!a/;7WD,2CAn Lմ3QQ(~'.KYeт7fEBe7q+h@fꡇ'J^^*QЭ#Ϝ4m#]3U \fw ]B筐P@ 9rS0J];B1@A© l8VX G;* F+K3Ջiw\ ɓr^|֮2ig\gg|="24ߡU#83#ZܳFUb63 tPҢ N-7>|O 1"J.s#UQ|@!N&$vhDAcZ)8]JDPy%, )N`4\H XT$Q,kYB\ ;l5j` V1 O o~䊴vR:X%ꝼBeRFP7vMX^,AAeQc)'YF01 U:wJ VgKB<*] =ނҠK#ŀWJdWZQ6] $gGe XzIBqBPҧD? lU9B*ckn S:sLY*~4rk?E-[BHV o9,d|Y܏tzy.Dž` I Ċ$V b$mrzz!y]n{*jڏ0ֻ1ضr.:n񶍮)4D mKFa,0{d/32Zz ضr YYk4j/Sڔ~USt-*^4E,H;<"~n]diz뿰B<03i{\G(-) S&#)bi6d>V L{mXB4ɜDuaLwoMi{T`h'!g;>=n~TU%X#\_Áj@K&S$ŻxһU'RnrAѷq)WSO;@}5"ѸPcC,8i  y`5UM(l8(TKdۂE; `Fd;*=[L$Hl2%˂`8C@8*B6h"BbK hv2 ovh}Í Nlh Yog7)!N% zg:o != ȸS/e1/׊ۑMh*1Ɉ?/n(J]1$2~1U>C1+k4sP_2rr#),/!%"V-r[Q1xAFۃ;t1L/)D4w7s` *&T%5C5`U1\IPQ:d:#p̐YqXd&x^S {2a=)&k=nKuw-1Itwbڂ40'l^|@9JlPP%C:EM$ e|59PlƬYZ5S^֒q[/A~Jj_|/ܩcz1+e.Ek@';K"2|~6L)E+#X=M+nd CG˺d5kv6!J7c%B,~VTd$>쁃۴q`lzVPD%g 0[DSs+פ'aMŭhi,~U(:nBjXaCC̖ PY`Ad2V\aB1{Takȇ<>㈉˯*f 󠝝Ēٮ7 }6!g@ LMd%V 0qZip# h9&갵54A_ 4OgWu55%YŀB8Ej|66[Lh4;cBE{r 6Ce(Ԋ!.yhde@pS&hȳ1apóMc,Ʀ#CzYݑmd-Puz''PNBVj-Å)4oY$PJ EQ]A^me E 1|G@j6m0drAZ8+=2Hm0Mpޒ˺utqFfFM[ԕk'n,I(EJ'`C ' sq4g5CJ6;eyZ{XTdMVAa*7̿} 0X4Ǣ" Jnj?ޞ],#fp֫}ӊ;[̀ybK8XAc4GηjD5ng/aXdSjPJO6rDEdDq6 HrͲafRDA@۩@1?(tz$@ -J2xeUn3\EܒB0T2] dh2Z[ r5[[, Xi,$mԅqgZRR74@H(͡=S6>jeB&f@Ys PU+ qa$-\CFb/ `8b|Pi\@k˜\SF  6SPpfxơB) ^Ʀ>KEӫMDȊ>@ M/?As2ҟomD `]P@m$y2/\dv_ܒBK"j61jpqrh(Ujk! /)&[Vnmmi@ HG $ Ew]?]RG%€\:d:,[ 8b+;=#8 siÈ쭃 `BRmj[dd];ɞ~_-DM| .͌(smeD\B'x4fM2WXOvpmEbRɁtw,((2E6Lk*pG U8ڼeȿ$ɘaW|^|3`-^\ m]%ev[ݎD 06/ 3!J#COW-2MیJ,B.D[!u aMHr7N"@Β<R5r6owGeh|ݬ̊ǞW>9@5Cv4,`d˂+S p;c +=&` gL$MmǘՍ]L搙 #@ "lPJ`@` $q)ZNīF:Y6R@"]bq 0Y ךIM.SDUh#A ._Oxe9*>(Tq{~A# t!i-KXL+zT"B|m $$bQLȮ.؀0KY?,)5W7IUGk<O/譂d 5 B=">Pe$A$ &邼RKI1|-ulUFs5$=) ޭGm M!JDo]**<0@BeP1 DIsRҲӃIn¼B)Bv)@SQqXN1/ Hgs_* .'X8JƎ,?1` B*_/*ݳ(] r* hfe}+h@2Ys3PHimC%="yxrZӢbvZv2'<]d)Qy)!8îM?/d䔴31 a j,&P: ĐdfQZ@C[=FMkǠ - hoM-Hʬ=\&xFMr\#iO4?ܲ FPDqX aAAkp=z~Ү_,>mg{'7Jis[{tb+*p!Nc&6KcQBiml`sYYPcBQ FuI':L>QAU$"*ãV?r<3 pnfe:/1"l`0e*f:g[b 'sO"dy8ZaLB8z a,1-ĉXӱ09kS0 iNIl# mUmGK3!]"Ç`B@$D 2@NhğOnP3Wm¬L?4n$Vuiԡ;P VhK#lJ%v)͉C<Q@N):wDyz_  R+6o )r@a 5@0^ D4RiEm,;B :lQ,9?X4TMuRO2ZlArm̂[ /#26,z!OJNG"!US3ɖZ` ,0Zd_FYp;"m<)tSeL$m n< xY+6foEayBag邮y ADI Δ. ]$`"{b(C$\^\u7o{ִ~DGSxZPeV`'!UIRg$Bub >ASn(&ԙM +*uE+$vT.* D=hEb8Nv5(A;ɎQ G=ߥ9*Ҁh ګ!^Ve]Bf^Dfq)e9b (CXliY!.0bdۃUXc Aī<=J a $M!n"_8&,FAZ`Rda`MB@5Qv y &uabj+8)<ߛjlpHjd[Q;.23 b%ݶDF%Q`KD!JZx}& t.aܡDhJ%a T/e%kIڒZUiN-5Xek_e*v+3Q24 +a,4V@'M<={+5g/jMRfBԫń|\ ph ţ6EJUւͤ\.zZd҈5W *=L <{WwFےp4ac<f&"+,QRO;hi?4sUd yUV,pEMcj?rQ1 4iA^ac" _6H 7qb2P$:O'dnh覎Qs&JYd\&VxHAAZ"bP%hąF2(٦+<ZݍlWfy>bMB;qO! u%TqD*y ^wK_>p:ZJnՍ_U4]~6/IG4iX SY`DQ])F8V$L%Hiwd$Q>t`jE_B @ .QV fM, "l-]U0dȊF;/+pCdm="tYVlxA(lvo; %ixl\'j4{}~DkC͈EO 嵴EH\,ڽ !.cQHMχc#9-쌈HaumrJfM5%=/t|9dyVB @,F7Qh2f}yҺW]%dUɞ0־fS@W"XښjTk'۳a 0Վhؼ\4:]]3GbL9C $,q7Hh-BFЪhI##y/ewKl  8 *sdHXSAtH&:9d"o}ыp(A8Ȓ2I9ςR4ixȯТ.*Hm/$j Udo,Y @2!;=& eL$q F& YZ-n*UeAesN7dL#IL،y 0%+;pyʸVQ.֬زSn")%?$A?7CF2Mّ,f} nhoD@NLq!J!P~[dSgIFC`]20pۨB2jӁU֗[׮2JxҏrF^];`.QbVڙOp&x!r@aQP&N9mfF3'>T#ZCg`E ג .dr5Yk)r*+;!q^0pʆp6uaW{3ߖfȎv*/,+Vq_Sa'Q}_z+ʒ.ln0\ I v7 ! Q-,xm2 x бV !-CРȌ 4nRISuX1QS@- +=ڕ޼uxaȖ\ƽU#-en7o=6~+h䦢jDjl˳#Ck(& G4'0a+Ьz}vh(~[dPX/Br59" %er҈,$Xyc&~E zTZ TH?a lW0:b8v >e#j$TNJad'77)cS 9}a_vrO'`ZcOkBѼF*6_ yZ1? $D,TzzBmelɗ̱ lǴؽs)#8wjoVޭqTN3ث)P^xQ]iIntH%L Q!U`$ @S6Ǝh FcMp0Gd ;8/: jwl86zn{ O!dǀBYk C4<1_lҀ -x4@_9>{`zHJ@e<[_gS,`%@?Tӏ敨B: a5gĺ[j?`X S@]iñ.U MK~[J3Hv,ńYZn3%OR@ҮiFoS/qA`ϊsF!*G(>ݿ/|'%| eMvV*H6uNX*U~tЬ& }J@lV>'1#.1bGD~VŶ4^!j,q Pv 8V&k: XБ"Ar$Ul o{UwmȠoad$;Yc J2;cJ=Vi$ȉ,(f :"Tf$UmV#h;%=x Ī}]:beQX @VodOxj4/!m/pF|kE8u#s.-]b8DEk$>k{[ x&a= P$P\ 1 rD 4y0 ޏgEREw䮻V9 UvXu#EaEGiCmHϑgBAjf]"yF z}:f+:fܪ /^w f"Ul;uG/)B+'3 jոc1&dza **uGu˃#"0RlQ u*WiSannr뷴Z$6<<#^1d@@c1d7BAh]N.툷q$F?d u SM`[ LaXhζBO6`e%ϊ2w,:`LdG$zRVTxZN*ś2㓒#!Sd{Js,,@1AO=&u1T=lxhmJfgƿ DH1Y5D,A'K:l?}! M7Og$mp"  ~AZQE#eBM:f|= ʹ=Dzv^![ٯ, -]R[҉EeD5zUoȈ\׌HKL c&m#°QRe?Wo]x7?_B)EՍƢb+Bk2:ݨ< /KP3ƒD=ՀBF<ܘ[̾/tɖ8 $:"LON ,Doߵ4vg[vDΒД(J#'N+{)XBv6GdހZW)4[oa8M[̼k,qtk<R?%]f#A $ d4ݽڋ(DtHe" %P V2+, / w:GpٵăcXf,aTUUW/t۬}¡*}ݝ5n%FW }'7~H$c5~Q>V_ fc6"jKcPS*6g0r +[`V'cRPCK]qgbSfp{ X)sgQ;3mCd0Mc,E0!k=+MQaщkUHdS9UzQ'5g >WCx%f,fے@2,y/֟<|A*\ }^R Гl_ ЫA{Q~t5krE^=р:l\ T7LĄY':0́&aEWfV\UVsXk6 .%4荒V|cJZ3~[Hr_]O ̲{Ysdu4gd܀2MVsD@/+=FMi_$ X$o$B5m%UhiJ`QRbZZ8&! _xlwv<ȳ`1c'l!T8OgU:{ɥTm{5+({H,FwjMPEɥ4$8#\EXw < 8BHZt@;uΑw"v># Mndc~A7?{z =‚~ډ4E (y!{h1Wt+..,,[=ޕYՔ2Sd"{z@7UC'r8_и2( N闭;eaZ ®7[N(ziԲ۸(2=]M;6VoBSlGd+W D21%{/3Ue9+d5 04wȁ~5Hq1{w_K9'ϩIX1#]]?dS.bj]1G.R=riMه POQf,A)Ge0I m,H$iG4^?J_ԨH D2$;( jOL[r;bwr52>=뤻UaB(jυSOH@peG AG nInH*(BʮX{L,+5êdM[,4"3Za `a$ P"鿵>D\ J='|EˉG[(,Ewp| A! $EUkr0W 6;꾝8MQ (Z>'Jn ZTևx4^T] +aK"-xf돚D:ԺRb|=7xv*}\'Z4SHFU1XJFTCUgc8Lʑ&Is^d=`# D0=bj<u#]Ȱ | ו;4XU`ED1(ИsM*XveG($/~del*qD(Ju(ncWWuV+vjCEc}6>ͷwv:wz&y#tP/S 4+fRO{ǂ2AǕ%5@z14U/]msCD88p" uRGzTfFE~NK8rlUk[,.pm&$-6Bxԫ_ӉRgIݒX8qʡ`Aʐu&nglKd*obqdZW r1!o=]__$WNJ.< ̗b_\1ZIJ.e'9KE!8<,H7䈫A⡑0pT*,Rz,d*~C_WaA jʨ.w-8>/M鑠I9:@ZIҌ't9<4,iB7I/ ~P.##@ CTB.X`C1`s깪)/(B7ŝ[*@0r$acx/q;;[7~%J6ӷ>ca.Rt!4HwF7zq4צ7>LMnmjd\Wc 9 %ma_$w@l 0=,o}@o*kEfe~8b~'^F]mɰFDM IUsh 87껭/H G)_G[""vD,2 \ߍXiI-&YljAQh}uqA*Qz/@BZ2LYGnOS?m/0΂!Jwb걀Hi# JxQ$~% 'v@Qtn57UҘ݋hvh1ˬHGGyUR(d(S莅'/,@AؤFidTW[ /<"QY_W@ȋ.5bҁzSʳ`PtBWF?oR>|S.kE !!kP'!6i+=,e?pp`%%?AXQx_f7;V$x$.{1 v#֗9iR1eN׽۟f,bcY@$F\4@/dY ͣXY&~GϾ#p2#}cM߬FOp|;k窢LQZ4P=\ե8bԡ:@2;^,\,x%[b#N%ԦfI Zr#cJrٗdOWk p2"O0&` 7],1lFdC"aHNy@8.-u>V9&Ot?6hi#E 8l,[6XfsqKKI /l _}F\ܲ yrˣe̴6ۋh$"&v=f17Ϫډo|_`kWID[jΉ}b2>GK5()gL,oCߣ*zO$`#bh;J1HsVN6'4I7dhQ\/l i|m)-V2~5r 6IyDZl1٘/nKwd]K0bM0+}Q_ 0gn Mb&I܊ 7өLG*!ęHpS%NIZϭ VA3iemEHoo\`*e#P@$Y)izhҴ_6F@Գkx`%vxPgWZ}r! G%zc::_MgN( x|Ǘg9٣ZsTqTIW(z*EM(\K!Xd5pFʤ8abZ7!9I`-;ă0 vWrvȃ $Ԛ;I F̑EKZ^j 5Ç 4بxĵC+b{*hQC]65՘p "،-kXySF*@|]8fp|6#ݧ104&2$]MȑXAB/ء1{)fH1vq<͞KNҫpa!Nm%KRf,+( fAlZHU&čھ'-98̿ո_p1Iں rCJU0 !H3d>rP rdAR;Z::ת 8ؓT SFͽv5_׺_3PdD`փ -5DV>R9xhd̖hbL73]LhkY0Kq\W6EE9@[u8˲[)V ,‘BRffl $&5)",R9LdaaY:pR(;Ye}MM8&t*X,N,fR0 ;R34M9 'Z6ۖ䈿};&.:\=dJAΤCh5I# 3Q0d_Y*3[/`eD%Xs4ȑ#[=EvfW YR c 6+px)ie4.w jA#o$W%w2Ar*Z6\ Xė[ŀr]R&Na $ gKO߁v > PeA mkC᳹"s /3_2L5IGdF4"ŀ LF` \X1ɻQD0BC KAH4R6*Ot/!l@L7DG(lhPb3ܱs;PsF,XQ f#9#4GdGEY Kr:F $=.Mkψ-, 0fDK@6I]$@1c@(ZIG\Q!clŜ5Y(ɛ$au lXX-n =(d 'YPQ M~ Y|f0N*Xc lbn>Y' INlt,:)L+d\o|wELeֻŹbႊ}zZPű> ( Z1`*i%ǖUT8ic5wq L*m~G0^$Y@b!F_(1}J9jaJ>I`,$LKƦK3}sdTZaCb4᫟ 4mo !H)n⦲aS5<K ~2I:&(z-Bwhi-@ÉDmHG^#zNm!`(hḠ-2?\QrND=d+c&hĦ4I6JY2eG.A (ަFf)rhk(goomm6B4%^OegUs\, Z$ b)`u0H\`25ʋV.BH0KJ$'y9s$*T7Bj3.YSwxYԗDpheF bR!5"C@Rd1RC 4rCB;l=8fÇn LnN@Fbf͝@JJd]Y3J`8$ώI@UЫ/H;lX΢IH쀞F.4\Z4NyikYD])E181CHWLR @cuŚ;[T%^1_!:@J6H9$`; Cb Ϩ btj_,Re*DJ$2RA:F[ajdd>u/ 5Ђ}ܕEW%k4!̅UTZ-.#fT&? 2I/od8;bL hilO `Hv_R,mƒHg eKbhhi\t${~u$[ @3Ҩ7 a6ڝ'sdq9@q4bSͲIL&dSZ5==#W\g,$-gX= ae(!'a3bPQX;dLi l,?p P 6q@#n%J%%2ĥLåMѾfFH,A1?M }v!֪+3EXX>c11+nuHSUٍ&իY5,>/gF%p1TSXE|w4^E J mX*\ 2lia`TD'L#HU./뾧kZF@ДDm'ˊPBpaqaBd@2RܺL @~40ľP!2ۄ49ZE݁ @oX!x@+i\\E]p (PL%hydwUH 3q=OolYԨ(&2u*ɡO{-R%qF;X~Mv6]F8)# y /K9^DDDTa*WrtKoD~0$vgQid=Y 5+ hiǘSӂ=s~}˞fgBM?A& $@8#!yr3 ؀hoXv;÷1j58%QEtC%l#cR/ﯔ1QYWmyߒ+^b[ce̲c-Il Ԧa“ P5mvhq~e>)S;SoSvkLx?d(a Q*Gr9fזbۼ^0'{j=U $[طc  c)䤓C\u~ [9;0SKic)gLVص}zuO92|4bBFASΪՁ~ZQ =-Rh3CTF%0CP9*.F tiɋe"E[LW@oQd{iYfumJ[:PIUPa1?i5& Ab)cщCiM dz3*ZU\4/wS]E }9j $*]UjVSSƇ/d_Zi0C8Q5R{+|:OͲdsM#,="{=dY_l%H#6N2pc]Q.fԱt}F?,D)FRXc*uUJ|AG4\p .ƴb(B 5Z5n hT!0dO`]Y o]bZFSv?,;0i+Rl*& zN"۪nt[ׯd~^"c@&̲+/Pq I<6;D-xßσb|$IZ2iT}&(6kbU[p 0'#mTJ=J?:dQTXcD3⋎ |i0oA ẍT9K%9 `OqH ?}xML((<| 0 Jq@z+|b0lmO*Vw8R#PmT ȍ;J3V C c.DuS'Ob?#*_}`1 {ͺK)&f&]KA!ɞ`2DžC1*6in;hḁci9٨Ujw;mlǑ V%=6P{NTSF{n~=Jt}  ]9@t\2x23GdyS58nb#0`kT3dZ 3>K<ːMmc )o8Ei<4̯/q 7Bؒ۲!Qeа APsJ1] $A]N&ڇvN_3SWcUKf:v[WuFa*Cq")m.DڮnXS'kE]g닲vg D Aph!cݥCNl4Jx2|QI'w)0l]ܦ*QHj`Y7G=wij yEyC&7l~HjcV+upt Ըڳ0Q݃]?X25Qݣ{lYdAs 43̏1iSTo$NP -y"Ed0m)kUm\\MA8FI}-d&f#aNt5wwe~י]E ҶhV9:7Q%BMTLh,s#j,m-`-/^Ք̧f g'xtuh9JylB|]AvR*j2>iQ,ZRTL! 54kFS\DNMƳWǬ-kb@`Ӹ@5hZdaaHwn 1ΐ4ˠI g5Z >e\H Q -idBX 4:\a4gǤ-x(UmAqwe-\%fGg6kPZ˽ 8ˇ],,+tt;U=~NhDr)i01,t@@LIUAhTBxhtk/x?˃0dcf(, oNgO  ,UX޳pZ"'8"$ԤMFˮZY2gR޴Y`1q8y.j9ܝ# c7zxniUQF 8Z9AVSD#=(i2 #VHB+ 3N#&B*5cݍ ztA@#20da,e$k 0x kDNi2t>(IoֿGQ6J, ЎF/R$a¶|ab @̒/EzvzfUVa\/1*!,fĠ.6~}&ۿR?.@4'P: =-Bq (ꅗͭJijco.vJ5툫ܛ3'c&لZdIV3noZC-P0'&+TL$hN q]@AڢeBKAڔӾj&`*pwvW~c` I^l#((yԊIžCtw} VdZIAA- 0gryC)NDݣ;׻E ~D8. T0 1L4`t|P.HKR1!w8g^_j*itL{R(8"a` J$N` @ @yB~H%̴#|^+7K#SDbs=p(ph(9FY y!#Q#L[ŀ Z*{3W!3&k 87r٦9*Suʗ"W2),[;7NH l'j/8(E J, M2G5Le "6Ynyad@n) ĉiǘnҫ,*n>-]McTUE1Ca,rG Ӈuj{!jzV?u^Pr P%CQQ%? HRtUQKF7k8:f/Pd,ńPR B̳nE|k׻3JsKHÃGaC42"!"0(9"BEP0 !q{MF gϵ*SdD"$;ț*-Nd, +cn3k0~BtTo2-{$,ca807@-cFd.}:oDd!dЀFY`7&56>6P[#$#fnDR\q V̈́FRrxpp(Q"`*`V6Ɠ[_^^\Y;zy;* ySzQUGBb }R`25%YȊv`)=yףM05ď-w*,tD?Ec̟XĄшMP|2 dr{q/dˀNi3!dSG+7С8lrB@RA"O#t@\b%jpDdz0ˀ;= ]9QDB7#`#j$Ƴ3.&(y46xK4]NwTq)2pV:8Gϔ@dNEy2:E-4 e-`@oɍ&iKfY̔(56~ќ lԲ*ȵ:^. 1 pp6 VUR(:V모2R,` HSmX <. <n1(_K,&&jCdU1fkGIBiY׻ŷs)TQ8฿N*k;kd@2rD&Ƞ<5%Rh㻜GbjIߍпi*ZFs@4 lD C!!b p/e9k>p`I9%l{P0st) i (,RD6LwuvO$# @i2,iZV1 4" G8UU =O6TVD}ʲUzUe  I"Q 9Y%!U!U nDDWtmmux{d;Y)28c aJ e% ,č`QrU+)4P 7 v:(WgŇz\xCi/h1q J$ K c)'DK V5Ja+Or(.dcLyPsi xT7ΰx͗Ubf.)+5e\"v>z6ZG~H.dzmbw>vC+I!@jQ) % /CKŋ rhIu0=BoҐpE6c-6X$N; 6Zn2|D=js2Q5r9!;/o (^0HXOGX T ؒ3]dPW,E GB>=cfa̼ q|4nB*,]< U|6ք5Mu]V>#V~i8j1jǍKLH9 ژɟ^$2 ;_f4#Q^Bu  ɚ3Jm)x#4ԕN'b 0U+aDiPz5W]Hqb޺솒jRRx )ki2=1c=d}L ƤqHN 1כn&6vӁ¯Q|iqF:(E:X&yC_: n}Imnlؘ҇(I̪ ѕVg톊pxtu@e]eU03dxQV#,F;.=%Ym]̤yH H‚D慜U% &*{#5Q` 1Ys}&<+؆CX]cxE5F"\{OM*Or4hѦLsorCw$\Dй踣&-%m*d/$T[ =lͭ%բ3:o4Pd$βϓLQMP/tF&ih42ux@0 U^+4芍>WUҍ[bFbҘ x%\몚SHcK܂_(X+;^}hs}3jA{͝1EgJ1VdasI0@^>N5U0{,,z\]bP!w{783G9p"6dS#(̪ʯE#B) )픰ZWT0dXƟS9OY -  k EX,$yHi2- cЉ0zvL]3M1sb4#)CIB+WKױfA*{[/tL 5D%@tzSE JxndtP˖݋L"34@ʪ@%q1␃R4/jUʾKEG|&21z*4wXʪ"XНΏs9)LF"7z6MK @pN0\ QMr@BG\1Cuċ%H>Gl}:#ջ߽_W&6a!j/PÔ* 7rL  Yju1UoGw }ko⦲xJdU?<?",1KG/3Ve:3)Ad,q*GrdLR&aN="9YgM.t$UT$m76up8& 5=sUA=ZQ"4=xTyezz>B4щZL$ I4(ܲ$$@r8EV;VL`Qnw.STT>]d9Cä4)RIjvf[ /,dfKN 돭"}Y\:!;AV5PQ>- .ղD?ݷzOUԊwNO/bRȠY$\"SSF~gSìQ"J2loyXIв()# Ss a!.NdFZ/Zc9\fud$]\ 2`< aB,8L %=# Sܗn]=8L ~:e92I֦; fA%H2g|ktߤ?gW*0F@ {hqIXHJE,~!(O/ACJy}~TQ2A@P.IdM-Y3C]=k<m40jE= T:!Z A%>TC6*g+9CZh'.R8XP@ 5l6rΌ0 W&PU0SG0Gr"Hpf+JTac‚0),j]>F’jf0#G5j5P م6s''3ޡÔ0e;a%_{>vפBH*y4|'BR(›++?kzBC?R@Hj\\3KLAZ1 ^w Mq h?$-@m.]3@D3%ZwN eX\8" Pd`c0PDKk0eKDel0K !9^r %*#֩b@hXH`Uoܯj̀Se$*8Il 3anb}YnIT I)$xL:@ )F98sBPaCVs@SU$Qizy;##[lF>]*":#6,_oE\X@X\EhȮ d(? ²qrKJJf  ;Joplem!J]D#t؈XPX&44wꠉQe|&+1pˆP޳*g;W3);hʂAц"roƭPCdY`?C0•K qI . >@/T&.zܪ"%罄sIꝺe= xʸlLKOS5uI9-!#4SQвa|vpʋw(-p$ e7%Hgr((/߿Ѕ&)oatHjO1Ba f} "o5  -3e;O;_r>5.@NxW<šOO ֹk"1)jCf8z7gϫ192H ᴆDG/Yb (*Z05e5`[*tsd2*Al18 tg $pplj˱}zSTbypp80&8I*>V 9V(&z#h'>F[VRuxx@  Fߠ4`@*-$2ibS@p3җՁcP\؞ċJMU)5T0򧒎n^'}p:4!1} |Hlif^p,cD{kݕ'HJ6 &9uHD$Yk9$ϩN802U݋*ų]d`spPxv&5!+nS` `CEdŀ+\>+0… g,1 l`h" Bhld]#c#,/$U#Πp:h2wp$9 :(,@T@o!xag9c5,35\Hf P?TP]+D;~٦Jp|~wBap%&arۢM]B7/0Ÿ,qD1R␢A@aHQ^C zFV*,-̚,uj)[HV?Dξ|]"- j ^A_F>X?ב$p@xSBX$R'@I+ xdwj2n]l/Cd]i3?;I-#, He$M"p8RG[A!Vk Bp5@n ԙ `Ea%5LA*`Ӓ[YLRD.|0EG&t՟t)^ +赈ƃ­dR#$,pB 2 ʀDJRƩ=/ yO{|q#\=0ոSQ:B6;x7 pCL)D8L7GcP/Dm$M+F&$8݄.3WBhF+;խxs8aSV^ M*c*pe0oH9dP5YK bDì =#)kO*-mlkKݠXج2ƴH%5eF/ub$Dy1xm@PIх`ӖQēj@ A|hEIg);˃[a ~X>D~wR6Ω懚2CegM溢Ln֎ :e<Ժ=q{y@zպ;\*oL@ ҘiksٯA F O&nT .S] $,3 8\hӰ'^׮>C>8'% -xEhx/11s0PDcE;3rgX)9SXTF0Q,` 1Gp–* 6CKQ%ISQeAZ IjoA+#Bd[0"IeK^0#\ 0z&.s/QAi H1:$0uݼG@7)^{sp]MkiM0&9AW㔙s{ (X-W(e bТ'oے[VnO#r (ծ6o "8cnΗ+2%=yIu{0S]t 2^va^ ɰes!tba0&j]p-"_X.HU=u֑W $bEZ}(_qPekvZ] vP8ΜfF.*IUjb8V5 iuKQk ~d. 33D4a -h!œ4S' A@$^hPd4%(ICDY˔,9y/##;*p"ޑB'M_`2, LWJCjo r;X&/>o n2*$rd0X)3C{9="Ze%HވǘiE Z)2o>B ap+ ]?aD(E5q8"P:r$01ap`y&BXAl[L y27xi.(=hNSj54884\|A܅!,ba!f>"rjLG a/J<0Y|9LNP6˚] br3'8T')PԺFH@@]׫axJ4`#4X:)Ēͨ(w?8_C3_hgHzjJvς5& YNHP~j&Ӓ&\`AŐc~'lF[/igwO_[Ά*9 s%2rd׀]Zk 3"7A{ iGrÑ.0\g%-,Ф[Hh9^=S',É>2 @+YoYAQ`Dl@& ]1@}'\'gP6ʏg*8'odgI/sG e)Drʍ}N" 2!x@rʆYqkO>8aEhav`ʍY'`Ԕߗg'ʭCf8ј!N)ȵiJl9ž{'TNbjSJJAbb$?'2fhZz܆⇶_xXdC_~`bJn #RBRq dܪp9B ѓ1)DK+uwxgΣy}rm$q2ï*0uƉO(} 10[,H)xb/g"d [QRP)=#j go , ْjV4C1F-c&1)\=` rw2 @;O0m8OJS@4S!rW:+wp82`Zz<"x#cxZؔ)=q@Tid|}ByW4BS0$8$Y w <%.zEj 4:pAqRPG(C6~fmQ_4(Pݝ7pi:fdN( #0<>XA|d9EQD/Pd9i2Gȋ$Pi ?߇ӡЩ:I卸JÀHxZ}o d2Z@B+@*KaĸujMV"# M(P(o޴W#"ZVSƮPRz}Y(oTBh٩jBnJ^]影L> 5Ki;5,I~; G'F \#]V>$xOa ZT/`%hB T_*F0X#6־Ƥɥ=4Qéo@łAqAAGF"Rk}zK*-Egffidi&nڲlH*QQQ>[KϷd3,X 1`G 90v De,0Kl 9_);W^&b:ZbCC\l̪8T<sI4M*$Wa{Sru@<,.i@Zl$sseĖƠiJݖ|Z*ܻRN8R:fiV":se*0rKfVMPW@ >9Vy-Z# @x -+(O_ٍ( ޞ*u. 8dQ8maE.n*ۘ^!0/)_^LQNUGƎEj(Tu1[lM-ԡ^'9p$-:r 톺O K ^LG靈CJU š Fd%#:qdw6 rGC=/ e0i7$4Ȯ28c REu^uYsq #>@_ĒY)tH4>eBXL r #8 n 4 1SH.B%V/<3<,dC2Z$;ZGM6XcNc0Vq$(QP\\rW=ZBCd̀Ka=B xck3 #˝[V)/h: R~skݺ )7IvMnq7N I 2)Ȇ!ѿS#|BAp\鄽EXPi?!FMLEhkJD$1Ktnm%:$d4ݟYCzOYVFGtGXM]a#ڍ(u@8 " ]Zӑ HHBULi<+z\DC"eU,/YNHSLE0P=}blc1Wә5V:5E[ۘaS 煹gw% 492d̀.<{ 2J=Fcik- jx(AUJ/8ҏ:?>Vs8ƥ:/zPGig8 e)ЀCJ#)H)OA4rP'@F0YqF%8i)KpE~]j2Sڽ(4ѨۻlEufb]T0#+},NKk|CWCM- C!@0 h513/$Rĥ\gBxwopE{Rk"T#N~p<)$05œ1GXCVt\L۴[WKd͂IVW{,bBJ{o 9aD_`d , 5$@"b52 7 Wه:4܂8 1K؉bb##{$alF1WBdm;4?"K^a8q_Y W,xXʊLE+"aWGg8&Qa\X=6Hpָ;;V^tNgBL,bm zrfUmsi*DTڕ?bJx3sF0) t8NG!q8o) (ƈGnTm*E3@KRh>ENǗ?/aYJ9%v{rtSTUd[Xs :~=J-ya<މ9Fw!,a" s))ÝZPP mYC<<)bq5EUR2cjY#.;H)(%tԍz eF\,;M zš1#M]t CSRj减m7ga%͂䦮 L2hV5JJՏ6sl^>zѯ0Ұ0/-F#Pd\4e~r7.89Vf }Z=/ծY ,Hꉹw n*XepzH"w+5>傑7$O|,dWsrK|a"0a̼mXg=ܛӿ?? (v&V;v&QUzH\-UlPj QJĀ JXPS ͏ɿl 4U6ere6cL9GN@ ^ٕ\Jکa߈&D#"!B1ɱj=4ŻOK@}|,3smCd#9(V_t;}_םB; "v %j|L PZ Q t;+%`HuJ4Á / ±X~Q@ջ!``@./ OV(77B|/a Y̰/~hzΑyC ּՅYn= nCG9xTb\o6@}ܣ[.u?E/a(] <#rvb(K%PM*$.FDIhO Jv-hAnځ!(v 3 c  &R``nKR.f"_{P郎v]- B?ݤq GF7yy+Zx^߈} DY^Q79p@DgOɩBEN"R8͠3^ AáKզdd_V/FA#=#f a< -9x&ܗ{:nLM$W* 졠ۆ["JŖ~_91CmLYܭEa: Pu$n`?5~r迶H\PĉV+zuZ-sWۡɨ`6&:{ZAekɪgp~_=JIiAp@D+s ,CU{W؂ -"bS]xVq*W#mCJE.ffTX" B )*]~;dPZ iF ؄QIdQ"  9">E, qkS mp/!/ԗf7bWmu)+ٞ]-n_ͯO HmrCmNh1zhjwC A?)id+Sk(kZ9jo Lͳ.w|[#  rmf ;Fn*P'X2vC/1f8 lk$r Kv nzIR%>\=?f$[ .KC8A- 3;ejEF(M( , %@jZvM&+%K}B_; NY/gG *d (OHoW+&#gdJgY뉍~,_Z\et@BAHr\Qc`"\zh 4;#k=KBkafto,ka?q[5ޛz%*/iDRT3ڀ5 ;'. .DAJD䍨-D(d;hO ;_G Vݼ/d!^i3A=' qn na]I5G&-OLOS N@z.H7޶ihVE.uou88Mh0 y3-PHI*2!g}RGi>"H A7=%GqKQr\ 7uoZHc4I;յv[Roe|(eO-\y"MY$`jHg)69EF]TBEx ~dTWS$*MF}YV"r!cؐ-&t AJҥA`ňE}폼_nRޅQP)KxRC'ՂCRz(iB!4"-P Fa|5ɉ{Xdʀ0yLmP$U0jZCbBCR 'OZt<~pT`wI98-scbʱ]N/!,F0HSUcf'vU?lyҎQfWϵ/}W.\[c'Rd"Ť,4P2"q]DN.Kj[9@ hp"D-nPbiV>_vLvT3Uem\],"h[XȉU.ndcXWk,R>O= [= @Ɇxǰݷ껥5}.x"kO3I $~$:R(SAfׇEC'C@8mhŨ&Z5Kz{)о,[HDIJF' ᒔ%&_KGg'b@ϞDRY a1u77 +մ]_"5:(h zyU |/w+ݽb+!rޮr;\|;?9U_gn>5wx3*;6iI5Z\pS1=7 QVGdX.Uaʻ̼Jkb.`s9O- G0c< ]z@g$)?yft;D[ꚉ_QȆ4@Uk>&ψQ @8~V5h8᪪K0 m̳P}6(R{m-[L*)*n1~&!pY\wH#hP0vdzT@,XsG[`kp 1b *}d1$H$% M[Bd { VN=#T _c$k[) UR# RFÜS$w% 83d@HQa/>[[ŋ ' F[`@ Y@;A/`׽[A uoiH%R25n"q`(󧂧d ?X Z-T(Qt䲆%&L!@`OiNjUƥHG,Yo#:DUchu9<*%?itUU>G(B0|s,a}6qX[(\:rw["e#Nz}/ >(y5X5cNn)^1h=.y}S6Mc2"+te^ wMf4RA5q})1,>^+f&H'<ѝ! Ş  u% l]_u3ȜZVQM)IbNf_ Ah 'pZP›$ 6GDB.BbEME@Ɨ8_jgM L Pʄ DRdT@I&(թQB''EfN߿_mmGge7w'8cK L FT٩U}`j*J")5Qw0΂ӠHݺ(A@I&O8 HLvD/J0U/I:0b 5[$++Xw1IU?J~$Xga+Ы.}>ΔkߥAr="W%Zdp&uch&0BР(  ͙ n<EQPE:_$@ \ Q1E*i5<swxM'~=X]J[ m?4#_/UX52A Q܉ˋC7RFkĢ|>" ..=Bp <..=`1?P'$IE@"[C Vt6.hBа~*`ayU0B0E q:dG ZyHb{-# |eŀm8`Xa"o)t @qbrlRfIs dLʊt$c F9((TJ$q嘞 ="ҵةڷm>( jqncr@R-\(0⠰c-1! 5߭ +=O+[_5J'd R v4 .9_lbܖ2|uiPJ+15UF @XPh6 BP 0Jb ަֹ3#e @!o6 @IhkPT6 pG/`f %\@$l5HRc'dV%Y;AKO< iS@ -4_6_Y#E B;sS _$.:WovJ%*A#Qx]+?z#ZoO2a " AjL\% 63@yBkBoIğ}*FS IPbK$A!1b,7LO~_Θf` ϾA9TbF`%$ݍxg$̖T_F;S(ř#,4h ;_SvoIxj%Daņ-@I eFb3FP˰nCn)ˑ*+p'SZKjlB͜utʛSi"$dnUX:o<& aiS0ǰft$ؕԙHH(# ϔ86b(Yp:f.U Re$- +eVY$pu'}a>iɶixx05k"#rc(R*zSဋ*Rr'eXb\"" Һ0YNXD"Aj Qc+`ZP}Qg8V,aZKkʀit KPnؠZ2CY_< k\%|wȜFE ኜ(,2ؚMtڕmmts5,,iRPm׳Zd0BKd20:+<(@ uanm| ؋XhBZ,鑯坞?AP= [i%rM(IѝXzJ܉]Rjg#؛H*j1%dcpgu?w{th`B1 ( dFCس U&N`+ޚ'TH`J)jtD0L(,_R[;Uux'w4޲w:Y d4PDb!m?wKEd( O[`y,#޺oY\8=pђQY-7R,} X! 'YPGRړ huhd6Vc<=)& M] M 0Jʙ6J<⡰ƻ'H>WJ/ 9@ЈxhRxQcͨ)f$##<Ӡe":぀#(4"K:p*J^RA!/$3 z6FYԈD&Q6G;iCX V$4*#Rn `0:&1m @J)e :1ىvz5 RwԿ,41 `"w% &% AYHZEB If^9& WEXV!~ddʇj]q`@\d{ 2;J1 lgWjrw!Mۃ_؜Y:-Yǜ{%j%W ign?xԕcKe;%Y_),KUqܪ1Ib7ww:z rtkչ75˒YN{3~̾ݹ%RS2!;XW2`CI6$[" 4&@[*G24,;dҪ{NK-'lI)jpU$j.({ܵJY1va{1ZK5|=4:^yb+{g-w=2Xekyb)o)8/r謨d{]ng̋`Qo -0f`0 Dɹ ln uѬ[)h_nwhgR;X* E?};2n-YXloz@+y5bHMj״5L1Iԅ_6S2Hj`'əbW, ƊfE Hw/\}eƂ.'pva˰.X"t\S$bVYX\;a.)h6<7Ay]!z߽7E{QWG]k+ 1 ARN*QJE옞ׄ' "ZmH *j=m=' jʯB!ȉ R!&Q "/`!x"B(FQI]aZE1Lj^ܣmyz~6Z{]gWU >d/zGB2?Ê< $WmI!j  deu=hF@԰uS @7^&uE ӵOthymvgF83wKsX$0@!CM FLaw(pQ( ?!?YAEh`=NS/z ¢4AAAvϵBoNEWD8 C/dX6LB@aj'+t&AwUecXk B@svw  2 '3c}WJ}wwxg c,WMc{}<~]#(?@P4 d- UF#/{^ : ;[#-[3hrع+"gU^,'[-4c?o 8,)3DyMR sôxl)_$bW^4rw$UNC8'y5W!IiT})yd Z<`6~ ]cǰȀ+Ĉ 0j* Mb ]I~c9s3GlyzپతCjAd"}qu;gkv5C3?#nԍr;U-U+5/ W?#VA))[LGה L܎?2 $|#u.nfܰ$.kDޅ#D Gf>AIE $gdw5ۑ`QfkQ3S#uɣ?/QU [n/ _'9夙UB-_d5?Vq;+a9SM@ٕ*8 إ`ͨ͞,DP6E1~0۔)lƩ`E?xH–u0@"" SOc_ƥ&6$Yk(mU̞%Ř[Fh{Qt}tQTC A"%A iwbFE !@4y@J =ɍW|sDl>(GcRuZ7ˮ$ AsBc.N &Tԣb! u%UL2U`ԱJJMPJ6pgY,eؙG1zJroٖ20c Y @ x!)e-̉2:BąB!W1`!-rbAgm ,M%a&rGn jdv+T= G#JwWY,k0$Ȕ'5HEHNl_qTymR51 dI Wmw)NW*Sa).X~)2ʥJ"LXZ;{-د~ij-o5El4QR0b0+D|RNUlQ W A|]৶ic粕T(í&tb_Ge,Owr=}FF'̙r[֙wsEyNMm5՝;=צ\{B8\Tqm i6VHQ 9+G4IJp P"O5'/LH#fdZP^=`D|}ǔlqy30RSp_#8oo?-}˝n4ISݷ([;BUQ{s?7 ;J[L D@0o)$8@ ,!e-fW))8Pye3jljL !-8c$NeLX^cW7I8ٛz ثDHcfu1sdzCᑿXp <5 L]`USboV* 1;\TGC)SC$]grjaa " 9Bb_ &P BHz^Tꏫjhנ1)8 wVQmd"y49;a"J sompg\ %v j]6!>N. 4qZT˟TD&`z-!76n(:COOY s< ӥ$  a 藎Cx=:A,j[=ygk.*hhP-::X9i5U6¢!LӃ#GkRp"X`a T oPg Di,j?OٙT9KmK " Fl1 <{!E#` ^ߥbݱ Nݿ/d0"#)>lyy[֝DiټU[:(sU+5,vNjYW˛HZ.^~6X8RB$M0^4&Yu\Spۆ %ExyRe2 3=h%MF&4@6$0BF܇aKeJLqzBVkAd]2Z[ G;o1, g<@m0Ǖ(P-ILVPB%-^9H@5A:u Zy/;ӷدX ÔPH*TF˚2WiKpg@k)XZVMn8D@4)ZS4in=65w,JU*v{zmIVYz̺*nȕtB!PU3SM:XI9H@2q(`p07 bxZ/ 6og?u\$S H@DH*Rx B'h(߹GW 6 cUp></dm&~9jXs2UٓD A *\#l7."cp,"ϕak OԨv'{c*BhW'asV{KG*/hcvg ЦCkIF5@BbV$409QjxUmὑFܚF' 0r]fo䋦xk@ZŎ gMx< L&=YoA(+O#6y]52kÅ>n^ jL"TLW?Wt"xJa ༼=Rd5fȁ ;?ˆ KMmPܠ-9Q4%-hY"(\P(V8qjFFDmnV6p<Պ ڿ&֭gAqE!V,AĴ\Rk$@ʮT(2i6iKDaX%zzvy% 8EW" ԄJr]orjVW\)׊1*%~56Lѝ=1;kXdR#y{B+BXdBCIc1M ZfWXK4C=F5a$CAP*!n%r?[tPNdԀZQB@>L0Æ Lub0A ǥuvm1JUあIr:9laX߷!CЙY=f̃EL ǖCnqY#ܾN:ʉ]rR[}0DZ1v & hOKOQCSJ0ʪSkD񜂖p34QVMўPg|PX"&I#jBdDF(DB<<9zD"[@l s Je\+W!1Z4S{/]!ff8"CAabXZK``U-TcA`,H.0}`o@J\qS'$R[d\X *KA{~,LL܁gGIRl` һӄZj$ق3!AMLqzʑIPvlY?]:Ci$H&6wQs5#Urh(z8G;#\i 5TW=K&$ICW*:},,P#*mjr@ mX]ĸu!F )rY͕Q?Ug %FZqKtBM8/nߚw#(@[ϹUXŃ11#1+*Td-ػ | ӝ4HFdFh(&E&O4%r$j%4?kSdX adL=H k!a 춞: {[~߫ 238#j;fsȞ}X6+w;bJ^wL+~c?zYɲK(`A*i5S\*bG%NײT 20$F#a:ɤ}ΎAF#8+҆'X!%JPT$K3+~i2GX8רB:rwLH]F{Ç)Ot7ս=+D/D5 yd U4ڕ0nF97d"F5ߵNz ; :59ph1$ljd6BbڱҒQUL 1 aHLcHA3 J 2ِM4P Q#nGnFΉk`82V XO,uQ@pi|v2QJ[p3]dQBZ 200J r[# [ oOH|gb/ *p;bl="> u0c4 2Ie{JJ9p& R^$g[I$0<$=aÀR\dy `b?S<NM Xt_7bw@B _x eıB 2mr7CI+WQ 4!z-7 o-J,%|Urt 2ܭ;dTaAC<Ȓ qi@ -h(󙢤3QO!a@q~ b#Y0Ώ3IEwƂ I .j:9l}xYȡ IBA "#Âjʾs+͎xY UvtyrkY,,eto TK|M74/kJ]KISf̿<_2.<ҝ11"H 4gްuYܗIJ డ"hwB*s*zg>I+~W@`dS$hD3U F?2,TBc`hQ$=!.Tm3)3G8WC`-Ũ5@߀4%Aɰ6ô}i,S Qk1PrQ}P>Ңa4< r 0LmURPGye)-E&YxFq>KL~WJ :",EܺG9?:?NP$$e3bbjR Gd'i`8#I$C 4{k Ņ07D,#2HLcy \(M[T 8ks!eQCwes1={ALw>qk;\YŃV+\C8h D}*<q%.m=aHW J= (@c4P,UZDէmO}YP ψ,!P[3$dg3 9 V>]qs!S ;0 8Rظ [H9S] F\ 6Ha(LjeG:)r5K8}6u(P2 ׊rӯOդB섄?dÀ[i1#;K=#X kc =# n(􍚊.N*BK./uӓrVKbFX"'e}4%]H5@L`kBJ!g #maQ%cDLXbP ;Q'a쮟,,NE@0T`!yg V"x@\9 : Hǃ3#ǚ_2kYPP~XE}KI2iH ۟owzTȬjdJLS PN"ʲ/!I{)qsD8ܙk_gw<ُY3 *6K{뵬IX;%GNgMתC5;#ai}-fdi0D"+\=. ȉii4l6|*SJkMZo4[Otdd2!`7 ,و0G7$IB!eP\Ě4+Ѳazꟕ'XuՕNtOS/eFB`mN=p.)0MQ@crx($b=tV:KU4B2otbA.s: 5$vȮSqDZ?ET JZa Re#j6ᣫ 1uhR*I)d>~UMWzQ,EyP9W G7Mƒ@|$ d6`[iPM}<"Ksg` _'*'0]ac` `C5-XE D ,z(pbc0dYU5 5)%Z=h!9A19FI)ǸeeZfqw66^0J*vVC;@'&D$r8.6cOur-OӗעNYLS.8%`lh(H,hZT/FӁ +X!ʨa,V- Wl'xl/8^i_.f(c^_n&e -&a$䰝)l3J"SsV2\" (@ r%Fse$)) d"[iRXb[<3 $ `=8- z|\*Ec`Br,5d>Wu.&ǩcMKz"C@n,*A"#2SEºZm;j+aH_ֆh._JM$zsZpׯSrz7?:mݟF+.fh7f'diKJ>q`ZM:.$0jJ]R@әt& RH|]CQͩ _$$Sm̄2(:#h'YȀ]0 #%$8|+.$B5v@w77vw@UW4}4WDa$Dpd!Yc 2V=X UacL0OA* /p|E֔,@p쀙 "VQ*t=*՜pE $IBs_Z*m;D<5c4!*㈺qB1&U\kk<7*TҨ\NEBjaC-l{+ǟJ!n ʿߧo9s %/^0hCtZffw\H 荰tb T -j{nJõ"ǀCDGXԀI%dx O|4 Úqo^If23S&rܷқV`ӏԻw .qDT<@u?bDJ%^\pOl$3X!eRd1O"K 5oqR/t 6Rv@c [")vDbz[02eDm4aakWqQ5:!6+X& !;I`̛mǀ ) .aN]󸱪 _M4!$(hf8ܓ:^ZˎL#!LDqr~UBE :!?ѣ"Pcq,$D" wc'is <*%*FD.Td2N d3.ZQfK tboQ d% e"9/V}8&q8y9l*yAD<7׶yْe~@Mq=6 ?*$Hq_# \r6Pva]786U ֧.o&T̋&N}fTJ|,\T&?N/_xfp1  *M+U=CՅr|1b-Scl@eC,3Wk}cJGRJA ZЂa $'L`THB6H LM/ ˽4F3ld #<1Hiu?sb0B#NʠޖfqKOhU %Wh{ޱU\w$ߦ e|z5j2BRi~{H+js?9޵գnإ}|][˒D6RII$e&QBx\lQ*kN[Xv]>8Jc 1pYy3ad^ngk<XAoul{0*BaTd99̅E2ӧ!ؖkV@VJoW[oXaXhx狘{Y)q+ y6m{ͪ⺧edEJ08u/SFzo1Jo4)F0VS#Z޷=$ӂ'Gr#vb̙ ,K$ a R0gzv*OVbpj0*L$ [<ץ5+d!4YGa:»| me$p&9Zg5{k_{◦" NY* 0 U0% Š(f`a)xB- ?LrߏA3Q@)p:K6@s,hʵLR2-* T[Vh yP.Bgq G|B$I87w53c:Zڃ7iJbC)*3/*t(T +%[Z|%c_ɷL1-!U7wo4% ={-{SRсr}x;/%'Cpyl f-d9)43|0brNeSl0.& +!4ݔ\O昋(@I2 $6 ]E9}Q }4Y5`-[Bˆp òYhsq{E  PNnvw]Y&d+Nm1f2,ܴ-JbH&0'NdHT@,r#N,X^thˏH, \!LZ@(#? oh`*lKİ33+,]-{{{{O/o} +Zu 12x7u\UA0.(uWlKpXC}>q5dU-YK IDKL=V c,r`!=S%0}!K^rO<N\ɪvcA/??V~M)P!)8]SU4"VxBn 0CAK(#bcH*PqH{'y4x8 bp].,h^75^ͯHg+bC'4FHxұFNo!ڀ P$ZuDRjm؞; =f9:Za{SyĈ Lv]jRʥ*qzȂ8Lh:'{ 3sY.W]֖Ks)_Pf7@,"94u^uf`,S9B?d 5'Y32R@C= Di,p p Bm:-ҋ^v$L񃛢21_m k|}B?#7^xwg-;4Q0 @ Ԡf#.v4TDN5 a B%ZIg\ 1DzФ paƸakkSyɸ3ZnV2ɝɷVvzI4ga%1k价JorяWQ6X.C`BLs, PaCx!DP)nWglszcp͙;FK5 J< MG&U ^z̵FZYvrdUV ]&\Jd9YK ,;"I=i: e,,0ii/1?l2dcl|Zo[Su[lJA@T: /]vH/$B a"?c2x`ꪲ9'g2[^NGLL S^xUb,%&H︊ X~QUMݴ@JLGֈ$-،;5\6;A(qdi<+bXQ .jdF Y:A0*bV3$ DpPbwyk41%8x}RR0( b!4F%HYz?L$pqI(LTŌ~RSx/0lr+)\~D~w>fsHdK_d'=axC{L=J egKp۽?~C6b?&8jnpu QvԧnLW 1>qw G'1o9r8{ (D2St3%֍nqZ5rm0 |cA;ZWM*tG?s>ꗠVt$&ȳA Ct.8غҰ[{?fR!T9P -7ve %D u9 JȎD!i]{{8.1'oaQf9(7⩲㼲;2bKLHߨ*x[ Pǒ:d:j2JK`u oiA% mH1 !߁$.za^:+x'h]VMdY6n &ӈY&N#]:=yp9,  hE:\;DϽA8@}A)yqX:S6)}s;#cd^ B: Av|^Bb;`i(IeIpdU&Pal8˳N#e`6^Ցԑ]h|W'܅[M_k_TR0A0#n;6[ÕfKڭ)Vx t޵}ZQQaŨǬ(ChJR" v =K dQ*[iAC1"tȃk$orW6AUw%X 0gKw M}9*J?GA,!js՛GUE¢@ mC! Vwew$!b8 21Z1`tx`魻?U^6U166Yՙ@"ddr 2Jzs>-/XEAw39(:x@;uM$vz怐4ݕnWe?Cn61pi'.S$OwV*IJ1"H[-fSa-Ȥe0M1iCIx dm5 p9#=fj |`= -0‹^& ҍ6 X4`U @S(tς QGʧwOwHi<24e_ABA}P\*EEX qBFdGAv@]f=N oe(#7}6"<9T*kL9néA]p}u6"`9RK袻~.ЄRڝ  pJB[6VpiÑAS JݳkKy> Z,7_3RBn韫;Dl&Qun ~פdւ#YW+IdC#K-=L a,$O lhKY{EIJi>gGsQ׵Fa1PU+؇&#|%#WE T3hU)[VjL =A|?n - PHdXi1iSKn~ K'H*x@ty.{Mdۗ WnP$4rR8 $ lF]{iU"9ay{ t Җ9L?y(][4*H ;nⴠNQ睁`YlWfOdsq,g#l9FĔګN~edACT]֍A\T:Q"P;O9MC %L!f45^#AS0Z6 HܜnLaSK$ :K`>|z^,on)۪3e0P`>C_t"`a'p(q Q^N\1!7eAPd͂UW R9CM0"MUeL0 dP7ǐ0*OɗuPPsNz?_,FŔv7>0&F:C#g Ax\Dp}y&=NY|rk Ǟeށ#fg}v_>}.}<4XhY x2 |w PP,bd\XcLr>; =#L|_l0SkH<.f"?n\OH\=ҫZ^m{LUn44F1;:ڞ1="fDC Т3dsڜIqW5I%nݤnw0f"U,m\?v*;BEM,ξk쿩QLgY \볔;#`d ~D*4  R u#sH!W hsSڿj7um-hfio=Ad{'y tC)̓6y.q$32m<~-D%dE-SyMh+RFM8RnKRDdXX)D;-)c0RxAWyʃn2!"Kt%fe:OZ- [ZED9 @Fd\uNkzZ\Mb<$w|D?AwMn;Po69,Qkie#䓋%?dq[I_M0@1Ҩ@iɵHod ),d @.} j% 34䝎C7ʎNWΆ!"0>Ig{!Z],D&%eq;LxE' SV!=3'#HydbU`E6q, * 41+P()Rwp젷j$+20Ul:Y] #vǿJT `ks85+B =>,F F1"B%Eb: \y1$Q m M9bg-Կ|>"=5#ovsIq&n/ ~X],}?d\c `=!˝& qm 0 riЪuUP=vf8HfG2 e nq+WJ烐ZH!R2 hh P$HɇuG"yc^p Wi2tkU$>L3anL̂$,p90bPkɈ -ئ)qPLQKN`. 6o^tҀQih_c $*Qܘ48* \`LKq3} 2dLR c;W%EJ>@b1%1;d;&Cb `F?H4rSFHy{N@>uL: vy'=qU/VuH H⃩bUh<#KcG-!ydhE(d8 kfoQj-E[eD5S/&ו n'Px:d33Z 2R@.1& oǘo@ qIc&c BRUK^Ha~l7` .Nio[8"ue~yپd!+R vRÍ9v:koHMjKG(/n5sd@Jl|]udh\`W/~ 9 Q gvSL1qpN>Zɋ= 'BWDky[p5\",wt2GRG%(nsxW[qT NR?kl ظ5y'(by2!4 ,7(F$Hj Wl,:PR@%9P7xJ ,r{lC^-fd ۵{֮Ss)t|mٳ (^(ż^ PMd@B{!cφ0;ZB$-du`Tqa0Q/ E ‰pdݣ AC6* ڣT}PAIMG^KdAk_Id.Rf3`] U,5b4nz6xrQԹ{n򅢖Cʚk=7]DVA2 *%ph&MAS{ޯ[+#=@ʺAhņju`Y #)CW8?cI ba 2Pͅ_E`WEr|D*\dNv70_:!|X΢(9Iz[Zs{6}?ǖ *K[ ް k{Ab8N0Ea@y~s46FJkLek9dHW 9<=DaǰQ@BR'sUE%3Z1 *xǚ(Y uW{( _%B䁱yp>5t 7.es%_/Ga别ڿjk3]6d.*u' Quq!W7v&0)ލn'Ѯ%-v1y2 f,uE{N(.DWrD@T;4Ww22WEJTOF'ePA 2wZ˴Sp(tqSPC2 #VnBK4a'B?mއrYraE@[PjEZfUD $X ums\1-dTXs @4E="I]G[<l ҌIj*+}^!Qz:&D B *& 3L7Ύ* h"#tU]P!AˈeWSdBiIjD w۱}&;Rc`c${a^Rm8r Zf`S+ HF]*Z9 9;3m Y ^'MD@0M^:=d#"UD{=h=}Bӊ`!P^dUZDP{rLi rB}XBd8]-|jHg@{82=?C! ZɔK!BXY:.NbdӀ\_Ys -3 4I'Ik_̼K -8 Pj&H&ee6Z0{rW})g!+f$܉2 D~WG-!HN`\%KΣ<ڵ)pRJAGbYaDY>,oth"7$AqJLɢ,TWh`SR񨎠J.Vu_3Dwc,^a+E=HGQ !JaH$1d[[{@=b;o=bT mgy@!vA1>^_lM%X0)e53G\$[ԲqS$ Y A t5B`p! :5u|d(LQS32 Ei a柩xDB9s(R>w4ΩN~伜ϑťPs_3쯗oULTUxRtflvؽ`;"Ptglz+wWJ-ECLJ+ll=Uϯ*!%Wqp@L<@@2,N g˽hV)@M-L>kko? ;!*dH0D_C1ςH$%*KᄴJM˚dSup玕`kPMJci8r: a .G2͓ՓJWPHAG#S|k%Wy_}/%άGڐnԙPw!;5s\ )KѬ@qmDâQiػI*cf@-ć%Ou'.|ɜ.4 F!w8_gֈ(`5c(?;e_:UfVFuGfUR-J+5d3"5[=#*b0 mp P Dk`J@,FZ$ 0)#miK(:/tX6lj>1S帩( E8#gk)&LܙCk[i´ӓHyHyF59״1鶨!v'z!S#3cEU""M))A@ CqJv4b0eHh L06r ]ydċ(\r""FuR+Ǡ7G{8p1mb 5 aVB>Its#:z ,bZ aPIGF0}0h}O-GMLL$1.`G'm:}dE]Y@:KL=%, )aw .=v1XL5aZ,0rD1#\[Q{M'52bU`a}f/SjJ5O/@q7ښmؖ@@!$Or`@ ÑA;w2/Q&_21@3+ƢbU&5a0@(uCbF3:ζwEޔ% *My6(HPi=\Vl]$N09{},2f"Lo*8MitZ⮳c?y"cP rl0QirC jrZF4c"zAŃ߫Eo7d,Y,0=B{_Ai1[ =݉<1Д٠=$z)Q䯃inߩ[o Q#ɏ@Aז ]*8}P t\LJ@|'Tv>L߾b+<˟r^:ZF0P0]&|G(֋IO36T l]&=%_OJ!Єi}tBH`>(\uSQDSh4/d\W5k=,N a,<.%2=00A!Y0?p#r/  v{#keKrP?42]']tM \PG(QQOR;'#L@YC fwo.@:`k] e}Dz6d\뽬$b+;k=KmbQ*&-gC_Bd?PLFmݝmTOJIth6+%lc X`fA*ȥV>@Վ_/ p+y_ rUjʌmȬ|i0 dfWYI<"k=xa  4 )$-$wh*]`lB?)t$X:+]X5:,J$ᖂl;o3L±W&E()D4h`[FS5,zpBO#.eQ]θ ϧ,H&1D|~ỏ\<]];3^=f6w 4P@ Bb@ ';q9.YUR],m?zU; ALFKh5Gʹ@68!k*j# KZڬmS@f,bsЎuP:] O\(+Ko=H˙St_)r2OPҕddZYc @+ Q)-D퉍<O ɂqL PDx `v'2(܄(d&92Sg5S Hqd*䂜`A(YDAI6T]R4=m'Xq̇qa9RH2/$ !49{JH)iCIZKo:ş؀c"kHZ`[/Vb/dӻy8edJXZk =>="f=uefGdрS]\"@;l="H QoO.0 X8N<&ҝtհȺ}~*/a0!:ei,h|îj7#}ߗq֜d@R,$ UД^:EՋr_Ŭ b@_rbFP,` 0Ģ ><m?y,D8 0mGI*hI]y'!aaj;읲UVǜҷ3)ZՉNrlvH'HL&6T%1Qhis:5 ͎uvWD3G(f0"od׀?]5 0RRHƞdfEVi%ѝg̨{3yispgUY$MA\$ ĠcKUk2{~Dz1jvΞ/M'H^$٦v4d_5|i91T4qQ@"@CB|MeN'Iӄ}ek<`b ZM1AsTRPD0(d `TIUZjӢP5Zm7hH?F:1dIY r@< k$oՇm"r&`}(N *^1fY>Eg̜p2R~QnTwB ZJJбi`d;Gj'Mq0jΥ578N}^>kaتa7W=>7 Bf[3$G+xUUm=7䋛/F!V@䨈0qh֌VwӦQЎ>Z`GpdojbxT8nY$ A,bWrX)Sm㴴d5*U;_:iR[ޙBA[^$Q8VkQMo۷ OWQ J"dXZIp<^=,ikom9 I64$oE-(s > h9 V-w$F"hKFoף1* 9 8,s!IJ٩Tr6nUs(d0I`x8>0-L 0s-i_@ZtxJzHIuR/qyh%OײZa0RB &"(V0&%<\'zbd˒ޗ\5 *xptNf#.%~Ŋ/idݏz "Z9PmRHPIȴ z7S ;ߢۍ`b qsk@sNɂ4MY].z}ϱ?u8*d>W4l<85e 0p$y`"C u局Uߓ:m":^mA EDFx? ?DZnD D>F55bY| 3+St3Mu)!"I6L$[3vl9RW_׋2BP> łi:d #tH@: y4DmޕSX X&!"* <*hs~ԛ0Q8b@ٰG4VF#,ՠ곩`w|j\23rr[]婪:ODޣ?bHŁV8 R$ލ۾9YdDUar7[|0: il1&-00cIr#x䮒xN %%eiQZE33Du5DH @EbRE5!K DuQG Xr98AO6o)₩5` ^ 〮"deJ8$% }!$Y4EtP@B=@ɝRo?vJ(j$xXBH$?Yc [[Ԋ7 Ti̤~SpAH2*GDjVK4Y9& *Mr.Y}I-4-tU՝XյWoXb@W,xw;\nC؂s+dY8b9=aKxi #p (9&w KwY-b舢T(f9Vԁ0s8ʴ@<~]#J&fTwm뢛p%У QʘX$Z5!ٸExvq͙^Jij("Y82TsF\/&_V bHQ0L%-a@cZC+ 3RI1l xɧcIˢ7sx[?IxC<V)I.&"Etu2t-xΒ˰T=O"-y/ l8'a2_9?HwA.koZ@Hd3S3 pF (=#Ja]al$o&,PبJJg&P)T)"۽4nb2@ .d5nŞRJqn0<-|HE`R:l'y3G:I?X-k;\+32#UV!*4'̳AGeC[P#-f)OF LHhe,:Dt1塔jo)B p?B "r HQ"Y5a:u},H 5 Pֻr2OlrjSW.X.&2%l쩄c6Ꜥ\CSBq`ێ7XG*h3iԴjd$\@]=8 5Mc,$M-t(@(]ax]Oq{",9ɣb<  Dl.J>?ȿn@ H)pCm%8GFyD"B愣BPb?o[ꨤ4 !A xV#1<GAG9E?r| l{F\pnTYeϐa!C5$LvrK^&({mh/q2*5"V Λ+7Ub H*&V G8Tm}jAQ,??;A18p@P!r\^0Apح.Z&閰t#(d:"Y3P)=. \el -pgYp˃O4Fw^!+z]T=ؠ @IFNۋseVaRc`:ݐ}|F:.>WYr4vz7H氃 |P kѯhYEI2  v`L`ȬBj/׊o{(x ~myA^AFQp@yf7IHRKkV@t%4S)|!2];.g KA0#Fk|eZʑ=xEI@$ђI(RfdD YJ;=.Lcl$MmpW()IL^j.v%8\knk뷍5v-* ,Hd2a!mqZxS2LQ`&0 ;?޻ !il)֌, h,N=+!\(ۅ437񔱫*9rhl*5E:R+ x6%܋H˦z$y(dNy),-XBO )UFuHT*'y}KCXSe=o293&98)&']ǁU:(OJ2UVEmd$`ף 6 ;a{\<&RN)qpHmlČY!w .VK8KJrPߏe-@'TikZCק(#J+N~A,4^p*#DQ nq yW)PE ߪYfRx GD;zhBi 8MA?Poڵ4paanʮ"3PH'@+ ?咖T Nζ'h{`Ec4Nj;JeNY K"le?@ g5&56 F2Σ.ݻC)x:vI~\?a]}F(&\+Ov1E?[i ik.O]jdECD3;<` c̼on$R7)MO^g2ڮ.nmbӼ} DJýL@ P fFy|}%DmD*eQl>,>B„YMD@ խ2Yy0ҿ"1@Kef&|Ȓ1 8Yf-c?sS_tԝqK`|圎a J2(02;L7md7Qr?^l) +(q<16 ]Oj @Uv<ʅoqJ5Z#l1-iвda3scK蜓LX4&_I3 a!>"N$Ad߂ARK/42B,=m [l,pP\&`/ F|vͶ~Z[;WUiAP(2),mSz_! 7otD4iC2Gգm@h7NA| kS!  /f<<[6أC٥Ij5&8ro#Qއ@@-h }(ą6FRG UM0}쨂إd$[bE )t#iS,X+w!B];kroX"ĎR;We BMPFcw&&:<( 6 CAR`>ƫOyaQd˂ =[,68o1dY= S,{YR'S%P8ErulORw1^HM%bf``S\hYu|&+`e[Hꃻd ʻAuDǽ2f\ ǗV!C(XXXRA$wTcԫ=xRwx:1$OH\1#)E֩*fRd˅U S`:.k((Jtn勬jywy>1o@Bmp:8 Q\pDq7ņ r 暒eUHFCMi 8sbvݯ*{rb9Pc17`{Y7Q̦id>WD4[a9 a{gN-MN;GL@BEP,f.38|3"Z]a͵^[gۘ1ep2eٔ3G$鈀|r|~y'FY*J,L1o PѰD<-bQkh =!bnFCEss~鉤 Vz}M*E3c3+Z.IJUqlǮօln wd:@L=&rwU7}s8kIc;DheUAح<$ /X@ d a@ e̚*z1JQ /}PUv# @rE pEjFƻ27Sc,Cj[}}|^!,4b9c抲hi |vĕA}c)@`ދc/e6 xp U8JlM{o!B/J" /YtUIi]:09(c7:#}S"meH݇˰9Sv٫UG*uiUdJ"@ii2uiT֖fm^86]/P0$`472ytK5oTPLB {(`zO]&R0Ulfs'8۷J*Ɗ`*sQh $J %vjM5hCbiSt9LdzWX @2K$1ed Wc Q̍<􉐤9BΪr* -i %İ`ӨesK\ ƛj(AUm+BI9R_pGP5mVcSSF-= ž#r]w$7qYn䌺7JWT2@t 0-59Z"Xv @ۃ)QfP;Oq h8 jA` A$g*J@O$Z RII2[ B ~_&ShiKw mK$O40bÓ"A7_D٣NU^+Gmw@BTVDwoYNihF73-aLre)J1:)4a '_}UԲn<9UIڪ]c[62T I; ƴRyd0|areHTۨٴ\Ԭo_\qrNbؔGVJv GOt?ϴ %Pd}\X{7a|?=' iaR y `@@@%q&_zo(L0yM3; Ù%r+E} fX.m*h0Uz % Y{d+d~R)$Gy# 1gm6otH2ҏkef9ŰX h4-zv O#x1Uv Q, FBt($FE  TpJ p:W,XBƑR2G43dd$Ji9bħ] tOPf+FxP#g(n1m<_F+(62EI1_ E(1m3*zY dX`@R"a#V i6쾞j|ڢN( !XdMe)3Lm{{4,2?c_D_gHd}lH!Ph#V瘢!է)%K_IOEc@FH*7q^fpK}當{mX'$ νW!O pX&[>Yl.<Yao'i>)(}%nY=YVt}4XIyku(KXw/gns|;z@)&m-* )<T$ 27& 0(#pJ AkhtD +1u\Vsdz\gˋ)h5q8 0p< b0 ֭L=8LˆqA,j71֕JiU4zjX%0mXKDD͝\r˜~_?-~]Y?_z @z Onb:^]#'Eퟖ`NjL,"?ԝ$)C7iӿ*}5RNsׯ:A|r;Mszg*"[+X -@aQĊTCpÇ?uQ6<$čcW숏/s_\6-@QT{q즁Xd =Oa4`} [seσ켌X{Z-zň38! BIL,b(htFr1CJ,lƉ3E::;_7S% ,mku<[$tq2j9{~lj 4{3-ccYIݳ/*lv"])0M7tU5d+G{`%:+GOw3Wuq"^p-J2)"P#JfTA$ V-TXVy\TVxfg;S<~O|щME 82| j`&Xv(~` fBHp>M,I>4@red?<5Ug\*:T,XZ٤_u2 [-jmGO.Ʈ|ʶY].ws93(izYI_.~5D֔n:ah,@%BZrr."-(ZZ &@L75<\>sUک$"5K @JHiނbLD0q@gӃ0Z7Rd-oXKwof^}rj՟igd"_1@c> y,Pm8 ^0աPp @Ѧ\i *<ʙV00؆P]2UDGlEAڑ`YoaQY踪-@Em JlԪ\s)!lJ֞#?r rr7W*:R+c \`i,d bXwX՝0Pg10xnYFِ?IOUްѧb3Ɩq31 DI"8MgJ! I!RD`R\nsDd,_G*BbG| :C ʼnACbCq=+x~D-Xpd2\Ap8N8%!tZdG=07C`Fc$Tꎰ<5W\G]Ry3V+Ln(Mn7n6Œ#f q^* d(k}/6uEOo'QbZe}KJJDA=R@ 1^VDv e _*=:9'(Β$P 4o18h%V;,:fDu7ɷXmfG$7{tw~ }?ڍiz%uY^J8J0!Lpc="PŰo‹EhݡV48DZ}WD2R.n ( 3 Pke݆Xd`BY0;CKN=%b DgǜZmVzQufɫ6ޮ~DZZܼzӓz?~d[lZ)E't.*QжLHDx{8 x J$ұbňAcnI  hX̳QyP}PP,  .@"#xpX_2@F $~[~ш-IW1u涢d=]s1:[="h sqͰc0}@A`]Pt&lwId1 Ix"\#c IP BǗ*sOk DfE˃ )^]-^%r9c:u)ڐ $"FΰDm)t#$/LDܢ0AFmFm?AK@,РL&*1זX@t+>]XS:jE^2ZĉiQ|5ޟYsOGn ¾ff- ǹ>bh}fD ?&RD7 )V8RQl\jB2E r"BIЋW?~r j)se{L-d2P:K}Z{QLd @  i$i, ЦoR"P@ 1mWxa෨ oܠ*"L<w_Wpz` <ouá. ' $qTBF"1&8Zt(gĀAM hbf:hNELFҐЅ2Ί 4uu[ 0DUcb*u'AC_ Z\$X@M!ZP$f&E 0ɱf!YwV%q \' 0TeW/\jt xF'FVX D@tԕ6 S0^g4UPy2 )'&V`K^hu d*K >CK<0ǒ kL$i@ ,h e%$W1pcKD| ޱtnKL)]ŀIMEYjkOt=DӾMuH$0|'Cg*1V|pJʑ bBi{AZ3/*'OUSRꅵv)-*c@]Oe^cIDAx" 3ˁ 0Sj$JpZ9k4J*|<Ҫ67V+ߌ9wH_禍 y)=vЭ^SC "\5  IyԱr`N0vAdV$YI+O$]0X wO),d ʸ10 *&f@=La\y\|  =ݿh4hŒ9OI, TXq=K=;Xu0[+i`.w0+zf|oz\2! 7H#lR0ɰ<}QߛѯbL?quY;a"zȒq~㉭1BHk}>`>&g x r:jP6[׹ɉDMv@Dc)>/c ruXj:k9ǀLȺ&QBiF҂'ʇ^ CwpoUI @dށ&ƵE"(_JYx d#19rIë=. Msl!@3m7Sd3B o@xCy/*PWo>2@I!\"!!,ʙ(o R.COl{+@KQ(}V8%Uȡ@j8v5kZ3PB˓'P%(k (pd^a@9W .GO1CIAid5=R hT{[ҶW3 A1)PFS),$ۆ2|60Q\bS(zmB}Dq0m=6>2!r9sE 4\<\x^h[Y"OsT;Z[d(Y PM;,=(J i0I7,8`”fkqϞۭa9\&x8i mG?گkJZ(M4ECA~Pd<F p d@ &M*td8X *pIK<=#d e$M2.= $ 9;zd|V]l\(@&GF˪'ِ`$@(R] !id!ȁ# 5q%}2 iz2UIOB6kz_k 1-.XJu3 ln 8ֵ , a[&Tsƞ HT!}g߲V$`'$ag>g0ٓ?xMRoi&W̺H& O}wIjO^Ɔ 5u<˅sW6 6V%d~$ˤTY5 B KѠzwjdžGalWm]ҕ V&NnajȂOI}#1d]Yk 2Pk]`j qk!l8-Q<30 rxGڒ.OEnn/ H2rr 9mE@W-bߵ՘Ȑ4J&N-+RE.,喤2[ gUH#mRK~,S&|*IHƾϢ =aT![Ԗ"i 3bhDK; &BK`jR ?H&@O@y*&wֵI@"P8DX+aY)E ge(w]Z>UMz c\(ԊL, XAK&Mi.:Y%Ra`E Ѕid<dt*W J[+(L.7@*TIͶW^^9gF@а^P` F]l+Ap;Qz6'qW/8[a^XH͠]sIT{H'ܬvVQ~|z'ws`\MG@\m$w#u\5W:L>hvgR3?Jp]\H~xT.Z)TL! (-L$$9=xY$ ;EjyjpGrInq,g:iܷ7 a)EldG&Yc 30Lƛ=<\ 8c<Èm0 y(JZYtj(tVƾQ^H]P Zjoj}3@㾛&;3ZF+92rҊ0LBHNu0KI#'栵35T+Mv4ҭp2)y HBAX$J]'k0NJD( v;{3C q @$ xb4:?m}ePD )bZFYǥ@&+$*"@*nNRcEgX,2LF%!ŏ Æ:.UiZ k.Tr䟦z9)UkqI; -uv@#ø:REjm KLB/{+Zm Ǟ.yaȨU@PW˝=Se]E1&fveHMd. 3r8"{= i0kߊšNF i56aG'f؂8s&Y:T]VB(2ߟK B 0$)LfZ}jPMQEt@3bX|F֯ 8>VէWwi7߀J2NOj9bb av  (Uz14+6V/5CA7V!.HHȳg*.ċ!-sAq X{շҼs6Qޔ `r?wcLCM3(EHne$QY]C@&@3EWI7[.XVcA*IG|Ⱦ_med\y:IOOIWHi  Z3,kI">@:zkpW\Q9(Vd4Ǝ*N8{C'iHc E~#n?Mr&p@!7"$*'!3*P8 & @1QwI;djY 2:{fd8/QDīQu  I V7.pFh~vBFs|-Oԟnm~?~ڄCAL9tL<=Qŏ1Lu}d:%A"N  I)dyAa[hjIPQlg ‰6C{k=bcjzr݋19 $E.Ƃ#LMD+N`$78b<Ժh 3ySRL6N4h 4"ؙbhSA{' b$jDD<<",@ntz8 CZ]*禒d-F;\\3'sJp{0(@"Qˁ=ZFHA@BD,AG75ifԢd>vM^++{z߬W\Pk\C_-,ScBl/D"("^d׀7 PFBk\< ok, LjuL;o3;';%T1P. @7wlW%lۧK@z:$F#rh\(I pԯ5#t4€o (3Ԓ`3u >X+B>WV#am]lȐb)F8PgBoѻlY'"ENP 0 dńZ![OgZ@ %c-n~ `"8SHlϽ(x/i8Hқb( %dQZ BP Hcr058Bssܭ(Aqۉ7r{zҌD`T.f{s3A8~EK))oyc笛8:w#!{h"9$ ;^S*Ġ^t4H6Rhs庽@`fyA4Q:0EI4C-W |$ $`o0BDx khb`1H}%GjfZԦߍ-oޯƯA92Wi.H3?Ǽ&qnl{#B3 (f ddWc0LK?ae0k%m9!R,kV #닃U!R\t8$. }Ky3uO"P *Ao5`pQ2#򡚱H$;GB>,Bzr:IlSWbgל XrW{Qq81cGeGj̻vܭFIB`p䄹Q X aI>䦳9ɢΐ,}< 2iZR+ZTxG2,"_7+תQWtK{35CV3q* td$-]@$7XLb,$x۴\rjcn ;f*%g$H#*Q Hj*2ov*1wed%|]ֳ,MS0# qkoҋ ;X5ާ4#3kC1Ɛl@TXI( 2HLL- GU!@|C}iS)j[DȟO_Q%ӬfT0x%"t[($=x @4chT^9.5ڍԴֵϢ늷) GAv괄Κ)*p6%E wl9;uRJD! 6<;"6(9`($G Z.f5Q!c%[0iBTw߱Ѕ@:8dҮTtΙ$G(ESmm{MpɷKqK r[]\\qqC-S0sd,!RD{K=#f UcWI0|6- ) 1Sc79!tb 's&|8pB4U@BB*Y|EoXPq% ?#!J)ٻ-:E$zlZH([()UH?O'W˾,ϯ5 TĵĻ,EU*\D3N>cRRUCz%7\04^\ 6]1NALVd5^9P =< TVmpq<ǢA,2(?W4F/3e#@y@WF}J#V @(hU fd(VE}$GY":H6dOʊ%-/d̀>aD<[.="ti]=H lRk\V۸Yj̷ѵA Zpv@J@L;/RYHo^azf#c.}}uд:H1& Fd 1+e(Lc* i((=9k(.eHS]ؐ ήxۯy _D&`!a> ſϯEa1y>e۾%>m(%j6rfFfA<6x*4^q]P|#VX|U0i$#!0@\fIHc2.i"" E#:9iv~߶vk[**a+ZᏔ6TՈf9j&Țd̀'AV 2>*e 0_nQi˜aeDdeRʹw7Gr4eS]KQI*R&1 Z S}VCC=*]ٺ1 Ν M:V"iЗ&JQ7W\G0)eSV2L)#3C#3c4A֮x!+ _& vG}[+#I ru]IhXzJ?NoiB`ޒ1afL@8#3SVH񝄂ڮ(R͓EJɦ6-Y=U2"k{fËEҾʔZA_-dրq612:aM_0iHl<4ME纺*l ]Ō(0Zv%V-06q.#^O `y+@n(j! D+=z+ !v|gcgPc1cQA?OwlzەudP+Kiܠf9Q:Wi&W-W1W[M$)q$:pucP4>0D,`s~'_!.OJYCYAQGʆp8mb intt LCmT<ކn XV!A\f=U'@Rlqș3Rd$>{ 1>a=&*M<[0 쵤"2~ӖDdWV¦qLXsQ,`x:'xnKF9Mh4,N$rC߲.,z7ˀVn+ eT2c1=sz77x oqŝ&}gO+쉼/柦O4N¯3UL M@~M}-MOhbO+ƭyKWV{ XSl*Wl]M1DX=W, ]Cibqn;6#VCģB L w;Ő-_Kb$uc/ 78 XWQ%l0`:^`?WpUdJ&!^W.d'`V ~bFkk0ѩoa̼q&<¦M6 a0(_XeÇ'$I I*/ OpY4f/Xb\Նqhq(üRHcʍd$7}UG?a{~K!DC JT0og q#挮 A-n:.>\Y -e*Ʊ1HyӝO~x8t 0=8XQp1P kc:mL``5Jze Cµ(ErJJn(\5럠Iy9GfK#uFW{6\W̓],WGU4Λv&L),t ZS( 18_dUXs4 >=.9UauG{?QPЎ3R/sLQ -'vPP-LĴP{RdEV)6:{>="JTa⿯_%R Cj)ׁ;\3UrXĐhPt܆JKW Kz ^[Ao+Z8aK RO.*\s쮡|;IK VV͝UWs2/]\Ĺ.k& @q3Z+Z._Ou/Wup rK͸:rFgnu돋V3-nK.ޛM7RGoɿniG\{Y cg2zi~,:+NUʘYʊ3U.dU%9Û=bNm_{ۏQxzP*gmvQ3X'!]zR"Iv zYl-elѬGGX` nR; $AnJw³/-"VWd1xp/0(h").njR`@ra33~MgTn02'[F=$ L qVOJ鞶qou%דsg7a"Hs@/dsAs,5 0{<")ig$tmh`6LN.zTuQ*GJK&~DjĤ~{M|?^o]73r ))&" !:+!c_sӘ.lt0h_Pq _*VcE"iPD{ E )jwjk/i3Ǣepd/ mY5fW fVFW`C0U.6Jx{"oT(D_EjRc3f4\32%+];$9{Jd4{qٔy>A-zyPaאEY.~o`RbE-hXrh`RRdRDӚfvteaq#^w?ܷvf8("6@$;P{%nc(ISɔ !tdM(c v1̷ƤR;JlrDLW$X\;F! n8Q='1j4LL0[,RH *d:y<";o, ukH-7X@A#V}:; Bk сʁ*+?is wWJe9#/)An hEdM62(W[Fۖ=/< .tI<\Fö(aWx T y )AcB\[͑cS J L?͗w5u :;As1+cT756@ 3r}AUHS5&d& 'H( G)W(cnmPbj)T9vo濽UC!YMˑ4 i Vu"bq($>J퉂Yd+q@;+$!6 ĉsp0T%rQOu/E BI]R|R1| fњlϷrC}ҩEJ߃wxk`E%jzPj=hW]_ヺruj"K[*!Tr}N&L"cg,2D,JV¤P55 IXB֧@")3ols Draj7SȬ9>v M!#*Sl!"]o3xU4jbϠC\4JEWJ^>q'πMi%%ڴ[3d1,i8+l asT8 A8_/Zڱ:Ϫ ^;8l+@2F<i)KZJt  ԕ Y9^?ZjW;~zP 0` @eɔ`grc*FDc|R =P Ljz*+5^ί*f4 Ȝ4(̜Jаk#!oZ^* +F1x n.uNH-@P 26kE[m* S]խU}qU[f0FȒE{uu(b!]ݲݶO~N\KKS!Lƅ).;c=d""Y @F( `gRX:-\!d̹lMJC1HZsQw%C` i ĵ*.n0E(dZs~8""W%z%--TwҴ2rV_Ҋׯ5~"1#2a.S8X,;]+)oy2Hd_ ~o|zrz]G _UÀs/Z%t%8On25q!BF>I~?ZڙLO=w7oXk|_}Džh֯z';]oNY u3تrV3dς_]Yk 2K]+B5mi$rȲ + .ƈ t(oy1nWVGy5`A0Hnz)o`lP} [:1S-` Jx$%D RIb!B7'(ہ8o!©Y,EfA =w ?]W(LA򎷴c$W UczhwV;Yޡ4~eW? GRgW֠03 L}2v;J(\\5dX*!@sjٔ@ww_]kĝPc-.nJvf(1=jjHZe![&/j}OS3S.fA.A@)O꺊fFk. ^brD9-%oloM{ҏұ$_b[l \4ᡣiwn>PVI0n b/ڦ>w!* s^r 9Ą\ڌ /f گ4ua(dm])Mko=g o]E+P Ty#'l\V<7E<DB>ؐ*G }>IqngAw ϯi+P^a6jf՚ަ`s\ z\MDE_W Vkdq*RP9^Q:z2:-2;ėpMcwwܢڷMqZiE$&]͍H%bXyLkE;-63d M$hos#Qp nJe1t!Ն;:*1{?oߕ!Q=Va2P@lv9=VA9a۫qicOd?OB`P+a"|W0҉ } 8ἤY:jL!)FaiM9&ږO[uEȃ@BH1BW+)'9Sfjd[ߌigPL T dg 1\>kf`GqE][4bHH 9Ȑž9X(\.C/@(օ9%pKʖ;5ƌ=*J$ֆedŀ[U0< o=_iUr֋+G"o|#'dtWbh&ӧb ˷n#zc-0qG-t ` , 4-kqиFs`Bc,Wb6Pn0Tz-^Vt$Nud2S:Y 0Ծyav#Rw ⡹ @0 MU,n9e0%\X\$s <.ha#۹V^2fx~^4Z)&nG~])ѭ*,Ci1FoRH~TH1 kbt(v?s9\?T2x$B!$dÀ3 DPHzae. Y 뾰 @PAV5H0ZvPe8 h^6yDzN*za;yGE.j])fuBլ7Wq8C\Ά _N|W{f#ba-<:A8G R5F٦ykNwR*41!$҉CCc@04/jD_%R0; x*- hJJEFP7U96)-Ar.J[_b~.Ru^<5yOy|k)K-eƐ"?J^8:D\\Xa.< gn{`o._nW▎;}Y2ye?1 SJg{be1 - [&ovNlOONVP`T%Q%JbYU^zE) Ashrsu  P:VfJfd#}SWDGhH PF2`L"&jIc(aoC8SFιY|*V 9VKJE 'V$%@ rڛ+kfՙ7z-uM4(Gzy Q)7jEVH 6d7WXqRoa _ǘnIl4" p= X.0IvC zjӔb#9x]sV05,KuѬK}&x Z"j9+JhψB <ڳ+pM'TFn[kΜkeT{eѸ9¸Ee"TT5:#Bђ+ijrKCy+\@e@`z2~ f1e%CRX%+#Z!r}{$W>r"~ʻ_*J% `$@p%D* @jOMhlHNEGAAPox Pr 2l2`JIAdnF.(8tm_#9>e 8|" {+Xw3/nndS_-)E5hFt+!DD2/T`R)=h eTqG~(QJP^C!"!Rh¯45hђzOZF(rpc fH8A!baC&fh&UD@Q.ؘ!DL,)TZKãՕ \z!3MeGsA !ՈP JH?T kUlͨ{2u;>2'\XV+'^Ҳ%jd; B 1Du'lVyl˳*!3D{ˈAԉ@6Pd"B}F>8ЭQ FN dRE/`ViO4J[=1YaGl( 1d$0b QKIWe,a@|_*)4o"9AJs\*ep؉ۚ4rq: hRZBvVi0zȌLeI;o,Y8n =[H}xZl'J1l0W*JSfxzIuQAKaEBZLBt 894!$e$2e‘ VUohT@0 PaOa!^.`wP},RfD(&BV%*Jw*0;fsPX|’\!d,YP>A?= c O,8(%eaI?Rk#"2N1u#~j%<F#-zv9)^9dd@P)!Ҽz!T|\6dz 6 >xT^0@ߕ?-|H@ تNdCb . 4SQ@-HNDH Fu?#zVKuBX3=  Lxݠw3{Bn!l7slDHj hbaS]&T!rˉo ElZU&)$ to:=|h noZ"T*QP僧vg`lJt %f 2.­8bkd(2Xs0=»-adtgl} !DƶA$C?!0 Q+$,1s4S1}{᭎W;) WȧxH#%!zҶ+D%tͯ0J5Tߍ~=7W$IJQ!opH Ca&s)# kXQU(> xYFgqjx=h6*`yXqpf=T*M$]_Ē0QT>3$٪3+"-TrⲊgҪ"w"H@@EOR* Qd VPZ2 Mߟ[2kYKEٔdFFp@ze%L ]$lu*ޓ(ز \d[ - 2,l*!yҽ幯O'jy}URȧ+MKk|AJEKO ~ɿz)dI$2븞cnㄻ,Fe3x2RlΎ-fe!dvUe;g*R I%8IU\S/k "4أvv-h'SBG%5>ȿ$Q3I3G#*GA``;_2QufYFdbceLÑ&^tYJj4Unhe %P#P0\Ɯ!1aQƬlC5d^8Wk ;cz=W TYP  잇3Xr~%TPg{"Ԍ{ӕ:*38US`w(7X$'RJ89z`d)w#ߔX^5kVnIM͆ E"Y+J (`T:EjA,U$#,U?W½: h vp3 ck,Ġ4B"*84 uk4)η*GkSGy0 OiE:Ni("A PSDIN ,LGiy!%]q%5{MEW3_q&\[~q#g(DroUi2PJc}` pUT8j*p)8$K,w%u.,jG67, *5&lVs~sjYeX@2d$,LjKnɛ)r_@<\}$A`ݥ1BC+4W =X:3uNZ,;2` E;=+9Yv咹ln_?Jjѫ%` $(2xi+ߧ/rY+;s,>{%.|@Y|]!90ALlvJ"(p Pf ޵0PADK)- y A!Rd\PgK]` 9{r.`f kβei@,#uCo,A$SD'~V ρssSxz%ҞYEM vLԱ ; ~ _9X7NB AL>8mO'0t]0(vh(MQVdGvg#Fm]Qy.r:?wWb?0#S_@S($b85$1.ZKwTUGD5g+h- ,TIE禁 z8ofo/{GUWL1u14+ BwRgsA:@[Ln 1td w<`G#n 5mh Q]`KaM$rfTD(Y{9P fPtPFeEK2ƙК$VwLgQ1!QٺNaU.2_]R (QZ6 9WWvÅ3iRt^FC~KC83@DPlBDo&t n ۼǿ6wW܆ kenom#` 0'Lf!OUgaHӻLP hOIEo =*iŧ)t#H  it9Z5 ک_CP/QXb.6_8{]3)")6dFYyB!/=eb #_ǔt@ 0K&֗{OM_61vHHȃmCY'U.ˋ[Bf /QdLF A6q!$oy(dH]FHfk2*BBD;B[XwCR\ fu|ϱ*PFΪNk:ֲk ď9I* ԩN,8zڎ 4H>BR-g dW-uҟ}仂Y!^de1zV^(=6qJ#=D{Zw[5Z)邫28syش۪S&h,\3"CY%RB@iGٯUaX+: y?d5?a9j=, Wk ( D@n'i꘍AqXy g%|i<ʜ4nDN3+`mR$E \ӞG8"Ke[fl+&28HሧdJH+0C dOW#*< (ee0@%[9̏FvHhdC`}|j>Xd."EU!%u.z# +,4 JJ$( SA3ܠ/JZ"WFڭag<(g7D1_]뽀 "[ skg ^-Š(?,M'U0, ^!P`t@ dL-ckO6Y ~%BkX "#&!bv%ID$yXcP U1Ukt NX*z{mZ]V:a*i#T^[KYfnEG2=F]g; *]}WHH25ZoJUoUvP()|jd9 =`\v+9.Aw `rL#M6FB@XC,2ڱd`H,"4ea9!њ3\DӨ8?iMX|ێ]e[kchJjCpa("Ckh r/TSh+ ,h'#pL1 ɠۮ[dsP= LZq{k/,w3 K!f !̞\'bqzVD<4iz,Ⓔz*,YKbu^ܮ&y;geQovarHF#r㒪|V(I]E-Ɠ6r֋RS 1)*(L8h㉬= !+'I@NKQ$ ܴ.2nMpu#ѪA/|AxD\q0*S/tuLiJ uȹq0+hy {5NƋI,ehC"S-FEX %l|'[}t̄ '1i$DY $`F Ie=&%|HUD㜘*cZ+ZdXUƀ 7BM7 3 "->yӯET jh~bL!g&3hhБ)d@x*G iF4N޻&dJ )L: utdkVӴ,+"+qHKrXJ¹_c3TY|fX-l>Р2#ZUag0u6֗S) (1iB,j\:\? ~-] dV(F ct]8ohV $d$3%4ވ%-d4V1FB=#J T[ǘl`75t ,B+SL^D-q17uXV2 A RF4\xJ ja7F~\ӘsuT^~dK+q(ЋɍtTA])_*Bv̏a='K`."RY+&0 @? a>ܩAR+ Uل;kw*U@[ 9󍕩?Բ,R3bzܱ!!6#+ydnK5+)FC(rcg (>߶ȏƖ1kPL2범`a`+Z QSA5fmbldJ]2U FBJĂ+5P %!" X k W8+ OÚ#"x5 5'F¸Rw{[ a@$L͕ؼ2NEV ]05Y\mϳJw$3*١uۿnqQrP eG dzcL=VYJ4*J(0|@4F9Nk4ߎ(b l8HZ.Z& KAHtcB@_H``۹rr~;ТSi} ceR_, v0` &f[nDYtOH-g*ʽ"Vs\U+3_  @$F,c(䬻㑥n b.`*5UrT% 'HQ Hḋ0a-`Fמv,˦9 ED<#= L:o$qB <.+5e=o:IFΓ_>a딖>s= p Ĥ .GP b1 V1RL$F81#h1&+a,U A_$1%!9ə(S5&,-y# )W;&m"yVI\5)4 (y@ɂmnidnMJ\-^ZJsB`1*i:,l.(L[ƄwgyD4Qlʱ@ B$ 3Pm@1Z#3Pz9 |S=&xX\6 X|?*0KjB& I(RJ pg-UilyhZT': 9:ҹ1!&Ic {5RΧ]}YYVc4,,Spd/[k0`@A=& `sM`ˆ1+o/&#9PX|8 J|, Nn_#W ȑ?~eQ BW ©b*@Tb@TmL\B "!@GuRҾbri?z|jܮ@ >J!7vicKTˮ1C`qeZu,&\Lhgŏ;6}U5U$$)Cp,Xȣظ8#3#vBTr@i֑QCh pƊ&AšE,y]cOaRw"fX6`!]%T.,&UgH*IH#|TXPt-.tQT6gWdJ]0hBb\%8 (ssphęzv/ 2+inHfrԈn'Otn=2|?4qe |\Âэ&}tc"zP^z2q`Db@%m1PV̊%y[G8s%9GK(9`O'@J͒i ꍓ8 'M!d_$Y+ p=;0b b 0ri&PR68x¢SE'&B?_\YC G)=< }e,=#09+h1)\ɃpQ*KD3pDlFBJ j 3dECCє@QQwre2T }k4k9x5Bb-`ĤNbDex3 i(h3O̢ʍLD 5x0Q ҏ8y-A!Vݚغ? ".0tHD)4h>F:pzЛL5!RGIMvuN0 + lM; a%RY %&l{qL_Tu58:%\g (8d d+Y ?bK:ނ%@ALEqXhvQ~!:S/M#%0>Ru CVhT#\ֈ+&e$`,fG3\SQ=ӝppJXŐxDJ0YGʴh+}WS1 Ęhg"HVbE5YS8 !TkK\}}di ?c;9 qL08wt-[UP4G s$I%m@J4 gfhV% fݲ+Jnf);?R*.QphJ6PH: e4b`ȭll!rYBhdˤuJZ߶YlB\%XPfʫIX 9[!(DQC3LHzGT]g ,REeHЖ "⥅DPXˢ$mIO16|0.mc-j0I-.ych}göCc.VD>SzwաUo5pzIe$K], d!i=;m0" }g=!-0njR 0MP4QQFYAlfRĠpUc]X$yF7SH@&J)ڧl3L! KH)##Ӌm _8͕Z-]r49\^И j( *V#^`5 &Z (5gh,AСϙfM3~ـ$ HωNO COLUU``c ʍ.e*8cYf`3IqBI^!:מHE5">2L)R""ߓ]$bh gL0i+č(_c8`n"u0o5,ǔ8 |[XRTڶ?R+IB8 Qt>[E^? 58}Zۦ"ZNTS,51ȺO~s78Y#tRwyj"dg?ѥe7"L p); RۑF${B*L}w o͹Bj0C`g7G ! %S`E*i(ME&!CoV  9y׭b9CD@7SVʶ\4 $Xձ 24 Y%8Q dk$W/Ip?Km  OgL0k/$Q.[C0 ?HJU$\aאbHC+ ቃ;g"qY*ahA9h>0Fi Hk` t]Bzj AvJ={JPz+,VcciW8,Ed6YS` @+:%8 a5 ! V2SlmqӌtHrʌε 7&q4w Sɋof(-qf E3YzebZp&hLF*  ]/x+mʚN$XڗQ(:+͘Ӷ|bM [S [DR1ZZx=C'puϡ\ϿfI+K9ȥ}&|j4h7_IռsJv۝Ylk5~Y}va'EfSRe**kV6p¡!}.4"[ K}r7= jic z]䩰E.,J EkI%la2AXv&viţd'AZOa 1\,k0-<@;r1kDQfT3UVZҚ[SKz_ OmX@0%!q1u{~6sߦ,$…J9#}`J ;[ %W%c5>]n`Y:*8eZ鶾:^~(\/-'' W'2OgW*)]wj2ƀD oHILZ,M؊Dl}Pc15E ŅrKkPu*`G - Zg΁z"/]&ȑGz[iZ(Ym" Q "&"4"& .&Id*EY J,Yd1'ȵtNrv "kUgIdYA-#WJ:^Qȉ˨>1h6 BQhIY`ͱd?1٢4quU  e󣤭28 b)&ѽT8d?+2fˍS-{ R DdbȐ%MYNBYyz/EE#=lׇNԟpLWŒɑ0P̣ Jm Xv*0R>"yn޿܋dxI'ý)fbv VV.wKDM1|ȯcWwX> RZm$u RR[5=љqRbR%s4HIC3rO1A< L! X\[)a֕Xbtѡ)~pW}#s1– ˇLL{9ۯu3EQH$BbY@S1$ign#@OU[ 24Zk1,i#Ç5kxr]**CnHF `5%:*L>+:İ(@M(T=! sL4/,D<}tcy5ZB4f)h"H)`9+d <CK g Kﰗ?b ..j17EhZk lqQߤD2CN%S[38R@`r4@:1yYqk"3{qyrv?c@:~$]F;X%F_3\5a$Ǩ#k(3EPO&#Kw# Ij_A9Li O)o:*<zdvk#rʁBDJ\Lc}V6UC#oX2dŰVX 8 ]N77_oe0x!L<.L8\| I ,Q3@$'f?=)Z 42d", `D#[(1&vJug A 1(` `Nc )*8%GcˆXx=Dh'L4Ee`_:&m ?A`,ALjk(J(On=dZhc4}څ*,+t"uX-^e oL %"n0 n:.1\3.T1BA)ML #`uf+-Qgk((Pc#S(%)Yc̶DPU7tWO<,M]'Z7#\A[RTvs66WD5WWPd:&Zc1`@1#]m0 1 I-!g](cv/\]!U"h N&X\+=dû)q.Nl*2}PSaa#, wc?WEu0\ $?D-@DꞃBhHrPk?Eq'ɓnjxuO-*P@j@H[{%БAЖÈAX8HVdw3IQhh5%h4XP:QՅIZ~B!3fb}0#h=7J/B!T: /'}8Bx]Lڏ ) -tntIydUq!YC E<=*"PNaDkT9t2ɢl<|Tf]TXŅa|mĦ8)]n[x3S9 F]ʐAAWz%ׯrpwkEa8PlT\>Ri0NKct =W@\L@^zw1h"),i}GhYMKI1(4 ϡx"1X FB,a6 pkqlA %e?G :3iPD.2TBz0'&뎦ѓttލxah7>y.0ԛUP l%`# sC auX׿<3n}$"¿N?Pd 0 ɣ @w]ˑUs0?)vO Pò"z pl"9m  40`X,*@H)e HW{XOvVPR"+s F"rJPyn>k[xb*C (TzB})#:Ԇ萞HGK@HG4':dyiF`r 8e $M@0vǜ ˺iC 2J{:s RMuT OJc%nbrzK78-W T[D4E}&Q!A#, yW.enO֙%QE@)p- -Qd)w*x"&8H"[zm btꀉ c 45tSHOjtC'Hy,B6VW_OS^/! P  ȑQ3q~| >⹨Vc<0"˦#LTS I)n@c?q]L)l]D )d*[c,`:m2  oO p + 8i}]9.(YfruwmC۫Î?T!R5TT~$VVJZ-=Cj:%4sOJqRot2oУ@@]5A血`@x ѠD WG%+\xoٱIldD0'}dSY *<=+4d$K/􍧔Biw@Eh@ńK4$EA1M6PiȷwtJA0-C)]K6ƾ5U?'N )K'@:ظv0 U,Y1j&A҃sAåH`H2 1HYIĀ7C}`9O$o)T]pz hmOI>/|%nVR95۴)" }`uO,~WZ߀ $ D ɟج-BC GɖwSRu2tf"-ȃS lIa'nU-EJC[Zʎ_abn DxX\,xB C }P+%\^JeADCòȬB. sK׶v}֕$Pg *:4 //8$USYYF(ep4 š!d<}mn_NE%u@c8`tɡ:;ŭĪl&D(U*ݬAjVCLd2ZF+\19M\L`p ٸZł DŽ$8@d)Zed3ZXՑZ`X h2./dpV(@#Â1iXU;/?qֱ,ș66j!T$)i]͂lpj㥂B* G+PB&2(OȚQП%T=jAIzΊUY(I5()f@YZetq Et"9 *MWRIhyU$zz̓q3U,! 1]J0Mɧ\c|dI4P~d(YK BkesIRl P-L?|O=eA|VS !=;e8Q9 cZ]m Mà0*H9EEpU{:g .nvXʱv2/ZowC b1a%<ZR[Z_H12dvBK 3PO;=L -^0k "1X#)JLDz{in* 0L /A1D ՕxJ*6s)(`Fw7ݫIѤqްΘu{b2ESc5ܔTFI}!fcJnY-hD峑:sz`5DZtx2mIVeb)rfʗ T6″jd)?W ?Z=i0T̼mǤBiM@RA$s%Fx̭9P?ʈ#\ ^gf$3SV2 I(-Ą.6f8x%@XAַ>oT6qaU8:Mڜ&T/vTPd;wTkCnu]+/@D;C7D&ǟFVH7G:.qoDe]<{hv>xd#;W;Bt@;X0*{V#Y&i-nzxxh0ɜkǦhQwkj;z2f욻E<#9h@@z4kvp7.-~xT0z l1b3V{Zop"%qR&fM˭Z9ƾHkwYk>5#,* 2L6T(ٝ54^b Z`&S0=ȧ"gn U: c .Zb[lPƋc[yB<2{ZV_=/50ֆ) fSi֊.Fߚ.[Rӎli @BJ~0; qANá-EP53 r!pjsIvEEM&I8Wc-+-QE82 p@z侨%9 đ9E@m}}0o?)߶/c9K*<c L bԔ,M!ʺ/GQW7EN:IJg$o(WkDҜ80ֿ$qB,,ZӲN%AE[ !B_@R rfjs~~dU](g0)'1 [%X]T.* 5A>X6HWjjBfT2:2!m:z)?zb[w;.r&* jS6d35X `?C=0yqOtiSt1^;rﺬ`nH M (Xy1sjpeGb]"Hm(xEB̂G6Rr?چlJ[j 2'9fez?ʌ~f}Wdrpr]Lj.-$Ag Jƭ ]Ru$DT's=ݕ @nè p=]p.;SGع}$Cɷ|-*ц_|sFra5?n>9}:`7Y"믃)c[P@Ae5ժbKYiZasuі._w{1]JBJu<&IRbdN8ZI`>$;}=J ,oS@, %E)$'M;KΥ. g.Iڶ󭣂D9W K 3 [i '8Mhk$,7SeJ>Դ+̕C{y$20 ̖$q$x $ZemA}<)P pEmf 08|XQd[R '$KK,5R̨g||(%x}EO`xx4^#IGOJ ~({ x𓯲1\:88 'R+KfnҐygMO2W?:䒝C.{W kQZ tK`)p8U{@F)H5l:i 6X-?%لo{[1[ :aSD p#W B]񄊐 1ziT@k``K(Z;)< jQ8gJJͅ'W[S^C$4Pi8f(ECDz%zGG'Ђve#}sa̱\9ewչer[`1:kQ%nچX$3cgfewx縲1摚ﭗΟ6VIy@>| oM37+Kz{LM1ϿA@@ @qbT HTLW = * XAnjm f4*B< }> Iix'9d]2/XD`>8 D3tL^Yx.syGu$ZԊ"k[g=I$I%Zl$KyWdnpծi:{oAo0eG#m&TʖV%JZ [WcftI//-.} e9*qq?K Z楯'-ր KuO9U5+goPG{.E%ʱ{[kY1Ϸ7*u',gMM;l<0ynWR%[\u^ےڦ[sݐC1)8 6`IS4q4u)lI EFHX1XS!t(ô=B4*,?7!c2ԒU&`SIJHD܊B§AvV'u5\\+iD=?d# <\o=:b*0uq҈ 125|zl 0S[7A AN$Cя4pxDHE5=9Jq$;I)ibPFRL[GB٢~2O>%vݨ3QA¼:w!TN4m*=G#o㮵 `Pb/N00E !voQ8U&0Œ'cjm~#X*Z MdtP4˟͈SR! :B8Se\2V݉U %?Z(UL #c6D3ѣA5bA%44ΒmmQѽ{*NjN6d6nHc1c7=e4is҈跘*qH.˃uRLOS JY?ґDLQ^޴P0X1%:eRI]^yU#5CeD*ZxRG?Sʚ1\'fuV޶W$cG!/F֓a!t4a.b2b'ԢЪW\OWͿGmi}P1c-EɦGawA$ gשb B9ld|FhRvHpߧJG_ϨL y0=6h^Mu5ctIv@gs_Y8HT0H\@ױJd져KjdJIXC D1 Za"6Uc0qHŎ/u^^;Y-2'-Ϻ˃.՛;J,$=,V%CB$nWahC9spJf8aV&"&,Ӆ.YNDPc2E#iX'* V>d$Vl5P#~emW "@(?`FÉ>sD8@cJ3iMQ@.^1nUF`u6ql`"H:RFHIW#0iJQ ꮰ#ㆎ XtBǦ8?؊0 d` HI&0a"\}ME`c†wσdPJYiB1 }=b(I^0HntĄBaRYO^   \IۛYTv@鸂Z,Y):^KrZF-;kQ0Dry$TQCB Pdqj)X" *9B%et|gW3fX P3- Q`L Qȇǘ i79Z~(;+ehh`~@HF9Hjwh!fU/eцncB]^ԊƵ˜B27]d2RXL,s.5\ 0-gYD%:*Hd>MrM5Jfu+}@0mD`f!aBs}-ւ J,hH޳^OpO|R:@1/P 6,ir?ޱt5?=M2};}0~dfHA>zb9n!3bp4#4-iRA)h!#"x"DcTxap_]?#r"a 9̫R$) مopF_"̊@+`P-*a΅ 444j+i>EEdq,Ҵ 璑C6G*B{GVf52R%Z8JV RA9u}{|Ф!dJUڋ@5{0"d =#kl$i, 8w( Nz,#u k)LM2^F4CAD@Qj` pg'+qs( HX`E: AÎI@Y(]A3Ș aČ$ E)&)H| KUu?*+ņm4 NS&p*Tr7ڂ PQRUi:NQ-jl%uO 4P1zH64T yĄV!Hah0'' l۫Z2Sh76c"-oPe 9u8#ihd#1!\22=% }k=͋ `C t\ynl|S3# U-g5 'p.C лB-n3ޛAXF6)fZBr f5@_赯|QywoXm+Kra`# DP3:H'չ?=ʢIZުWpDJ)[HJT6DuU%|\b;xi)fF{N&  5_dB&@@ 9"6[FXE&C ]ݲ~B jB9qW %^*2qZN d3 2-R$&,' d 4[cp2 1_, rIk}T2n^ihEPVq裈WrT~ z=du'ȜihK-]F~N_`fZ|l'ti@@pDE%įg٫{)[5~ƃob{TOe!H"ϘbUؒ2(즍(8,M▶ZC-C(ZX"ɥv-*t}62l/us)_zZ ,h/daB>DCġ,::Ad5#/vz|EӳkHD-4PͬENdp%*/rl7%a#|@k|$ЃdHvdӂ;Zc 2-$Egl1'ȼ,4V&$$ F҇ju*lyQ,Q"n2|vGHJu}pey%EޯVYi*7?yzP۵k >o{C$8Ж۽tm1@aҁ W,@"dSGP%I.YKPLbL_^n M ",[wUMO(4` 4j=xz 凍7v+u5I J OPS< q~Y q!&I A$T WZF$Qm!x6APtdDM;s*p Z` X&=B 22W PKv`܏715iRD_`ؗ`ܔUYh=5ZR(YZGloۭ:ìdBWc 4@,úbN9uU-0uчp$xfjVZuŴcGbPy'BD5&!T0!2}tVFkr/Wk] jR(EaHpBA]D0W#*BA05%9<5c6\6H_&(lyKY|ɚKr5[f_mө*&7!CAPxY0@ \,SފNpiMmN2*uA@"f뒤#''$C$ ~l/]•|«iUL?uYHnr5j0nQwtYE`dہCk E/š"Ym,kx`b˿ wF`8oa .y7! ʩ'=3\ -(W§n 0y52⨈u6@X L(h7@A|b=aX0r'-þ3_ln2ApȎ{J3vLj6U$B2Pe3LDCڐ U(QQ@֣UH Y*ZbHdddwQVwB`RS<[H{,:VXѓ~Z>~7y)aSt43oG_!{LK d@V{,C-a/<# aU-/n|>e-$ߺϱφ2E~[dGϕ`vW{i:{Lov }V,nsrz`A rЀ r 'd~_ɮPǚ~]K~s9\WZ>K6]|wϽMi(H[ƍlC~)BtQsºb(h\uk&;@@@ (k]J[|);:YyE~;^"MUЫ\2`Vs拐"9rUVkBkBo]<g8|pdAIUsK." 0"S!t`".lUw[+uF3qZDaF/\k8 cs^)oBk3zϙeQ\ . Dփӕ߭Mb=RA{9Zxf),QI2ips cFvN$L=!#ķk6ײ/Ǟ`gvfS LLuү%^XiַuTdcYPZJI6F$j!w3$[]*T E4 % @l)ٞ`_ j8G|gHQ5 ," >28ź̅ 9YXe2^P?Jg(鱼dYS/r*;\,&m%Q! ,pS =qsrX{łS&C~ЬN'jTDtJFD̴4~м~PƆzX^GzCߡUk<=s[f&?8 *ڤ6Vf`#h7FYL} rr:sF c01@HS-?z\<IOo5{/,NZ:m6<:5꺾7XKW37UBI<4F RK:h *ZiٌuΘ:C0ڔ`QNɀ_AˏڣX3 adŀOS[h~)a۟$/ ikO"Dh^A P- $)Jt/u(Ya32PGTͿIO3"ˊ@2~- $^#q呤ME$5\@& ?=清QYkWJh̞{kY+C6<ܚ6屩U̲zϒN]۞MrW0—<1]Z?Iv۷RǕ7̮dL[g rEL)9[ ~?Վۿ+Ua;~zofhDM$lP`k7=_h·UhRS08P A,b(H, >r0]NKHnEQu1`n'Z 3[\s/0פ<$$9r",A 9V"> Mn RnE"j3:<[ 5ZiIIi(T@_PZ+XۏD46P_q6 m`vC"^J(m0KB0G,NO^:1P@\MX33jd X=`8#  c[Еh\PwD1ZaM Mǂ8Ĩv=;Tg~wk]wŶӣ:TNq]0tKAd4~:,./,Zwkl?u2 9c({ÿ-*C@ 4!HrxL[ 8qHNL. <ղ_ƾlgb"BMǭdqkWk)NmpjniESWF'Up>nS*kU$!2кO \JwR>ξUs|,_t "0 NeʱPad IWsAp0Z1%[0 (("ӣ[:!=n"b 1^fbMKvIr 8G pץ\,Wh mUWL&+D,µǏ $/:帒>A4EV Bt10J4^Ul@) T 8f@; [''L.t&x(f@彲Rtm҂ ,43liP OP@ ˋ:2&AOɸ[d [ -"/9?0%YHpq>iپNP;xA{Eg؛A@x9ǍckFatWEg&E=Q*EFP'^M Bұ6x!0w2moh#\Tj2(F4L>l4{ЫA48b/8ˇe *D@W>56C* &P[ ѻ#j&{&3\oׁm1.a}(XE =cȕ2SsVI-cO=U h Nd6x3GdI/BP.' #[%| V*~w֣?7#@Q@Ъ8a.vuۻiJ<>E<^?,ͦ]^h07Pe#@`[RXBhLjťFhk:( 46SSTx~2~:9_$-]O P;CN-1ZzN[%CLLd='{+r3 @xbMdߛ;D={hb4T4(r?T9>*q [C{9Ͻ?w4*ԭD$]I9d,. uE,[}l{ K-([ʭ^D[=ԭ5d6k 2z e,R@oR|Fe[;*PϽÊx=zqVޏ#0Q~;@ 0θYKBFTJNՖ|sbb[;Ep`5!%`=O.Oٽ*E cɇ=刎Z*İ}@Gc=}}Y5b1j*m< aw4$=G{Ak89Ș_J\B[_W}Tu`_b?G 9Y%CYg\M9o*s DFf#9Dc2UEA{z%nUp4o׳f; ͲIl^,~͑mdBIW 2EZ?  m]$@ tzVxlvޚ[l]brC 33 0 2&Г25xh.ssӈ^y06hӄWˀ*M!Ͽpt:E*t6# :x)`@ guDځ3G_l4Ry7 ~ GP  a]%k'1J)pR %3bˮLNhP j!*s7ڦ:~G"U9JU+;"W]O3di[8iB eF? mYʨm?%:RT:H'n J|*HdWy-3- qe[lwi#*k ;`X@Ɗ@:e$ceEeڕO T9wNeC;؅G4EH &?/Ϡ A} ;YU6In --EUaf7)Fn4?ܨ #CjUe, fŶ>97T,:{M0Er7D"7cwץ_  Qw)%Sg&1!pD3ʵV7\,9n.<.HGko{xv>\}< '\^֭(E8 Hp xygB0+G7$;̉R |8K^t* ɗ/;1$KRLn|Z貖:j@jǐD}]}/uDZv"-qR]gIY-i`e90^Z1ɦIKy9BB4ju٭vz MN ɚeK4%Dt3b]3N 1vQdS\Rm{)4ykk jG߮{}Tgܩdow_>n?xwAbfG{_Sۗm@! C){e5 .[TM-csb#v9)&5T'I4A3rqi2U&jLM:N(RxFH2ez3ɛ"I$% ҙT&hfoA $n]QxG=5jvirYKkB#kGLywώQ>z hLi2 ȗrh2ls6`FqRXْm̹NY WXd7VVGi;K+A7X$Ӂ, S+Mrf髽r`zV>8_))HOhr i"^JoU-&HY=6%wghY Чva*"D.B pTu#J'k##ӀJ~މE^PƄK2@b9 @GГ I5\ɷTm:L?$962Qǂ-J}o[yFtMnS bD}EC5%,Oן] h:6Op?Ulr7W] ͜8Yʂ8C-xL"YXX\RnbdR0,3<$MHHt]-<ŀo4#.&\yʈSϾL ɝNt%U2"^ZxGjRt;g"C<@0Y4h>Q<9\ i, S&_cq:;T <:P @)5^TČ"\ ƲGwʒBdzaB~f$PZ-9X@rˏ9]ل" A$ AQPp0!˙(t՝c6lՖ MęE*c& #?Ud(ѱs͹Lx,(yٖckBD33I 1BNbFa1 A*RиĠtXd+XՋLE&5!] CA\$lda!rL?><.Yu-*B[KnVJ@60iDG$h62 KkhMW{)\AFsaƑhJDٌMi6# v<DA(*\qC=<~pm 4|ħ129Kλ,Kz490xATAϔUu%a%bؘ]-_HŌBtBT< 4P\ȀYPA6p thW=?!ůt[֮rm6T"s gG@tz[]bM&sV.dqGUK,E2+e[l0bI![L *be,D nběUt_4TXhwC B&aw3oD| ̀ A4bH@@4\UGWLWumaj! h(Adِʨ7&Rr'W*5 ,=)J8 J@ڢ5TFWƇl*;C̵NmvR?u{ªN lF _ ^4eC3<+JSq8*C:ŋ@c(O`rP O^gvHR/N!Tqad]V,,@-=&My^lR CJ c$L&R*<W;1 U")U(Fsb\Ff"#r{IL+OUh3=:7 Hef5awQ[gAT a-tcR1P(/h*KN $$%ﭽPzӻ a:ϲKzMgqe&lșGQ"H!ƪڒ#%5uudxVI?vGTR8@1]dlEnXMңyV*͹^9 -7  dE @? +dEp2jJI1.S4UUdE]3B <,e$,(r*65t/V;jw1ݎCL)٩#^0H0Lj40Z@ʟPQ& 0 ĩаY[O!뾆VBlqe&˴X Pn"sw9`.1+U2a&,=!QV*sZF;b#dt1QA8"dTsȐd F%3sj'Aѫ+X+e.D$lA%3غ qLۀEi.J*M>Y|keDD#cS)gA#LC!d+IZk +4{ %"' k,SГ,h59֤DUP{R0}4lV3 q=[j)+PYbpS! 8B)+[6'Vesk%ca,5ܿ ۥ Wޓ^m`N voۥz`M(Pt'kTYN  6VPPWZ2]b?'}~M I\E/\ET*LHEҥi[ t"4/>e5q0^Ѩԁנqp6 LlF h/?>sl±]u=$'YtFUWةd@JXS/p1),"{ yWLKҌn0 & S4.RT( 0 9VX8jh Kb-S#6r%2#icbwqba-jzCN7ۯ8Y@534UЬ"jq d̟J>L~O I?*Bmb:ZX zkLX4u+/(A Ke _/ͼ(\uTM&G!$@jĒxDag qrLm?mDğZ8$9L8RD,XD:3Ą)z6UD~OSTTx,`BouSE9L9ZP:GY)bdYGKL*4=%* ]k$bKİ2}JLOAB `ÉЪUfY^}/8wJkM2w+Yi6i4boޅH@/ "Y(yS= B`=">f7Q -PZ>eu\zPb B'ݙE\ 8(">E8$9b~c!e#3 YHV\\Qb$m-;M uP#H5È!O]&ۉ5oiέ@r؈:&Z$@pkx,'g%Y2H'Lyr1{2^oj &@4rAoe$.vdTNKe@"dGE/SE eYK_QbZ{jE X'=G\TX &hVPB0C{rFL5E8VuEm(~ <Ok8v\lrv(ɉNBVL,di2ʝ<< iQȊ r"oת]Ʒׁd j 2Յqp3XMI >,avʬgMhWq&u'τ_$MLz:Z%`u!^{Y;y=Y@]dKX *r.ª$">Ea$ jh`G*@Yx# ZH11EՐRسM]^8`G2 yPlHmCѹEm5I]tJbc)BiYGB"s)cv&nnnSѯ{k0Ld&XHdc7EKuuou]뭫*AUo#+ْ(d N)jN.:`fwQ؁dUC,4.J=!E+VL$d=R&S Bo+@aT[Tz K)G bt A_4,#\ I:‚TX)2lUY{? F|)hpcg+axGp.ilЄ=2C/",=3bA[1b8( 1_xR(ѯ`,sN1TPr⨨ u",`|C@e84@'At&a ֤j6J+=.MA$U}L7h. sIm3V2' ΨdITIDR,m4&*1%LͱH$g5+QLFFSX|nֵMe$% |¬](bob=>{)<`ۘ&VHeb^UUF,JP`@KjVJ2Ffi>,2ۺJTӮPP vg\Q`+ʪj[S5V8v!jc?>Sq^VSEz+>[W@IFB1 \,UibnB Y4+mdӅVOi00-,g<= (dPijFFiE ՘YV1N9y Q ?O34rw P;H<=UtBs36d18-hFKYL&bLIGJQJ}B;44{=[=66fݐ$f*RL﹍gwhI/y}e.fzXe2H\q=tz!}z^05AS{IH@@لOxmS'TFg(q!YhIQХ$[ [a78< Ƅ!Q5S%;]cv"YdЂ\Nˌ00ޡ ̺ګ=KloFQ̵'|,0( Be@ `é]0yZ:z-A]@r\oWgʯ~Ư\#K6uGyǞ $$DEݝV8--FIR֒6,kdt`Pa j/4%Cu? n7pP'(P)lh@OA4ej XnME̪8^18WtG3di(֢HV$3VzOJ@ =6 Dz]ݗncLoMY?&{0')h@p`u)2ÿ:*-F`U>>٢d 4[W<0ʚi0Xx y8Q<0,~ w3X.êZ!Levb{/6ڤ6eDe]44=X2fWSL AnJTxR`X F,@r qh:]O) l'?w*ҮINڥj&Q n1-(+)aGk3%ӌTz[f_g'51}rqs-j\9%ua ʂVu@ї3i6|66Nj6"H" 1cg hgw!Aq@ȔT``4EfGE9mR"-u5gW 4T0)]u݃ZܚX m u%mԊdIYc 4p'ʊiaȬH`Q20|b4ziGDFQ!EXUvn9jxhLjgQY3?BnOstV<mr5)ԮطTB Vހ a҈oA'CV'j{vrIJdHE;h3;bC+3~1B/4\FZ%T] hD7D7J DFSDD̆"{fjRTFK2rEHdꢲt 96eȎ E|Ҳm. ߳ݻӦH *YI1@NP0Zh[#q#L!'.dt>ir/c$h4e7yֵCkBv=v8(2ʬ(m-wr;v*R`*$yqzT &hÎVM%D) b,|l35 l,_G4֫s?i *Ft8 iv#Td9h]":'bzs致#Wgյuf j0v 0Y-w΁y[U]pe 56z_sGcGR>f{#E* !"#Vދ&.Ý\Y,hԲ_gvn&@Q@<X$1X^[9 o -%]U 9\/NƠe$]mRu0oS$s͚g!#! 1LU^6AʛZ_**}7._y+Uk.+.$:\ !@eqq>)blNprI 栖v*ꘑ& ۫da(R49=2 ] :ƀ ' U,CO2c#KOqa$c^d$%t(i< hD6O )'$6שK'G* M5#yÂtDWM ˓PHyrUhrL>ڕ RA5(0Q"3?^ƜEi 08E/{\`kԻU:XlbaI 8h*`,8 *>g$[Cm TL&@4PqQ4SA+1{RYUGBKGsRpd-ow߄A@Gc¡SfS<ⱨ*\۾RF  `1o)6̆Gdy?a,@D"=X Ho1 !pǘXÚ50l+LhY P10xJ:U!85H l5 GHFȔ}H=ՠNl'٘ո7P:"lfe+RFHT&WUcF^L=^.HbwfЃ%N֌>YmcܨiTQB>geSQj}_"rdn#^< @~<)`hلmW1kSTIwoS n #׀z?dze6454R*\7+ؘNA6f:CaDg=JɆLɊ= u)'ʉ54 E":pD.2s6P"ʗ;3t8pi 5L=MVURZ|OpD`=&$4 ;A8\\e ַ{\7Hyd9!voDFh/X@ 8<dH&a,*e"Լd]\0Orb! hh/.[ia8,H1H-`.#uQu65l#98g#TR\`+4RAQ 6 3mòii4R`V:Sʮ Do3@Ly0Ix'OA2 YIJMn`KaAIc[wNdnP* .'$@ d`4 Od8mHՍf7Z`Z&| ?EC=v@fF.6!TeY&r:GZɌec[[@&z^Gay<_NҭˆAL}QNGA.ZYPxat 5T $"2 kb3QxU< kJ`ׅ6`0T&ـ!݅MTGYzCa (s/vI5e{/o?eOGG\]5{6sDTLiIeF1N H3L/'4 @'PK̂v=LI! v T5-{ӽ^-#H{i. Py` *RjB<3p O@QTx P@8`.p44(J#+q (6=2hNL(nCUIȠɨ-pAk02յb f3`@D->aseO]Xraea\\Y&E@|&AϜ.$À EK2XvVA3&S C9!S,eB0& >`|yd׀@=G,)%s4@8jV76v72.y"VV&0F%arظDC=0%B\K0s9z  ^S.b8@4,a)ЉrAZ_Ɛc?KBx{Ap/'.{E&ja#4-9Yhv+},-HЇ@_`! # ͊Ո 3Cs ^Da' d=ZWiJ İMXl `g_{˭j˂C HNQXJ[ĮҒkt[l^u˯"2zf)ˣ 5:&gܤRNM{ VXb*H2$ė AVm!2kS N:Ēd5K2:b))ҤR>ҋ\xd^F3 (fv;klf{|96k&\L sBXYV@YFAxlڛi=NPHᗶ4:PԶDS5^۰ɨc؋'&uR,!Ŗ4%w)6tX|e<Cd!E+IpVĻ =# ]-$ 6 i,8;e6vA1VQ}[(lJLUԙt4,Tc !MnE'fEtL8XƒQ`ft؆s M^r"DAbI,]c. KAnϦ40^FC]wqZ1!S xQ;AԤK ro2viR2/3X$cr4&,BtlZxVuBq#k,dn9C/^aJ+N5& e YmQRn}bSg\"rL hNhU!s@pdkWk,RD 0J Q]p0 S&03StuQQL5|FIڃ:+Wqq41T?eyŤgSv@HhY@8RʴVz6+T:l|IgqWy}"?֟w\UnrMR%9̖ 8!,5T[/3LT?I8j ?1)G }Oڠ >R7pZh-)Y!BH 5.P1ʍdօ02*)@@(6RÖ3 ]J5(Ŀh)⺐1P75D!za(5H הZ뵩n@㊥Dey dI 5MT wc۫WPik=' Q(L kzV JQ :ؒL4=1wdv+ ̝/tId4 L *%IR;dTT)R51#H TWMO,G7m`"4HVCJt Dj 74s"QW d[mJEqt|2!"grdBx{Qc Alz%鴳nvs+*ljŜInaH\"(%Uhz8{ܒQ@k%Yay>{#> mFoH݂kr$X{!410ekSH'*Dnp'{E%U|= pG=RkJ>^|vu}Ւ4JE:OI{9^Mp F^_BvB!"RdZ\i7[ "qqQx܎+h TEڨPnS;b#4ΌV!pWc- TfᨮTh+ ]ca6vD[Ml@ |'ЯNY5!OuM5D*v=JJHB9f^*99>!F ~|b;%S?ѿj xv~6 7ѿ_ed#2)U]߭E0494e"nB*hh>Y9z-G$v'3I#;?`Dt0Ȋ&4> RNI1`*8%yGr@e6~B8?s#Er?{jUT/cf쎮uZ 5> %d);TI+6d=" Ey[Mߕ,d튩@HQPW {?,lTJ0Egfg/8m4Oh"JB:`J%Emf"ּ)K[щjD-B_unDG:~~}E=&^@T`03WeG`ikG1F i|~D3uݵ*(0݊w4RLd"܇;hx*Q X2EIA%PY QۧC&FKzQ7}7_'}"v4@q!/c*]?y>}w;6d?GZQ+7C ԭ0-zz2o۪~v)ιjͭx΄08$X'>;3 i0̑j™x$ ZJ -bKu/rZ&BosXA+dlLWg8|Is_uԎn k2«CpKgĩX OGG[;0Mt\kWmVӯQ5f2'.8_j6P@@~z0t@\^UL\Бu}b&}dbZ:,a IBCar`"VݾU&g&y0q'HF]3f̠ohNscJWo~Cx ZMn`nB~Οujr!:dG[i+5DJ-'v YaSMѕiك8H}E]u ٔ@.ĵG `'tA#;Wu칚[й—r55^eKIG.] eW-Hr=o|N 0<6YEPDTԈ t.q;m kۍ0vщ" 5ZWteF~l~:\djj4ˆwؠрQE3$ ME;PQwD:s!~GCu*Y۞3lQP]GQ @`\iwwm09Ok'bEA M]HvAR 7I:Em5 lDtz/P6.8dG[|51O Y/vP#-(LVTD1AB.Pe-ȏʓlBY}蓟Z*{YK %ڰ,GYي:KbrÐlXޥ:pßVwsLƮ~{i~, '?RJl:Kznݽʰsv?'7/2ɼ*@D(gYSNEiԑu{zTj2G :aDshSꙍ䜹x쏛Rrzf/8(AW݋ AY1dBC肤b]HaZ5+w.Hc#dv[SorE̼MkiY,g IzDT̡4T6j|N;C̴Y,畦7X<zXϘ>W~An%od; ~t=1lV(y=)[=BaaUlwz5^M$!Tq9 H 38zy:r*%Sz8X#{e!sIQm}-Ϳ9ł - st+Y2kr[Ԇð4%ZjvϞ-$6F" $SrЁ0w#-@i_^nc`u<8usīs. U?ĶX\HjjfY8Vk@AYdK wP eE,xF4p U;i.)S&45?}G$޿m)Lc7?Md6|.cL1Xd+-=#.TU $͉> 兠sIKmR,h'*5a 7WSguDڸ;`;v2IvysǭkP ;Txp@رs.LsRI+T(Pã1R0s\ҫf)Qua2AK-HV "*:1QV^¥P[yTb{v^n{Nu׌ Gb@ dra8eW!" 8Iq;^)PvfzP0N֧((g<E L☙ ^>ܰ5Eg*%5)R @)j(6 dUHʠ%Fr*ى,d=l2D 1UMmf E+ԕjKAۖ#㜓$R8Q'R "DmQ*o:朝*6o{=XJHVKQ̫m&$4֢*8㢈$ _X}8"8} UDz*,*J %Y3@A4UTxIAŅ (Ҩ)9@@@TEAa*74m}qO,}h85G$KVJy)]d -UkLB9<$M9%SMmĎn0 sDa"DR'4k<UuuW[Tb=#9LBwuikuznx"HRH 6+Nڐuþ:=EC[͜c1Ӧd ]5dj(UB@SR;,Zظ@ AT,khBڪM6RUl$-dHWeHΨ!OQgyHs;R#,uVL]_e\^2fpscx!V#6an$~u&Tq2\W3.crPt7c2@̾ K0 =Y^{̙HGY,$@!]MR%dZV{)"2`X S0m؍jlǘ(!DBY>1u_Q߿c[wPDh_p9? 08P10@RDhhm\XTCQEP\(ũťZz7[ 8Lx`#!T,1qH9lbtm91 :{M\8`LGF4DU3-$tBGw@D*֪mc.G]`"bR! $LM zw*`o]ƺ1iI$rR$uG술 d Z@4ל. sS:zrD81`| TbpD1A&X|9+6$BT @_yhKZp:H$ v_`@_>jmbċ5&7/OחS~R񘱄yxN(ޫۣ~njI 7$]n|zzT9T c2& Dc!ļ0!ɷU4$/5{d 2m6AJܦyCɯ(?2mGmDgR脥@E'J8KE3Kx](S[9U\8pߵI;|x$i9ΠRfKEnŵxs;&-fzVq ,pڲ[Ҕs(D3Bku&W"ukb WJTZT]WhyZ*U5EZLfXQLAWA?W Odp9w#5ҍԬF_F6Wq}}3wtfiJYڣp#{ϥ{AIVDWI$Q0DiHƒpDfT~ak-<md{` uOAp3k!-`Uaq 2twBP!I)sbSg wVYLp71(WZLs! i7BD &\O$_1# Pgǘmk,B,V(n/QT4~B.bҩ "5pbi51H} j䝨5O\{#Hі$F|T\D PH+YR%!9߫0\p8 34,U@zr840 D @O=#. u]Jt* 2r罦2h*d-&FFeV_ep1@`فuy#&( 1}bv;eUz1ki!|ܱ,ԵlMIv&5Ba1P Z ƁJ={ Rt<,%Dj{2Q;h*fKHQ/-;<#)d#"X+M#*="<SSQ j< |B!(E!H5DNky n$@!h\dE?lЄ Մf"Б=:Ue:,~Eڡh."ࡢ&ޡWu(7Z?MBMgml$1 p<=$4=yAh*+$!(.&qL0JE$u+U* b Amn7BݽB S 8^&/+Jټ\>w}VRҊLyɽowt 2=cjnoa%DcXRp_Hcd&Bޛ7>jMd?8yPH0& t[Mǰ *)V x!ȋ"ȒQq#"f7]8Cلn#1cSâBu E՝(@~[xUUDb2׿HȔaU/nFsPD9{JDܓHx-42VU ½J $x$E{%X ibֈ! ;. pGFCS˴2i1]Z"^b1ؔ8:/py1`c]ɿkb>? iI@iAHg|e%8j}pvPl)紪Ed/~hd[I3BnC mx]R/H`Xj'DUh*yBJ_0f |Q0a2)| `@[UsbF6[W ؊w b„HM~94KHҸK?Aa( ^}F@@V٦.i$QAB8X`¢' \(`1e6NhX4a1ٞKB2ȆB32 uZuT DK&븾$"5&J9%( Ȣx"[qTAqDqS<` I]}-$#1ϓ%gW-,I{ݨZ#(uNK5} q_ݷnYj_PZ?3"PAYSLt8њ*( &b&#(Yz]+jb EA"Ĭ & ]1^Q@\Y'v95v%H8$dB@2|!f@!`pl\QhŨ]l F,X ҵh9t MOKW4Tڏ?=Vb n"ەj`CJ1 1U #:LQ:01-]}\RJX̹*jM4iw>G!>z; BZID]a8d. IniRIĠMp&nw m:lcՖh9`Nqa2Gj/G O$M6=e;b&ͯ"D&Fe벇(C`#( HhB+V8jQ(sδ>/f"SNSRQ> !P YXZGq^-v5&Gk.^?^LeRqxցaÏN< M>S+(*VҐ;0^noLGyݮ[3F @ ݩ?| *$ ((cLO;&d ?<-:f sMH͂j<$@Cay&n N]m}4\s tt^3[w78ͽecɀ'C\h;#dr#&-SŏF/WU{~-Z֖! Rq~ S >RtI`V0湊+r @hEx 1v]&],x=scK zɽߚ&AW$LE1(,^FoNK1z)M'Ucou- DIi['0ԥPM+Ll\T)"@JUCdDY I(@z%kHrIr4( Qn3~2#@#HMP~d=sK'9&͌[D醴OP -ZL$Ĩ_Y -h.GU?e_.,D $xdg}?3짩,H1< RXFf&!J%ٍs*]o][x0p(CELJAheLm]+QI*$` HQʳii p tmIw)Yڎct2[ 4$d):C)&e1cgȬhX'8މsSvc M}zr&,$8j?@Tll{*=4n$QHa\ވ?-SCu 8LNRtXɶ6R]^ض̀WGTt"d6-rSX adOBtgĮE,0 p;.sxk:'$鹄'k~c@KOܲk?7\5gU=mnE.k@ P3㸴a|+̛ykvN/(oR36Z>0U:-vDC+i}f(@c_azAH„d3B2 <"f $e&5n*.ˆY͝~H7(swJ6SK1u",<)4EtB x2JY_fʄvi.˒/54yʣ:]0ޚ^o:LOH48`:PrDѤ)i y{4nTb nݩMgmD*ltL*uQ_,PPP -n*dfJ2nё_-_ZR~NA N}ՔbFZ#o@10F@2 G1{) CXo|OHhdL FXip1Eg!]瘱fp'0RAϒ֜6u0HS_*bqIƵZ~Hcɨƞ2 Z)ƈ1>.rqj#%?FL@h"GI3L7[IW߉`2=/MԷZ~LV$d@B2 IWiAf@lbǪ~ٔ8A;;2pԈ%P_"iTcݷ÷оJ~DFc|K/΍uyd7S*- V+Ujo^^VKV-o6mH*PU% !#B$@tEI6.5:2dgJT1AihU@Đ恇2Noܓv'tˈ,x@T:&@t/Ƹ`[S0gRdv~L4/!@!qV7Pl2ŋwF*&@18PxjBq eAQEra2w'GY%[ ߐ0_1YVkA }*>F^PwzR4`w \C030DaDLE)sСv8 *?W+gg(3;N0ėpJؒ"Vr$^%4I YIGuhV* [`u?aSgtzL2׊_F/~)?B J&[q dcGiD1 ="8 xJ HguPdo=O[B>*'HPӊe*^"!v HWjܔuY-Ke ѩ}g~YF9gƛ:+iFJoQ* XD: 4hQIL4e>1 B7 "#C0,Gxw~3##0wHnI&S":i|45C-g\(\Y͟1Il=}< 1SRnXۛMv\c^8ᦠr0 +!b!6D#X@bdix!Vhpp@0/Q@ qEF滤 d=I3p2dia% Io͙%x %,Fc\=1J.+,7B 豕jDG܆Df xcwێ-v9] #<?,pfϵd@t`&E!IO"~`ZVU+S .$'V1IybJ.UX>279D]UĶvTuwJgIU'G^N<:Wn 8 #@.>W܀ɲІF>4C0 (HU 2HPkcXED*ЮCq[ 4UqcR1NO4D4>r&&ԌFdFO B0©0e k9a>t8tp4!"@(n𶦗ĩj'u5`QK2(cȃ{'i: !*A0A*HRXK3cLJ>ȳt~ʻ}; h'L)xʄFL@Q(-"`+1U1/!HTXjhVk#'qQ7B i=^&x\BE!B=&y4$M8J.f$')AndւC'7 @1i D9ȥ& }`T\$*j_ȨڣV/~X(^ǀD䀧Gf-… -Hn*6iNg55&qϜN7a3W9rsɮդsUK)`"W,ED$E_X,)!Z3D*V(0Lc h {;fMLٮg3,cM YZs3RxUWtq4.zbI%4qXǒaL*9Y9{Ǿ}ؖu?9VBgJ){,mWz~cr~ܼ7K.(<4&K%FdEHB?Ș`^ ))"#$~$JcOfhx1% ^Nzgoa F"dK&V+M+3C_> <F0` ``?pAkJwm=3<}}m-:"+_ C cA+KaP@@`Ch0 o. ~ ]`uX/JVPԣA REHXX!-UR \8^DB!`*ZT @-7bɳ鰛>)ZrӠYi-6A)A'H D׃7dDB0YP > ? B !!1 > BcJ)J/$i.IP8CF:=hvqi֬>U,G |5(jCA#hz3QFQt3Hh_W3kK ^.!z.L'e!% R_w~t:p诜qO8 p~!8@ tÄ"D8txڊnD썃@`t7=g!)=_ԝ\[46pkglފz9J4w)ȃUYFTrUUr9UoRbN[9 \*ː4W#_ \4C\ ^P4A Ph.F ` ?G* B`!p0e & _鲧 rhVElֳZ]NQ]NB,rޣaQi(Zd @ŋLH[Z,ZBҦPXD2\"@P|#fow3v #;1PݾOm>O䧖׃34 `խAbr :LuDԆJ8kL!S~(*aT]LN8t9kQr᧹m|Erhh.\ @Zx\VWPVWV`$#~"ap,X| [81069STQmNZj ;8HD6r KT+VTR+Rʜ+T6ZQh 6AU*i)(& __scE#f G`@$ F Rɹ$d(܈ #9o:$#I螛.T|Rw"PPpaxx ؄;d4 SA )%yP씶VWl%C 뢒uP-J0r>ѭ[EhPZG-@p q 2!p\S@ Phș߸<$ 1yNGv# 8*ÒSVc_EB}hZk-e,E-kRԿWRa tCSOeyƢ,aC $;si:*\}Jh 9=dLqhl\lVP#-˗K J(_LXտ8y0[uM2(t MdKFbC¨y i7f \_9>闎,Ԛ' b]2c+[+9@Y!$Qx|Äj_?%r[\[-KrlJq+6 h'T?]B"; Mahz7Bq0fL"eiRsgѥ#j:c!>ߒ 5*EdQRmNI 3G HV&e>Asr|=L }1KDSa9˨gΚu/{tj4R~fni)$c> ΰ )[rPʎePFcvP.' ڶ$$aFdCU\o%1#j<gsǤo @$a[M?˹U`RJqG 7} )H5Dҷ[oX!x>4uh4CI J:Xx%E*Q'UV=zϴoBUQ m0n\Át4 [MZ$5|%'2fq1( U .&%,$w :-r36 )aP3ĔGt nn{!!"sp9}4H$n$]24-Hb=kY&JfS0R$(0ld Aq/jKiǍ)pGrC"eRXRw|K6^q$L FfPVѼ8M^ SD-@)ڻk::P!TiOh1>O!@@6RAW0w޹[kq-(Q3K`l0L' iK,3)H&˂BU*!rPu¸HECnbȕ/'M-ʹs8͝ݶ9߫=M ub}wc&Dԡfz~XѤCtNG?@:]syۆxDI̝f<& r욣\ddD[s 0!j0mkǤ03 xPgv0ޮ;=]̽JgJ .mFP1.jq[ۺ0&T P$Fe.<?֓RU4YE#P\Lpׅ>fEbJک"9.+%ciU?A]t;Q,W**gfUBKx? Z5&$$B ĵ Gs)soʈdc;F]40,⨍qoȰhl2T(AB}s?B26Y o3P0U3\Ls33{[_WMp{OSI۹1]F['Bu5rl]QUVf_I9QuQDhLN4\̛nt- q=Pdh%+&p_OH`Z8p 0̻80 /__/ gfs;*x~&D`\r*8f!ouXlY$6z~R]Npk1쫜t8A d2B_qf1B 0"X )W{$Lؾ ` 5g~QK6{L&얗5?{6?C΁ v Y9!g6'pZ[YzZEtG3k2a@󻙭w5ZߞsȳD}_D*A.E 2ڼ Ž^ap!1}?4 wUS1)` 63A9է͠49+,}#*eT00L|淹t?tsn:VTթ5D_uE!a3`` YBK+E/!q볯'rTdP#,Sv4@N-kO-m0 To9DV!d[J%T^b %SDZ/%ItUVEKfSԻC9ru(x.XDB _En?[^^S;)hF|cb,`NQY_?GFSx(af` XL]Hb[ǺoT yP6&ᗥ,b=姢{D}oL-R\y¶G_ٙM4IDj5"3`H= dY3<4B<&VT'Mߵ1}M|ji>n?tS:;RJ$1AU3+PIX1u%rNZ  lWFԜLnY=e"!ׄ ([T]:# V"9hi+u+1gdF4Lpλ֭?AA-R-w 0 KͨD cA\IXFw#Ex 0У ENJ=Hɑ% S^NW+Cck?'Y=XC8Gix"0b฀ $ʱ-,9XDAl?wD]0EcK {E;3EIH8xvH"VL'-uaBS2 dͶ֢Lt)[Dd8u*ʰ`Б*a($ <6I2@[Xx jЙWO^jnlAwrsfbN9&yr/(t#WgMI 隔S\}+cXpib(o]nS(݋Uk^]_V3$s)w?njM/~lv*{r!?Hgk2P@@iP m?K'vPg-#' B'dݯjDٌrQ d#V~c 'KƌܳwƂk/ Gf|[kP`c8oe{v+ 7?9lz'38g-Ci%wbq8ZZ=X˦Ս;xn;B ՒCZ 6 g!)2UnQNECHzx m˛DwhersY(Ldox2w?5mS~s?ylN^m,u!_S($V+oAS"uV<޷N+wC·Af@ d0lg?Kb#7-lI6 @)RLmfpjU8]'&@EQed 8]i00eJ OiM̘*(4%4W ,b+{5_χKSTdr46 !A1&DuY0SmRpec[Qje:F6o괕oRyw+ 7Ԋ)bz&F {J$MKy%wDOܭ>L235v#W pRBʮ?p'P6r)ߕR (>I 2s9I MgFeoߥs2jE@zTRݮ]> $]3V(vP'IX7,F]J`;vi}@{`H )Nd,#8Z B2 ]ί JSuBu/0nENeoTS)dr-IP  u<\2WTF<0͇g>1)pSq&D:a_\o?䪀UUBh5X[ɞQ_N T~RV5\źr`TxFQb#bjUbGFc  P&@=@ }"8IxV_rҿ6*U@ldO3_AY{ +/|M}[|`^u Dd(gVڑ{|J+ߟ_ߪSO_IhR]'0$6s'PJf}UJ $@0upuch6b8ngS|8g>^ &`="RΨ\pX"^O\d`GXkp/[\ S'_<ˎ*48М6b>Z4NOw7hm2Jg_9?uv{#m/ZyS ΆmzA4@r|"hχ6Ҏ%XҸEewozf5*UD  %"BB%3-Vv;gGT0uyr$ ,ErP)+tL_KRNU/UJʯ;`Z(=M8z2g lC`ۊ(_koY( j @fĸ G$gteƜ}sKJ:P\!s+BIULcLeB-dlHk1zV=*, I!kO)p#v#v" =34gu y /\$F ex=?}WNGEO^GP )rbT;#&al'IDGZQ9v\/|+=ubNrU0fGdl4 ?Jeg5NK:Mh\j&J/.,\gмRN9!BJS I/ p k ɺO6֌c.Uְ9LH;(*k:FP{Nw"BtdћZUzknkQ_P)"0^c޲Ѝ$d?YP4c-0 %o$΍4Z~?GUUj5F@"EA!eDP*Rdx&eTO.˯]Ͼd?Zy/:=. a1 Έh! 0PzG"FN+ĩ !TV!ɆJ sϩ(aQ61e\Ұbq˧p"xgm(l s߾1(.y8d$%\y45a1 ò +j$Wt bM,k|e+㿥p?w)[Cd34`''|dNA_ɸ+ f&v=Ŝ^JWre<ag&=mJ>dV)3g{(V; *0!1.=rr↺s gO񁉣?n'p? $?ϢwEJ$Tk=qlY&rP٤Mm6$e&%'z#{H\V9Eڒ3g gƮL'Jeu-|[ݨv#d=[yr:K 7?)At;_dM df;:O_GHf{9H׷?xUFkEX %@r(%F P y` CTovạP!AV@OA~d%3P)+5b+b"@bpx`g$'/hM[cfΘ Yp͏f7yG`2d`o5p00A ]`̰mȽ,`xiݟ^&D)*2ȩAd\?;λl~@ H5eiڷ[N9P9k=UE9?({H@v{?nLp ; X ' Fa N,=L:# yBBN R[$Le5E)'R^j^Q&UjOPsC I#QR0zٴ!RӻfS7rT%,8(t@U+$pYcxʕXKC])3R⴪ιR[Ya|(\R0, 0pX‚I/Ŗ윴T^/' Z&d҂3W,3rJ;=7,Ƌt&9zZt.зLy;6i"%zܒyUǍ!Zt6QUWBP*X!̫k KIؕZOn\nb< ! _  HQ1+@L ňD P;rWJ:+k8̡}bcۥM0YFѥ *q^'Ȑ^hRIh_m$hyDH={Zݒni kvzW~ᯨa"IJg"U$? a>@w$ Sb3cA'T$V}T,;dZӃDr+;\ #PX|`|muGVLn  }TSl;v--)N3q +SYg[ifq^:3NL.]kdCͬcYӍ(\OO"8ٹ[?T,[@A90D~pe`ƭM-g5)~Y%tAL ^k#^aRde&Pca!St&@{n kmO@(B61$GF mF(!\=OONy8:Dmu~w Op8QKOLʷiOF8zDs2jٓ.Vَ8wg d ͉%6}rZ+ tl{h(] 0 4`0%<iYӃ*a!.1Y#-rQۇQ'"?45A*IH*9I8YbKab++6se|3=P{qd=T&DR1 =#*]NմkR< Nj(d&**;- Ly|`՟36D"M>ߢT vwdY5!`,@S4/^ )#@A!>I^S,D!uZCRx|n!)B9#vu&Gh՛ ˧3WU،*ͪpd%t8vOfD.5߸PLORP*I4yG5}PT>@.X ɤ\FqS1DV KLZ2YC:wY 0v>:hkEV3u1CcGCPk54:h;Gs %otv"ZxG^І#( vh$|;h;d9YW,C*1"Y] 1 ȳ p.:fɝu@GYKCp %,!*U@cJdWʻ-@ ­OfZV*+&@Q!Ui@aSăuŔKTKUջq@N:T=dE#%JG=j)4F .0Xxa⣰ F=ԇ!Hȳ55ҏ6h qeRLoKgb_ @ֆY{f 1 $$zh UHԵj}h0E;"&k\<)O 3fI],?J3b͂tqGtXTHpPjD bd7CCWC R,a0"FwZ0ψjh "SBN@}4aQ&ʒ0pv|2?̩oDBuA^bMa9`-; Yr;[ބ k%تY% ֮xZso3:^^ ݆Bc1H̰Dͳ >5{wo.sdWR*EEr g+sC!g! K?5oпcA(5Krۣq6f7%U5Q͟k0බٷ{Ӏ! L0J53}ne>v=ڊ  z_A7MPsYA+3myZd;]WK 3.!:=(=Y -mPWl.w&NOa5ͰfD$`? w?,c[]ֱ:^* bgKny. WpծųR?}KG~ݧYYkj9#? $,A[]tNBJOBOz40.h[i$Y˒d 5Tɂ|:;*r*"*P\i+M^$I@?y0}a VƌqpVE6M'7^_xɴKR˴**Z}xgLyd<ZS+4b.2=b?T \hf MBNNrucS_#$hA QX֟O_s_O(p0|Rx( L7)YOƐך4QQ쮸hy&F\`M, 4J/~>\#!wO ,.r2Q +4hn? S<$رb&z𚡝.bg?7y_dhe4^}9h^(L3IoH$B$ :T=ZqU5TX+!u)'サ2OMRd=ZW B,:Z$@uia= iqhMQP_!r$[]Ʃ3tv'٧lUh 0FP2fۥvk_Xx^H),܆-io{XcҒ,$-] Ogwee<CW2{r>s"*XTJl2tw: qџ^rC8pP0 fdIY/@0#g1/Ȣg # #KD! ?r!BH̓<E 4H#ɑ8b!(y*ߑ_$ƖAX p7DžAD$ە^[]ҋ>"6xnQ 0vdvy)nGRk[S;!n =×;SPvFeLa0 kk&_TPE՝.dJN-\iCb2@ʪ="Q1oȯ i ,F)J-OV@ L*+I漏!axSS-~' 24*(ūurxFصaoII*J)T;\YKLthĹ~h//vI2g؃V? N)0 XnڠejEueXWߨAB*)Db^&P.%R"#T/2DP 03 X]㩄VS9V G(z^#+F2fjo:FE"qY 0p8E)7MyteIWTJ47[dr^zY*_:e$Ov0 cBSNdZ#*H0ZH<%D slhXpr? 9Cđkk?S(Y*lbb_AfBTaTV*1`ppF 2΍1|he8͓dzIhi @`ϧƏ_%|#zYe30"@%fDU$^LS h-xC2a|М3N цM/Jlcڎ\L-j-pjMD 4 HyՑz{_|Nw/_O.x'[5L&"Ӥ 4 + tLC^'qR(/:(W ޗYR:`lN(mJH 8E ds;\y/CmA Xkn@`"=&RgA .OC4zEY{uh'z* ۳J [0&r 77A:$ baGrzD@P`8c)%`pIFrJaX/K&ds0% 5(GhF FyGqF8p^G׭hk Le̅)c`--U(disuʸJ[e }4LR*W@ ɔ0 r>F VTH dY¹]ŝL!` Rb8`J,חYb|+o'dGGiB.㙰a \_Ȅ)4Ǥ ѦBc'-l<Ĕ,3)ԺQ} ?b8[sȡ/$d9fԚ1jqQKTV}"Qۋ梑0YXAEĠA0e3Ћ@ NO!-!'bZ3AF%dƵCKOC[fK[rF2m$nU2~~SQK$e=jؚ Jr&]V.B[[-{j)5/v7?}3Ar}?ylhM"Dӝa*]g)n+EGxOarB]vv/sp 3]I]dR6V<1{i) c+>;8r)$ ,Rz΀m#T|e9 rM#ԈO̧c!  N5fi"b5"ٳ+hҫ"rc xܜ */USwgiFY2GMYͭ2ffg/Mo4j-j}kgooEMߗ?w][[׏-c^u싃TL A[ Ô& ݪ 7"y" j0l(pX@Ӹ2zx=%ds*I <.36P?D! _?}$@Ԋed{Ywe. k'mȿ0cn߿;Z0@o@""$$3vGm =H@;/$fG .hj(,BC> 8*[$d~?/ 1%aFuu$W(if0JsdU[n.F:?ʢQIR"ҡʨ 450yĮtӍ!r,Q @q4S<"^-G>YB_d #WRg`a&W5V.,!=SEB|tmյ:Ñ0h92G2͂dI_WKLCb+j "[-0ȷj4{oM}䰒wr*BQ;r*X?.G?@t q2aM1a@X|l%[a!?L? VuqW馧jhke׈BܶHg+k9[^ĕeT4=D08ȻzVaE8q]= GZؕq(IUQ4?w X4L@  id#cTB8?T2nB_¢ 9݂P4p` 0$F8+JH*4ĜoPۣ^?9De\tLZd| IVO+p,@i+VͼȰ )  EԸo>=E LFw@ =ES.i*Xtض4 zd?4pP`Paog{¿OQu/mpG1ncIYSQٯO<*'lq[WcsK[k uDUHEB:VJIVROb* BN V;FRPFα nBB#O04_F|a0 ϑ^׼D.q<1p''q!2LUuda}'GۊԿAڼAt?Js ,]ψW_uxdvdIճOCp)eʓ0"ny-X PBDD&flZG=W""K:%xÅ`ڒz Xr:F,a:PDƗ:, E!JUH.q ?; NSP`!@HyljeE3!2Q~P-,$ɢ?j5TFbVFj= M&ql)0Alѳipx]Xdc#IVK(4-0>E%T,P8hr  5l,(?[x3bՅ\FU"O&^3QUZЗԒ)<$*C\3|YV!+ A&+g pln#tFخdgHV3H,B1$kT Xq̏藈SFQ ed,f#5@f`2C/YL"Nz:~Ѡ{0Qcwu%Ζvi uG3VD 1 0%4AFWaywz^H2i|A 0`\K]=:(_Zt^v0 [J{bs ( rqrs^.8ʤaPIBF^\ P/5Q+SYUxYvL~J~W]vK#7PJE{?6Ǧ7+S,x\M]TEdwH鑢۝8ztaiUJJ{+y ԣXCHi^L0"A ᓠzuZ0|In׊ }u=N@![AY S[&}5JgL/0p°eZrS;3T=6Ohى{5W73w2^.Nݪ͵5dTB"1:1b!?inj+xgXPHP`f ! %~oBkUo#VA*2}Yo.kZӥD g!k-TmΏyoѰ5@r eJ}!DOCohͻĎ4Y¡lTs-"ԫ&6LN3ukҵ ]v#% ,,nY NDmdScأLp/aJyb54GVQy5H Yi²[u :Y|R+B6[Գ_;?A(aH1_-yCico ;5&2e|(Tm^!XJr6369o߇s4'tSbCV:FЁVnGA8m9@!r$ csX+ᆡWL`k!H2ͰϚևm xI F Hba9}hRw;[HߐJzc*7K"fj;tso:nAI clXRQbUE% [-uXV!2fuI@"rd#ZY/!*1&__ $oȵppdVD`.-KJ%wnRc[(pEtvf. u-8U 8dq[UOCr, -@eW Ȳ4R!] $#Y*xZ]e!hE$mqؚ~1LhԊDrҒfzKU¡emSJ&slws3ѡS)k쏡Gd\oU0.U C0 9a> ixa96m6&0wNU? i&H`u( 5.@ƊJ֬ 15q!8FI,'+0,VQP4 Qg;]ί>m7"Ifٳ>J%cfPd[Wcr+:1%kT=0V7p""0ۦFHvIvVauwXa VVBN8nFxѰ4Y#nvML{70G PVޔ CqcHeKVCč.utP0 R4 :5<kٛ+e3Ki9H3nyլN'B7@Ѯ`81cJNH[2Tkk2ralb-[" ry-w`bM˪"0cQpd %)ҹY QE[H_H3}2bRb@X1\I!WSy4yjyGHSPd5\i5mdWJTC72.] #>V0l(afEo &鍷}w'jcEeoV28Kz÷PJ0,AU2 ht&$e #+ s[a@%lE,#K]QP6eGeK> "Vem77V6Š-mK5JB<$/,e{ڊr1Zo{u!wdetR1)bEG8p\x+HI x #TxTǖt L-k勧[gͧ}c> %IrUuZpl=M/\1XhQT.\P &D%`{Z?d{HK,/@[L-a_$S+!1Nnu eso-W2"ųEV%clYX03A#2EÂu@]v+'Q=𥍌 IËH[FZen,>?`> z^yhKPDOt( 9Qd^SSBr ?_z^f N~X3aBX<4`ډ/!t#x4*k=4Y GE.i@(u [U/r|xHu 0}jŐd`WG2X*.7Bu <_h+tN4T4-@\,ZJlK%*ܶdr)VI)CZM0o+`.U+y)&~>d?!7u63)G88R턞*HTeymY 8[RB6 LP4h( S~Vh&T d@$5׭]٢LTJe".-)ݵT$θI^YRx8Sc߾{RUآQo}ls&6Ecl͉L'K\n(n@S@ `QjӬ Ͻ Z$92M /Ci.@/D!Ifr5B"xt$5;p|bYaB  +QVgZK Vd{YUo/0"b ]-$mŒhRVɧ0C0btJ! l h5úZާMUYpkޢ[UmLR-a1GWo)W,-@Z*;U7\|%'KqF6;D8%p`"Ӗ 9alN)BYBA LUQ""ܳ_=QHPEn4w,Մ )h22k6"K?^N IF܂}\n"{/ʡApك:}YJ/j(VARaKRsbR`w Y%_%Qd[ULp/㪷<.Q)[ 0ȷ -t /2EUdɤfr̻ ]O_/&aQ:1rBeQ@)#8X&ތI ݕW+]ԝqڕ]RDTpsV2Y.Qn";Fck6{Kb8@xȍf?N)]H›W8&> 4I%zR;{$Gݨ8R&-}B <"EEd 9(N&)Bi0xy*$&!wla[ Id[T ".ykpZX* ]"jSx/@١eNE|n'3.ze*52Uy\}TqId|[WK);]-Qg$Ȕxg-¢D]lC:4Ve#:(}%[Ls(Y`EjsY髆bkbr @":RX^CMT )bR" ii'T5Ck)sYI)8v3~?U-W ֕[>q )(d89;(gc@,t;l < SH7\hxd\ׯy%>E~P;)ĒH~M ILdwn&iAm I`,D)(Zco|RkzTdnQYs 4p0!j=h&}g/ +pI>bM|\N%BI~]8=5S$vi)*BhI4X01\, xP'Q!,Aue0qúC_Wye^zqOI[5F-rO 0CDL~4| }uJ\<^?hvs|Լ~nn;[[>w 7~ ze)E (͜`@꜏M tK} ȋ$ I.*WMJClCaR[/BK:vUjŐxV :a2E-^dsBsK/㪛V/Cp0cj}_q?g:Is01ŀ-ґ+c8&lDd9*b[XlT]gXEB$K XvtsXtN,Y> ̱ߏeW %R\ؒ#UK"zBiDȑPaWhٍ\W*e3َ-Je,mGCiSܽlwӮ/c&eυ7GF10aTg|IŀS >pfCKɲRB(ҒV*~YXLʆf@ D%([^aEóR/(6UDV' /VՍPȘ)ـR%)SA#('w{S ˱$ 8@P( E}ɓ4ñRAYb 5|eFbE d @I64(/ųS'N9&6lu}g?qdF Kp1J12U k /걇^ЅPuI("C )#霔X +~ DYš[^}5te(*'bo7S!mcJ_33kKQz~iűD;Nќ,~xg9)9"R6QPȬd Q"E*嫖y]!HgT6e)@h9-viD%-jZQBy@ 4ݳЀ1+5 !_avn 6ՑN^ݷjous BŃ}tXJ6e[iv%`TI-DĖjQJFuZy(dCY 3p0J=I@ e= ȏlx, L *SΒ0y5 Xw0@ q#rG)d@M6HBl*S2!Nv_Vt^b^YSE@uh8|&20B% %dR~|<=_yuP_zMQX+8hOVƥUB!ǎN%QeBG %Mg:x{ hs׭+EF". NfAgiOo0C/byGώ,eIW xxeߩ[}ʻ:x[(zJ#Y7f,݌[[dDXs J/:5 ]+ȿ $bNT4)N Q5B9lmXO S}LU@cV ;B3Ws r0t6RH<#y D8`yO?!lJicushK2 @\{avHn颋& &=I+y5*aJ8\"$1 <+^*E,eX.4aJ઎ʅ:paVRH'\d^ʢλR:/XgM9&HDTk :`ۆݝmzޥg f``RP@B+$W>q!fc=?߯+Qb@#v !)>6<֝HtRn(ArT R"f)V\[+Y߁&v[R65du]VkO3r.A0)4%aY0W.c%׻dV[,y%)b% -g]'lDžVxac>`]&G:U2sJBqdՈBqDqdDqēd&TE0LMek`<<6?K5݉r+8bG{B50TvUޤyeVH*Vt^xPu c)ęP9Un:7Mip(J1.~𣠹ggDWp.]zD p<Vk6?DV%BCI:M@L _8U"!"A$z:rtfAp'X Léh!њΰBd`GZs BP0=#")#i=! h~΄-0J'WK;WfqRp|=QU"BE6P99?NUsJ.b0ˢ9 Cgfwoϰ0Z>lЦ+$I :oa?pBׄ͂|a-{T:@e)wuB8*%BWps jlLhCB0;(@p@V㣢JHUzYN[F/y)%DECxϔKpdcLYL2/5GbBD1ũ^ddH=iiT#ZόMhfj e\(BEC}uE{Lx x&܍tdqÁ"Nh#tt\E@n0;(>x*%yo;@D$jdp]`VO-p'Z"3e=Ȳm0`Oc/߽`big;.-{^EܚD,>bRJbAV3k:^8TwW``~nR,=%̴LX:ק_x@YyM7#J fC& xr IM'>ƾhQl,ja<;bZ;]!5Fg-qm*nXT9Ud>Va79kt7雵z= 9} ogj 'PL %IY1 "+@c;%=jkuyPNdS>Yc/} $aiǤtxXYt# @'4)G!\,Lh4ynso-E~($*FeӚF? Uu!LD2f9jj/ *sb*9O^P  @{2ލȢ0b)_Ѿοu}[vC> >Arp$Tf_Tfoث /XŇJ[JܻTy>@©w"2 \AEGPVF,YNf֭愀_ֿ{cvM>wFrB6\ dL||y;dZG9q0 ",egh &7w\V;T;0E"Ӂzt~ t ?J+ }bzAO=U TFiK8s\$*-"= Lt5b I1*yk^Dᡄ5fקNX<WKSPgX*a((!~G,@ΤXZY(+cY}0CVbZZjȎҌKq=f$,yU;ҨK>t<FvgPԦGwX :Q@,tEU$>'qY-P"B@i2i贗ݕEպZ.3M\ޯC8Iji\ UR%{_R d`BY.k que 1-ȭhHI$f ԙCA5zwILvfbhA%dvar;Q4p /'uovev? .X/gSJ_cdfgC tw !cU5_rXnBW'-ZmLEƚ%AITRJL(lf˾:.oS[w~sHJIM(JPs)L*@qmaO-(4vKndr;{ 3B* G"fI aS1I[#HMB/뢊왟H΢XK8ULi!zȋDÔ 賂>^>}%xQƤo~U `aB_tmƟOHn7I" 5.!Oz4N Sטȝn"^U ;HY$9+ff6 <%A>T5JÙ c<nfﻁ€4ng* 8G( ,?u8Op $$H["71-:z:@%)QDMX;0.V^lSҘP*1ǕaF: 2p C}Xx!bh]Dj, M PD+ ,a\1ILƥ1`z'ފ_}Ue+PDxCM\IKh  K wr8lr6QfMvCJǦM+$K&$<}+׳쒕cF/>"= r$ιR&Ma|Y/?XM+!Bprh.qͰ.PG$2Gcf vwku w7"*_;Thq ,4BHx¡`(Nrx!>`",=S'*?!7dIRCoN(="qU )Dem0$UyVS:*yZ(CO]}:kvpf<Ѭ|:2l`P1˵og/њR'DkTqњ S!ȭ G` ;~.ؼ?ڋP_I} F*> d`ӏTR ?9N!s :Hrr8^WdQ_7~|el rC>1|(yI^bYGuye f=<.osq@P60 6Aauԛ/YgLzja@h`V2gqbPC얿[ldejWѣx\R(A ,BݏI t*yj칎\AvܔAiTX  FPdãUCiI%T;D &ReN(G#J1Q;v5{xmZ&xׇo/4//H[Wo܏JPClTZ{$Ȥu{vS?~ems7:|)J8!a*ĕd9 IbSL70(j%:Q=PNX(L Jjo{dw2,1eʡJzEA2@q`OevffR&$Hvb6¹3!i+t!W=b@}徥ۢANQqh(6IuMo4Øl`&;LCC YHnHmn7!(G!d#B|2(L 1 k<pHq-$֑iOvcR/U[4zՏw=aXN-5%ND},ZfLY iK|ar\MnShq>mVYy\N )=ɒB:TEjC!iM*bzz#˵ru3pJ#YsB0]'4%KVM`.J q`qxX0fw66 +5Xc[E7X{SK{Λ8nyglpp4A-` hGӂ{/q2_`\D*#8H@Д CȄBe+|BdQIyd0.aytgǰ lg KtE#;%Si[o8tn>:H״Eo<[*v} gCLa;Fj1L*Vf~ZZ[b(5 M'P^bbx {yZKg06" oCcT(r܅0OvU2ܦg^*~wJ쥓ss_# ʊ I}w1SqDe8`ڦv 7Rp\X W_ϧ ` ѣL3ӻW>51kPp[^U1 ak\3YS$hDg/dFDp/K_=GeȷTϳ>1%u,y &$ VM;ᅫTZS58՟3*$b $GWwV-&کH?H0hxZ̡) ?RӔU]cg^k=TZ@QH"HDX9B0\rsؿy3rg"6X~m.nT[aEpZzm.&[g!(Ԓzrѽ*2ǹyk͚ٴ04 Bg,꿿0!J>TˈIDmH4y}G‡90gw@8ߖ~``((.0 HƖQ㱚 Ed#pdIV/Kp,": 8Y= t7iS{˕):͸ icm~81ʬbm=iSvu{N:,[u_}~/^DG@M ÊJ[!W13,lG[挹$21E7LD|n,^N)Qx;FI+RKC-P^$&7&Yh/ʓ#fN}N0zLjI5-uu -(4kQijz$z%/ʫ1g`@ KA,/:5oƭp>~t-J)yЈnA IUC@P3a#a)mA0./d3JZ0j=,@kǤsx%sycnY\m Ci u!UW3?Z(v&'@ ޏ +uv^RmPL¢#ks@2v#HbcB@ku5#G2qP%#%K/Qjv8'tV|[tY5Iphh6':јRU(=:qaG $SU8(i',ƪtU Ī!=EbP筩pB@Q ip&R=6x2q77 WgtTE/aFgD%M,"c WVҺU3򨨃dWK]d0j1&-kǤgl=ܮ4Utr݆u)GF_g~٬b"`,ӡmZ*罊{΃!P\`]kjDVQDpG,(7+Ǿq=N 2 4y%*b/YFHq*28d*CZCp0 mǤ+':j6M)8:D=A: ɠ2(YT9jYF$R(Q AQ^nTj?YORk%WMM L*'xUDznH̹e4ReS/`"(2))O aIye']Y""u QFOiIffL̀ aAD@%9@&꫎.,"Sqvde42"$H56RVCQ.jhJѦg/J)z:&.Ȣd*a (CYqV7zh/1ӻ|ck|[QzYd/L[q21Akǥ ȹ+͈ۿ]٫x434/rƊ & iЭN 8?k 8SYLO(PBonE>L ]o":p#HeV,e/JZ@p#dȂ]fI"QOdUǻ$*0T;A KtX<ƵV, &g16=w=K (Xpn+p'(0@6m9& -!?_u޻TE ػw\d~3")Uim5uՖkV)O^垈,՝-[O*%|l+`\(^5d;-O"em.jd4J[r0:$bFmǰؾ *xcQqyӐ +Q=HMD׎|C~ݫWѳ,&LDmIHdM`Q܍2 Ҏxl"u[Wt=l٭T;ldp%8אn&P%YZ7;(}($pZfXi$ Z6u^ZrUh974APxjqdi &V 29ӡt$j1wjVUD2$1B$DJ{CFF2x!e],]52 & PdS !X [C4 7[Ց)R_J"U>)GlZTd$ONXۉ3r/~' ] qȣ$k"EƤ 4R>a&;8lyf-Zywj5FKSVsuY j)"SAʼn.2vt4$^| ƉOI.DzV{oY ~{pR>̱w_v {Ϟ#eJdq"x8/Zp/\? _]J{rŵ\}jd le#2IeA!qA߯(I@:wX&`aJB*m!T Uxs#{Pad!i%j-vx˶:jDmʂA:md!?OCr.]"<\0QH #1޻+*ݓV$P_yGEKVA]٭֕͛<;îK;WiRH3ńIw&" :oF6{:^UߧjxqFK'P+2(\~:]ڠbTu2N{3VC*FKם2ߍbH33[r0$>tiMϩsh>ڋ.1)0LŞ&) #!:i;@pJ!FWjcXx$CG嶮IU+c54kvC;Yjltd' JVLDB,$">)R-H,#QAwYU[d5B6_ 5VMHt"VO?!0"Xԥ}l09g| [wW'&CA2#4 EYD bIj7l0Bvo xoilUisz.qݭ-,ƩX%=E >"tC/:ZzԔ&(A"b$b(eiWڭY)Ͼdsh" K#6dBVc"?uv&,X$R$Q'gw 6 `*!d3lA\eSA*7C4~\jL.xv~Mq ږaeiiz2!f8(#lBRPC8M#W6DryUGE 2T :h O4G.^P@Q7hdWEK6-c0eLN-=lhQmѵP%5RCp6Wj.I MAFnN‘lyio%&ŀ@kwL^+,Tz)ꫪvۇ$RHgvּ 0|ʑJA^340hQ-q=?/TŦo&DzBOw  ȕl b(3S3y$NJS) B@.>AY,4jC4aC1miDŽL>s ]*GSTWM?r C.13i;&M4JEf ˛dJTSh`-J%_PU, lj.20+=՝yE`gPQ-~B*#Z={ZfX\޾jRi.G F0""a%E uڠl3e;ٕ fꠥT>' ?;n?xF^[cJK>Vڣ*) a3tAlV~&5IF$fTO(|ˣ~9g'TԨXt.dt@ p1B<#Fq_L$ȰjÌHg4y|j(p>MH?y>S9>P^Hʤ(xd)ñA B/cfL'*˿wb*#`$šT)$pokp'G&IH*"xL|] Qeg}A,4T@+0DPn̩$ͥ@X[Vxd2_HXkC,!%],[ @͸mb@.)QVcX(U@]}/x%QDjz nw!j]01a5yn:'M)'W"cci$8DA,,TH:& !JE:J]Ԑ 8 @nqP=˔ڏ]]7BD* U'=+SxA̚=\#uz{PVfh$" ΊǮ[3E[]X+n^ZʁO8qG̰{acD 6L`<߽v?릏kEdD[IX -aj>H,[?7hUUk[zeEAehRB?Y*-X(=XJԡtWd.@~7pf ƏxgѮL0|,*-jBhH=봎YܕE4D:u"(m"?)㜂mMp 0iB5UNG̍n￁[H dGR`aBFUCz2/vA:#ѩ@T%*j!ĪƽYn\#~P*öI pح m1deH3,B-<#e3T,+d Za$A)1H!wwlԕVߕ ( 'ꤣ#uҒqPZP_Jrs)\pAGx9G!Qюu=3Qjr8֓ CQՆQ$$L0o 9jjvz"-{;ƄzKDlMJf)l!wwQ{&r9^^3 _o"55C@gt h΍#XHѰ[[@ʙ:q$\ $@=QfC=NR9Ωp ,*Z[۳7 [8$%9>*kKdo~GVS&2p$F R0HȆ)T(Lvig4 +rԲOw 49rDj~?b O / !*QVZH`=ߞCʕbR@C*ȁ$IgP{f.%^Ktn 7tɋYRY)VJWMhCcVTwdrSUQnc 0LEL8E6 2z1TIImF@d J8J2%r< LS\Ņ2LL QXq qy +$y*d8Q+oB0Z<"EhG Hԅjp4d#J[AC54ˮ0r4F`x=2@:ˋv5hU:z OYet0ҸNH#X/ʺӻaEQ9*R?NݻK59w3Q#R.BBhbjyiuN]fR=&9GxTƄ]'H h{OYwJX l 00ġX A@,y 4 DRGb@և%&UdeWO_ +:<d'd( HYBzɗd8\[D:Jlͽڗl7̇F *J[BB}C"uu}LCwt1w=ST910 !B`,âTaphjCFAfv?[Hj\JoQ%U~"LY0C0CtJ"RCGFxVG½i^Wz0 XGϝ#44ڴ`NL/mY (!9KJesw8SnD#=# :5 J&Go0b Dd &FP.!2&H_Z+T}>ys9K.@muk7qV+UFzZOTtDdu\lB5Aq:vp% 3v!@{ b92- fkb \0Y -5HbC"jX ^d!~"k sUܳRfsʨp%S$iS*E~BYz./=R: JjT 1Y9WK}2gdi<\NiB1akYy= $uՆgǤz0wΎh=%>plUUQ%K뱑a0=v8by/>f]Ŭ p C;汘swK 3(9WYSaI]EjgBFa<#[%Lwt9 /D5qmv坦s(_\2[=Sbg㙇BF)<H1Bs.Gt+bfެ67' }j. d^6[OC B3!=` >ڋ&b!K v1-eΣt9•JRUlgj&J,Sp$ J9^鷌Su^`KW'-g0$sG{7a*^XCuW~Lth g6a!v #NB}XxS+NeE1]חV+V>ky|^f+LTcG߬X/~nywoO)XfCH2P(6 __Xgt|>(p1    #1AeҬᵉsBd 8ٷa -jq'i0h*+Drh^hy&U+r.MTbpKU:UI)?'(;uGar'3 Z^p0SMeA,mk\VV&RwSW-+*h?TJZ$ BD`i*\ 8]<uj7Ir2ɤb&F 5bg6<*uQ)C2sӦzEyr# $'i:/9&\S=ve2*`K)PѲI$Pt;q 4(Q#IiM>*&M葜%@$L`JP;!r\ćՙGȳ9BX ; 8vL qC'@i{m lJS}ˁ=,f84z܊TR ]5XdeKP%e^JP3h4.cRcCd.G@0cyTz8:L, $Rg>@HeW Z0\3+wous i*+b\˨;] s:aE0=td-Ѫ&r IAjUc䌕Oܑ̝T1HGs Zhf:2~_dhfI%b%N0b "pÞ io~;v]*04C KM`bnCWR,2`b='D1^ ..dqZ7nS7qZeP)lU]̢AIOed,H`2ie2 `Wm`fl3k5!h1sκ% >OE|S+B^]O\r2]r^իOE)^j43"s#huzhURgh-]DФ Aa.fL#SĴ|*uoXN4 G\1zbBUsk2 8)Է)O-UqDAf4ڈŴ`ܥQRH[l>!J,:`+]O,r@矗i/Y e]LUe7 N{Gl_NFEݼ`brB+YϜZ5dE&Mk2o- OlҊc`w?#Z`  Ef rBh;y7nd g7"fSGr&c?'I%jVFÊr;<3"_C@( >S;C(",piNfE6".L|w0CݼKȽ}^lJ 'NLJ)"hj6ppb7r:s66dg3̢373襻-VUTk/(yw~bZ0 670ڼٸq1r&1|wl<{*_"T҅HTw<'I _ O*$E(Qƅ]dZ/Rb4iH A='Ȏq&9Q4&QS*0k%.Lir148`]j-芺K*2 2K3cXtf$, DG`x&(q8M\ohH (vPFJ]ׁZ$@ Mќ~k֭ʺM@^Vf 9+U @N^*090{+`pXE6$Mj(ؕ/bqq_ѝT \*!SE#Vgƛ(I@DI%LG$LJZH@@.dֿ A1ew*( 2Q=a 0)fA`dt2/L rT<6Ciωda20FX=&D w1l 0\\@X'/rHiH!ʸ" ~=l@;$hj%e8GE2F54!Fۇ)XU"GʊJZRNv#1.H`esL=Q3B"eQ2 iXN,Ǟ%[FGLHU=P "Ez*9œd/pZ&eX9(J<4[&\(-Kkn/EB[M7Tiv - U5dK GE!O \d֣}g@IaDH 59hsj\kowT13rdij:E]{}R=GD]#Oa1#񡮤E!\(@N)EVdPP4Ap *T`dL D]J  R`q YQ*A))< AC=}p"A((|bbT? 03g8d Eђ%([͍6`ǎiY5-02b..S:6Rb |H/{;A$7@IpAhc"4'C4D?@Ahk{ C0fIvT|G_#ǐ;A,6#G \Jd1=Aቬ/TA ƀy""&Pv1̗2ڪqdUÀqjFș,|Eȿ]2l/ cP"?24[d@#D +$)hhr'4I%%yB8jm LOg ?ì!i0+ 2,HhOlWBY#R χ q0^nqWxntaGg>>>6E/m.%aɟ4][VM{15KQ)uH̚p @)&e7i H`Zҝ Li}r;'`pxA} #܊Onl~{d|_Yfe{],)a}ǃ-s 9Yq bgDIC=)rwMY'ʓ5{/mtE8+9Tg %!rÿ Z( 䨝,"s BmfH2UgF7fF7oCHU$2]$iSE, "}FDQL.w?ooa(N|6R2~dɩb۞Y!dp;pR}K(ZHxjh{BζzЩ8T`I]#^G9[8?CƁ|B n$D⹱UZ׉F(mm% lk8 4d+LELCrUF 0j _-0ld `Q(H{9H)ƴWO-ws01ĐIJ )NR @6 W&"l}~ɱ>7I.6=B);:L쪝_·eUE7$:(hL" d@|+,oc@@~p#8g sѹ)ZMR)9F[hC/N^=LU?M[c&:%5GJ@kO(.F`aH;C{U)5i,Y`?S߹X`0+OWJe! "[2 .mݚ8rFGd{KVFBDB1&Y,𡉬Y:gpPts%5\VfOTgʦ~iaB\M3S\#'&W[Jа` gz% An7)z٩RYH&L A@mFeBgO9AXPB 2S.1aW܈5R?E#z ͞ ͊NȬ?,HgDΡZ7aw;vWS7ȴ$ Ƒ-,-;as[k; @|TLN3D%مfD=.JW@x{%\AP3 fZ2*.T6>>B]%V-1rPE TP( дt "PA U X-ۭY*iM,-M4 \=ۡ_=f7:Ytj@kAz~?o2pHPcDw"`l0z :GƹV:`ҪJG`[U|NRPNmZQ(Emw$ f -dHd6?VS&R1ۭi[L$m6 A_0w6_U֏QD~<5* Oھ1V( bf$rHڌCh $crk!)ŵ(,u1zb!6rȴv;T3;cuvW? ̎$xhfV-BNS^`j}"ȰcX5¦ ѣ!Օ@¦"$rʸi];0 bN`Lqic5D6CLry&qIڹ)z/\zי(TP?d$ZUS)p5L$b_a!opՑ(05 & {=MhN^n3h4I\ GAx-Yo׃yDhŨG\:j1VNCn.4iz]fv 02t"YYRx]~)iOeRE3"]Q\h[3A@G |caT:.r#_Ɍ߷&ϻ̳nV3Xm@|_ۯSO[} bwX9=S''UjM9R_׭3RUvbRSk7dP^TK3,w]k,QPב* ,HhХ5'&7OB%GhrWf. qߵzR W3#%?) zr|ƾ&)Dc]{yl$zi;̊CeL25- h-Z!/q&zKiJSxg_ŶMPhZ>Y\Ҧwn{&+1 e`C"S,1G3CRZX=@C[k$HX=f⎲InoMm]N`T1HX Fgi42`RRhi5يAH48OcOƆ]ńj8H;AZdfGUK+p4D-WOҒ,d ,*k5HjaѠ8| at&vYGm fYGٽ ʀֿE렍 Pm3Jn[Gz]<!GTƓ@@ M CSt,S|WVZELՒUІ^2wieI2W=LCDM~u)ڥ:l ŀ'Jd&L&ȩ`TIkO][T2. B ~2."XtR#\NG€hpsy']C&gv>jc_u>njB5E;cxq 6)ܷ@d+;[-1º%E& yU, aJrT Pmea˦7U/ F5 `]!U @cT_`8!kt1x:s*կmo:&O|b vyZhZW΅ò)#xy&:9и}93c*q2۵ҪZ5_xx@J (P(,|謹EH&-M:Dܴ}*-%Zl齞 A?-3 F57:^6Ho]w[m,j V`4%J<]ʚ"d FY`=ImKg̤mA/ 0u;Kfn9/ L:m wCCF\hi0БDn`s>QK[$\rFe\ZD# |oȦb$v7 $Yf"Fm+dxhNUY籔qVmgyގ$=c3찶9hm0…5j)k+ S $cjeS2%e;MYPRB6L"@+eFLJxîڷ<`Ph]%d07 !+%*!tS8b :d].WL1`J;-<: <_0gA*,|RJg^r]Zw~T\B2^fEol <"lEaA)# 3Ai#SM1[+lD"H=3R!+4.`Ith`F+ŁiI;.F-OGP^T?c1aE & !A4~謊<&rB޻^ç UGlAF:#?!AVIԎs'^둿Q2 sO-vmet(Hd=UcL11#UM$mԈ븗@T#A8:C@N+Ys8(8e]/+R4Αȑ-2mP|W/Jrg٤:hu4 CmeLH)#6j2򪂒YoX ַ;W# šcғmIBkgV \leEB%DYdta̻RR%S DʅX]'VuKc%sx@6p9a4fvn`;{cH Tb2" ԃ.ZnD|Sd*†?S9 S# :f4f8b M5Sw'ydZUkFR,"[Q,8gΊ+Zn,G8bR ,K|P=sxDE߾a!*†Ez\ʳ]ƦY#6j $F Ԁ ᄌ[MU|F2ZZڜ2mN@ A8ĭUjQ䒎h"i$LFurKy%H d21lJ9%Yܬ>se;U<\wҝ(!&)paR(K<2wdGAZ]7!PuV׌Z:M"HTLCR2I9*0Q\)iIQ8c)JKdOJ "vuYVcd)^S(8c*;KW l'2ۉԣ"O @q U\ ˥m'!(Oa)( µ=Р= WHmW@*k 8GW /'zrvKԬ%!rZMLtRGW04'oQ8@LjuM@# R=NݶMlOq[bq PQ"EdT]Yk3eK,="i,Sq {B0V2ßɟh̜0oWT%1>bec XTIru_Qd Hh¨.ܓ&E@qaAZ3IG* gZ.mUH- (gVλ!-ު)\.nT L2P]`_:݄M@*Aʵja=H5L"PPcF(lX ^#pȬ^!|3] `s|S4WFT}N X0fje/ "9АWGO6kNjpAN21PSr(H dl2UF+5-&LLSM0ipnjX?a@g8$ ܏@J$ê @@UCznv1 TI_GqX)6*KQOު '$-Z (QM n3^~;(7I@D#toOmL}n!BFK"e8^kUZƼ|M3E(KAemZy3ĀOќtvG088Ջ t}0DFiN"P)ḳ$L{g{b(Fv @'TDDMJ뤂Ғ=rz-?*N9s!,aT 2Q d+k1L7Ú| ,M O5'vR@'|cdbjb} e7\LI[Pd<\i4Ch=#> ,oi*(r!}A#4U%b!1N("jٛnk$@(1\Rh{P)`ڹ}ԯ+;ddLʫQJ\FBf gf)+HÀRP!@$! ##yk0@mR2E䆡mOpMuVwMpA&f.2,'+g3^_dž46)p}^Nd#ÎM[#|m䝵I(d%CBEgD_SVYak 0 ܧs!Y,PZFEa`I.-; yxtFc8X$wOJ[?hیa{5 ˝JJ^Ye6~ynbM̜YMmT͟wNW&dXXsn噬ئm&y&Lʘ)xH `.Yb%KuIsQ p('VcϐrϰhT"Xv[,KS%cdg B Yb: qtXM\uSWLqbg-ڕd W0Y => ]ǘK`l=2 E5h8,$RqKbDD!Qy/oҥ&PD^NYc,B3X6Nr>|.YF(:iMHN K; MU:?^P3b)<`r&ըX-z/@oѺtI(0 ?TŮJuGrz#: KiJ-,̬^ݴmSwEȘ!-]m,u*ܔtI'eTaVް;;Jryi 1{@يpN"z)ƞ ̈bIJf`k#)E`6jWaF9"U3Y.KDÐ;D y`T$ ~<#a OA^ UVѹɫrd@le75 U@P,2Z 7t"0D> -;2ҍW}%;~ږI"4*iQS? t'S`d1ONld FS$z d ^ UJ=:Yi( tK.;7oC+n%q ESg5mʯ cm&ssMqg Ss@VB*Dx5Uq&ұ ͋bhqZ#o;n@@U.&(BmR.fG ^ ]dʛ3jY'p9;2_㌌[_&mf:XT/ ʂi8+A,RW;L35 H 5ccƦi[f@+jv1l{ >Mzq@iDon}`75QI1B6=LQw?d#60J 0b Ueaq3 j(2] P¡k18Y4 ,l̀_#,kvNɿD ; ՐI$D-GjuRehp( W&FZu{:<#j&]kK?mˍ˥s=[Qұ*E cR:3߀Qa JR&,{R6 `g`ɦU"IL-P'~I39+ DVM EFm65v.*4=\XF,N*} rGQwM&";bd Np7_ii14H hkY&ʜd;WT4M+Em[Og8hx ġZ-Nm,]o<d/E*(h@h8nM*Uu"7AN |$46 ɍ.1Ճ2x42g4@_TC5yˈu@%3X[euXв`QQZ֡rs"OGE B6ssӜ8j;IRT,p.}L59/P#@U31Bک`GKBbFe3Wfl NbAr|z,OvMPO11*򩹍{ ľW=/R?#'RCdjW2rKǺ \M0a$8W\em֫٘Cq:`I(YErȦjZCj=LVfk^ȦsŮKĸB[hRJQ]`LVZdxn1u H:ILgҬҵ \Di6S±  [V X13lXb    67%TH.YP9(s9aǾ*lu톂Jhִ6k\W|29%JD4@$"tda9<Mt3@'Oױs;D"T-iLI,CVA(| űƝE-{~G0MZ<^NZP >'ˈg9ʹ8RKG!+U݊ވٙ%c4DOv}%T %vgEɍQS#+U ɣhcMUlb',\M$YHJ)yhz1=47p#\TԳR2iq L:Ǩ:v%h|U7|7q;X*3^KqV >*B#NfTЪ Rkh!i%.ģ1Y o!X"\/kSPebL1.e{VVL;EHI\w7cyk}u6 dҘ-F_5W2o_c?ק *F,D͋;桨P "X5/{LM|GsލS79",D8&MeHJAI5 (3i $NN1 vrgP5:wZ"[6N1.'nf j[ CLFM-+{P2d;F+o<( #wi$8%svZC/UHf#1@\ 8a 4\.{? rnߟl̳LtHʕ⢹O~}8 a OT(uH8`fO&p[nwFmke{r$ݵ㋥R@3QMͮ 8`kCƑ!`-7;uڹ/RƢ"h-&jg>PF;3K@]j"QKRz%E/ ?a\+h?fmVD/-- ZԦxܧIG{*ڞ#m (HdV= 2) i1]#i$mȲ)"7v߷6?P8r!X`tK([Xi2f.G![T.%SPHC ,r ǵ1Hcm3n ֨XyBNmW@@ Ǫ&G~Fzy[YV UAlFHĉ"3d! = ަ * е%֞PBZHdYG#q#p #!ͻlmrXih3Jfm>{< :`@r z"lcNuϒsmXh}7O@T ,Y"*d%C80Td_H 1, m&5_,%h$HtEbTڧdC)MBa<9T8T# 'du0R 1TcDN(lbqF W.~F'+* kW1BUIw[ وW@iAD62c]Zu?vq,w;vW$f Hv0n;O%`cH16يS}:(⒚jLv]^2 $ֹZdJep7'+j-KҦ*M!G*y!LC,ocH(4xB04*z;=§bMVWÃp C?95J8 kzHXd^cA 3/C  _Ǥ@g$v`.uh84j9JΆG m X9rf+4`uLj%.Ҷ)'H,6.PdTlq#ȹ  ( 8D(C2]h)FvO1i2ȪBFs1 +X`p u 0%6(+?m qY8cloύ-JGSHC%UF܈#sD0M _@Dph^q0|qn> -28QD.Ub >!9YT)up:VdiKb0%Y<: Y]$jϕeAc OS"n__.̒PhVKt=^HT6iņnzZ er=F|Xp~*5+Dp|&1~ 11c\bUGw8֏5-䠣$i @#vj {i5_&斦}Ì fIdOMRH i|.4cgb0٦9d ^gt F>:'( H2 GD41:"L*!FRrd">Uip2C0i6 UGK˘$4`傖 ]]$X^OG1__IOI #M.'y.e'UG8e D}2.}VJ˱k0Dn}wžtCHRSSG ]u\ĒbMɍIf>6` a(!ޙH]4Ρeo)IO0:(f(yϧܝ-!Qh1Bndغ].,U;ݾȷBꭦ*³\;S"c,D8C;/0%8W=S|zGW-+"d~JO Dd/STi.`*b _SGIɎdE4X q0n&lF y2%a6E+$ "vVfJy-v"%YD4fi4g>dgJ$a&Xsk”SU:LUܻA(AS{-׋zqػ][d@˜T+ X 8< gbL:%J= U$ J&ɦn%)S;y(S$kM0V:Ym4Gk^g/4%HdPWM`w/aI:v/!%f$6տ,؁I#]?ڜd̀FR.Cc* {Kň#ȸ?+ uT=k& B4 s|VH.bPUQPUovy&5 &jX=E8?!)BC"2ء{A&>!c[-կVt_ B`JTtDR_S8< ]W/t"@HxO |425}j#ļB l6(|]d, G$*ј}+tCHЃN=Byv+ QQn`Վ䨫hlP斚2 V*QknqVxgI`q82B"dFPI"5k- EMbxd\bE׃Y^ ?rw@qD3"o:$?N(]㥔fE}+vmKg+ nNc(`j,@l.~\2#-8ұͅ3":nb( ?ya)})0q LTA*iP 0``4 R%|$> F O9r0=IQfN B:.-(VSa =- ig4W# er=^?Qi3c YQtL V򄋂U8ȑIؤ 3k2  dd;Pi;pjS.#IrꕡIh0t°YJ_!gʜˇ#ȄO-b_V@NOpRO%էf`%zxt:MN4蟳7<'"hӫD "3!~I,."5kqX+̦FB X7G />2bA`Bb2^b !D" ]VornrJYR1NJsʄaQvGi!XCO)ZVwJdLd2M)~J ?j2Nj"&kFS:SCVe!,QPy\UdFa:a^ tGS엜0l#[!L-CY<4҄biнW‚*0 1@ t"DPesLxaI:KW= 8i<מN+StM&'LZ)dSP #1V2{+dA%a5^#92d*e [9DطWN WS|nБ@s⥭^2S)^4a~S-KHAXL4e(iqRE@Xc_v借;4p6 Ya4et3vj!$dmQi~-%ȫDb93ddDO42Ù%<5!GzXZT5cJRJAZ"$ F.t"D$QErb!6w5 (DB؄X{+׬* ^H/%K"K;h:S*6@ 4B~ϙ"i3}\8,$"GnDg&2VNT8RRNLCN<)<}\mo72! mCM:0mkBZiVRZQLȊxMQQ@è(RЈEACB. ÌH'&3Du һrZdwIZZd5PN#).)z0F1]A=H$HW FB#Jwsd]혿k!J=?%frD )9g-ZNӕ%_? ^Q@aeD"!4 (=e $([2X@BIDl9(?5SJ_kugyEVUs"^, B^lc!̲E5FQ!~3tzI%zU4& w6I<,hjІ/F*eRZ!.:j١"#L>G攃qaD:09iM#xXD2t=>4nRV8}}m tU\5-XdIE 7#D 0"A <=Hh8+SH`(QXj6TJD@?0V8 dI'ȹL#ȢLZGQ 9XkDh%R3vYI#ݽD^Jk~xԕpzFI1d !Bba~(#DF <}2dHR!zO<($V3i3W{yc l 蜐0I? o-ݻZ`M&ך gPYx60U- nPRaSEFΔE?vD i C2ՍN\UV"ՀXfiL* ~@'{d$&Vy5M )F$ dEZͫ)#aieo5 |ИdA 8~(V%>Ve!f5wcQcvب)g?o \QBKNxHF 1 , m8x5pU fFAUfJ::(Vu  ,pЮbzz$#cc@ZU #-VBX+Eff:(g~Ձcjb' >T4S~@Uz3f. ODuAsÕzKDVT29Dc/P3Ol$!/fJ֘dY9wmO?JXSMąӜB!?e ]?c_Ѽ]g"Q?ø>R{ܹr_eT/7{63֙jJEV(ԝz䷪ud^ME-D)@-c!K&UJ|^dODYNc2(PJ-E8g`Xӻ[z#?~7yy,l]͇ ѶJʒէ#&]/Gn ġ6f&Ee~K`lcW#=OW嫘w>93ΧzV{n-%[՘R"W;M3R+Uo[Ϛg,c>eW]ƖznatHM,( .}<~Ku, "PHe=[3_XW5u+" ıˀ@P 8sr28t#$8G)!aBZ<<.4ZUA8M&.Pnx~$5Ep- +gðCAdd:pR1r([,5绘PN5 1Asa4Y2 ;.KhH*B@O60ġx6om:95|0FE`lEuxB{{7q- l^?uլLxsɭ|l8Ƌ`сQЁv$||}L. 2Hz̖P]H BH~Òi8LQ8򺨥+E1(-Xuҟn1h  @ %f;bd LYqRG" % U3g hdJ?Um4zfrSµ49?'7{^=z52Q*cuuVkLs4ݞsZ-w6_@8nu V APJ"Y[8 @ɒp=ү?5Kq@>0^Ldc~JMlX9+*|THHz 8Dx0hEDQ KbҟpJbFd8NY1 ]K61%a!R3bLqiu]^J HiE =aI,(\UatH FLֱ a8yw#! OZdX NhjL^ahYyd-B`'y ޡlzKHLA!\BJ5R^/.͸X⣃TKE]ĶE3HHlDv ,hLhK=YbϺASePZRG9bn (0MQ+@$?[a0=s她ASǍ 'U@&2m#y5m)c3p)I44\% dPIO[l5"*d8a{W@' yjVofC p#Qb ű6zݱhgleYrgPpd؇ֿ%޾z^U71agff {ѿK \2At1E֝nu8%1Z؈*{OϚDH RR7-z{c7hMCxw䶟0'Q[RV3ˬ=oi#hvMcAqqd[#MV:~o9"D :<\x,VwHA)KLGyaEe_q,}@ `Ͻ#5@Qu356\ H`!JƏU5vS3] dg3T4 aҽjiNes^訪N:0@dM^B¯]v"/4%wB/9ܛmXEʰ4u"!8)Pf&A3i'a\ܺGNZ'zS)xLqz{<%U96&4[U !KMD壈 3sN-)H ,B0}Bh0tJd#M$t`~3_]?u~W >#'onm .MOJ:X2d$RaXꫪ#ɰ[>E+D@M8 pQ#"Ta8BZ@IVl BѢU8hѣ)JFw0WH4 l"ck#?';D"PʾKfT}H蹸F(~d6Y2S  c,Sdž7EF6Y+_C0f\ ;q2iP @Z! $Hgq fS"GM0ry$'׮~?o ++,q-^j9'됙%s`n"yjyY%o2m;ÿWp4i+#gn`L rxNHV*Ņ)%|Im>KI}_5#C0P|Eo-?G}|}X b &MީAT8Fålo׶D^ @( B:>LQljnd:B0,3zj=V ia,S i\x3xiF׵~+~Ŀ0,@CZb"7'nrmZlj4GeH6n"` {EF3s1JR|O1ŠdXZD^6 KY٦r~z֮=6&S6)2".@[ >QuITv-YP(ʇ0cB%5Ǵ6X\9 T6w{Rƶvc?'}7E1H@E]D@UԥqbdUZ<2EZs= 9kRωj Q\Q7ܞ(@:ox:2'ÔbH1KuJmsKI3m2 4|Z@@A(lS#rRZ0=)$k gh_M2HL),4@.ⰉB> i a*9xn_O@=`SK#6/~n19a,5@C {W^KbP1=Ys@G(`H6ǐ͡?=cJ?$-WJvIL2"0QfO>lv+x#cvoBP EfӂJ• 餿`E3a^Kx3 N0:'VQ||tʨX[n(ĦRqzg f "v]u~ayVvӡ I)K@nj1rDNGJXH[N*ֶ3&,aNyq|neȁmdGT3oA2cz1XJ l4} G{= UTodiM!z$ ucebh.X/ LQļ`\+ ?_֩q~LVƋ 1DUƴŦцBeQ\860 n%Qw[MYwnWq((PC'l/c|nR؊J^a˅v@(s9&0L( X\ I2!eEh%(i27Zf ߮BFd:6 #L$&,oa nN36vPsREN͹Ja !6Ge*WZ\lb1u3/z.f!dsaT+LDD.j0H'R%Hރd0~. *Rѩ()tYOΕ.BӖk9@1Wa:ݺpx# @T axsM#Std13Mo3:q4[k=]|t%' -Rϝ}tLgfbKQ{]Q<,x_H 5=MzҎPCG`⟔s DD͆/H704d]%E 4C1!k<ҹt1a (B: Eq G"a~&PS,iBT=HOcMA:nIs3d\ֽe*:={[4 (YH, Ж HXMUeb`IdIfhHvW[@d@]y@@ǡ gE* *9`_ag1n9vBWiLX*2`Ȓ͖X@D= 9:LTy&&^d BX /-&] 1 ȣS m4iRfҜP&@,κe {!D 7>Ygg;"P ujFc !Ap)3ICdAX:%рp y!İmԽJzm_[:_#_up4C*m(Q B/\P3%P(i ؿFT]NoswVh'k EHmd0^Cl+p,j "5 X0дl0xr[hVDL-*t<ACA2DHh`q[a FF+v51dî_R"ENu 7W-G2 )gs >ZT3:X-3EhdW@#8`.K5Acl1-48pȘ^7QA;ߕRmTamm6R43W0y;@S@⁄B\!&۵i8WK20b<qL'w(j/eʞ|H?f=N0Tn+~f$ sίZLL a #8HGLnbi);Rww#o֡\'#k,HM m ȅ{܄qߨ!p0,.tpeJbt\m@ݣ]R):1]^߉ UCX@مٚ76tW|ccs.rd;{32Z, Dkǥ ˉ,7iSmt:'_,PVLR G6_Sk5L[֯_i/a_}=}˵P5"g{+?ƈ %.Μ&ŕ4WAPh #yOR INfAmF@]c7XFu %l[e l,QtY0ǟg1m '6LO&8IQ0(@t]憑hWْF`` h G8;^H*Ib.(( ,t:e~W51JPx|(Mܹ.)VpɫEm/)4dD!]5"3{M puoώVAOw: 5zZM.q֔?&` DJ \%j!ATh'ڡyWv!&FDŘ/#1Z|/K[WU0%V-|xhH 4՘01CU6!|"FŃ"ܽ_+ gm!R\LE+h?CD0UF;i 6 $02?Z:@ 27QRyݩo)% {Q8*Ώ-ϣޠnGFW*F"/vd0ᣑeХ'Ȩ?={Q6DXeF 4|x,@"7T=|+odÀ xjGvZXp0(Ԁf2'?ւ੸*ufKQI"gjk~ 7 0FSRֺdE%1@318 sn< X-OZR͞Y]B.dI$Xu1W^e`= C•sds|,8_xy&aJ91-Q##enQZLȈ<JiÐt*CxT bd!-C9` M@G$D9,9A? 3Ca]oc۾jfd}۽r5]~W`eґ̜"m9F (ã~@l.X5^?1`ТGݪX j"}acmLN B1ؤĘ5[x;ڣ.(ċnXP;qu݄d`324K"N o Ȣ`xQd"O ?OVX(]4Ԕ>Bo_mdXC['2f\ j'=]C_zM"*aEZܭȞN18Ȟarj߿ u[16YE r!X\Hb0-sKTDp5oDH(2= ?$}q AKG+ 0 # _j/׳HR<IuЪc_d4{`y3 e N JW2^d/Ip2Z0# a I@q& GЁaBrbwȄ98i幢;fpV9l[jtĮy!abe,Rsʠwy QmD**jv`ʴ1mI*d$2PHdz̨hc/uI_YNA7`` Anc,i$rd4kcI 0_R$`d Y-b(#kmj)3oDA#P&A&J:4H ۛlz/EG2I"S][c#A3m0Y\DzY%{Ӟg tp ^'wkGJ# kQۓ=*)p&ۑt9!\O\,~`cP*G&e. \DF8> _I"{IIȀD.I:Rh\ {$/i?13 ޥ,B Є&  1.e~ d˕*K12([J  [t-_W[/ӐTWkA-$`2d@c IB"% `<4!e=",$9+YZ@ - Db5E8V 1-+]̗8M @zx..<.. 7wB} N):H Q"'iHކ؋B6!^PDLqeԗҒ7>`̌H (ݙ`ESأG*wVw]I6fh%NtKI@OF:39J2$zڑ-uQؖ> J1Q(/gj}xFHUHW 䉒"K_ MeruOZJm>ZHB|co Р:XMHPC~;z[dKX[,I,Q'bȩ$V<iT!f B@&,-n2ե+IفAB\$L!#8  $gRYK lPoYiFpF0#0l@}9't&C5ךQ4^j~A/Xb0/%0$b+kkӘ(P1F`[Y$\DJ Z^U1_qCjMŤuE,Q Hm8Ym"o M# i!WD,mlF$$yI 66 YldD SC@'c A8+nwm __67",Dz7dRM#KR.% a;dр)xEPU4@3 P Aڈ!X;tדSDžVŠ$R%t=%'7sM1WjH+. rRKSf(™a@[ [z5n)*OHƘ$@ECu1)Qa吴y(S}$o߳WW(gO0ʪ@dpC᷒*@p򴩇/VsouAbUujikԠz_/Nq$9 >,'AB }&GSAڒ!*}:3CK;RrhsR.@G4V62m4 4)N"k-dRX0r.%QZ/Ȼj'ԁ9PaW}Q^ab6FVXAQ9hݚ򫻈'ALAGd͉Zj N܏^{@?II4rHСo)Bp} ߄rDD` )P?dž#͖t]Q'g$<=T!G!+6^Dw eI)D L-#bsx\q4LG 8Á $+eĊ|rO̚jdfjƊR!T0|HFQ ukR n MX۰I,YZӟ ϊ{XȵdMXcL1@%pi0o,#0  p2ACkfDddV(F1/ҩJReUJuSUᡕj٫#F \ѦR,ЪΚjYV5@Dq|9frQ"B b^=}ؚjO?'6R1Ft1Tx:wֻ - MK&dA8lOn]^,#qB, iqCQ#懱54BPx-ƀn~Ѷ%P&'j] j/%AEJT+!!CBP8blk%B!h*PQ IXP+yT/fpOG(2% ^=E-'WXT!x~K;GK/r Yhhb K,P1?eDc56u!j3h yv߯rDXהƜ$8',H9~C|s)YPF:ȑ Kx'`D C~&uy`X`䚪 Q.Ai*.0:R\1 ] yWdo4כL,@0y\0@+t.E{25:\,Ѵ|X(TGMk. * : v"o0a%?|6ߵ T1額#Sc1rEV .250#KG 9_o'oO@A80CO4s@C$V3ޙ=G!4lj㓙d{nϙ^9d i??= :KAĔGdV&J+<5NLuK4v%A\|V5 hiZ^zy?U $D!VQ f7pp7f85z%To_yZ>H6?dÀAV,DR.Aga"*qY=@+tRbՑ]ӨCѹ4s8??~$S;a-KT, U\,S-:T˚5\-4npXI w,n!ּ}`$_0;?JؤFyqdj"&V8aJr &lDpH$tG<;-.3$Z<=>Y$kߝVNR zPڌ'*WJxVɰ $҅ZWɂuPh&hL*fA5ʪQj:!__{mU*(D,hmLJ\knk[ xmޱϿ{dƀFLDR-:%2VM=-ض * "qH%5a[ D5%tC[ fk]לk "UTm:M [QQj])ϑgnP4|x_r/\g3̨Y磧NuABNBޗe@(V"@P.)LZAca[Q* m|.)̼V#яoCIO'~H4EggWE+z5~ …JJlp]&n H# \ T\QH,]Ѵ>& {h[d! HP̼"49 >-XHq|N(U 晫?ϗmMGqx?fvWC;"GxFTOЏ uY5&ÒNPn}oTzBVaϋtP4@' o1@"`uOvAР1-,@9aPp0"7cd QFTO4p("J%DMN- 먴j%].[=*U@[M9ƋNzkcOSF;SKOw!i,qcjfAR5<%,ȨԋT:6F Ml<"x~_ IDЈ1f `?/H\QDE>-][ĸ`, (( `Ag@/j$U/ 4DtI/7[ʥ\>33*Q})YAbDq#'F 'ynz$HޅO@F8F78*mhb?d FTIDB-K0CZMJ-j-}#&0d桃 E)B X E$d0kbzAj3*Usu3OvbhK_b>.Z9Xc!aJ/k3Ӽ=Ax}kzO7K㷁n5V-;H+GH#s*Xiʲx:󛶙f^wG !0@I00ϑ(pShHt@!=.7|,<IN/mwOJ+{ZhK#zeie:Ix~2;" גC#_I?#ٴd SQc2CjC 4p0 ! kxTBLgJtkǺm O W<[W0oG(M0}cUOf`wnխjj>ڱaC?ښfo4d.Ϗޜ!@A Zۈjy{TTW䒠@9&ArrܻG8+I}sV5fhf c HpHN@30H!,D,E -d \_P 4+*b,u}@=1ȥ6 7o_JuvN󽛴Ll~Rdy4/Kkڶ+S-Mmnٸ8`jW;I&$Ų3NDޣt?0'RuLyr"%WbdzFm##\=߬\㚫0;x 1>M9FCPe)ك+X!^!DVMƥS{By~E:kM+ڍzg(;4;jު&!aKL xoM޴w~@\ 8WA*7T ީ֚mK0@cSdIirNLU1qR^:dV ^ЫoDp)cK<$eF< t6 n#;:0mriX8ANǤ$ӄ%P$aed4]pxbYe4LoXKLiUP ĤҒt]2jj#`REc8& Tf7J- }/~YJH4X, l%8J` 7 -g![^qKV#$ #!(T: Q4662ѱ$m#/i ̤ O%6m|eg"Q5)!iGd1l&sff"~9Ӏ@"x4=7GCm}5+ qg?Gb$a"!5%nAVhF%3h5ydD[ 2/;}< U+_,0lh",gƮ5ȋ0es,(zN1|"\4I*n׹Q&m=wO+أT<ۥq&Ul=C47 ׯ`-C:@hD"z"r76X';[)E7T' 5.mMuzY%G `"Q)ί&!XOf`8dbPjk,e(mr[l"mCK X]yxaTݾ/Zҏ'| ב?a@;تi˩}g:鈤ld#6HK p2%R]a, HkO>C3IUTCU Bd{Vx B34 _Y}9htp`oʻ+Z"a Hibee95&ciH:bcy(X%?Ήʤ4QS2bmX,浙&g{O,[ZBUIV;@%\:ҩ30me'Yl5ms%[@i/pa|IEzDF{Fs2YP+td.H 1:%VAY0q@JY3dX״ޒcoJJ ArD*T8vٰ(4PΑ0ÆFF}oW:Nmj[DWԘz,b\pjR*bzDeJ#堈rJQ֮ v͵8dZdK( vhhG; /NNJH"8$tx~Z%_X`AJ%Q$ 1,MrHW5Zii8KWXd=AU+D‚V8H4uˌ2|la7~앤PTj:uϗncFv.la,OL=Zd5IUKOD,a<&6 5%YMjXDw _XJ9#D(&@ JQ,XБ^ Mh p$`,X:(=}FC@NvŸNAf|x6J}QqZAy+!k1og7Hw]H[fz% g5ٔ@CΣ- 5667 ZPk@x,z$_ؿ_j S-wԡr]"F ݾ© GJ_kq3h !noAC!LFdz= {@LxFFҮ23.lѿ!%2lpD>xţ8J B{d?Gc)/o<&"}_=1o'Ni8tzB@Dݡw`!R 0L4bR*ѽV(MShs8tm_V]Y] 2cXZز1.WSngC8aXIV 0f!.?Nj5r'lI+BT F0ާ!8ժZhR*y~lͻ+va$e3ޭ6SsHr &eP\?})CʭRUe4hB(T#{KG4 T.nN;ÈTۮp/2WQCd뽭uc&IWDZRNoݹ;-mS:gC!" d/zӰ)Nyp&`.¥Wn5 u 2 A;oKVjIb_4LőJ1_n&w~4(1MP -|$ yYdB p߸oytI*K DN  fd@#WSXHr.<%Z=)-`l憰4MlPsD_;v[Wx)hYG3Ik0. e8᤯ecjQSVGnRa$5ZUTYvyP  K ctG43Ъm@טaU7%$t6_22J)xY r'ɦ!S[jb{K/;Qb7  &fh"_R6@)eJU뚯KiSwGbZʄғfݜ*#\{]+z Q `?A MV %ƋGћL vGMgtdOCճL.:%$%Y-$s+h e/zI,E?2#^`gr+)X: ?1ƚ m87J$דЍ[^fWccZ47znhY1 נWF~: "ZP"Af7Tx{u`z?rB_tq=5 /ܼO2DKs' J閇%=-N"Ef)a"/OUk(oџV\Xb~e? p pqBҲ!@%U 0# 5|P62ۗ#D,%R dT%WLMȲnvPU\P@8dd%"ȵ$2Zn:1pa=1 nJGQz}0ɧ 4W-2%<ЌmSHdsF\3{nF@a xbӸ §!4( !D`i%kQ\}#O-,nKgiԯ>L:b[[jKKqpƌt-(!DtMV7zl&>? "2):چ*GFꏡ&EdQTTH,<%RXlsr¿а`-r\0a寅A9mEF}J;d/' @#VkX]7h6wfra$=Gأ]J#K^׼(@'o$ ]3s\A@^YJA]=@Qӗgg_-H!G'2W}@AAZRAh4i, Yy1!*t$V3 Vr&GяVz5|2s} &,+2$DS1-CXDBCT*|%~S:T5ܧk ˰({~K " C dWTՓL.Ú=cL[Ls^BD+"=ӫv/Nf .mz]&Wh3ՅIrXC/~7C4[N ,RSҩ z9 Jr  ぀:ei2Ԅ [E`"DP0}Y;)TAY|zBșGPXt[N}a㧼qV5KDo/BsU~4x}ERY"`*Q RTmڢ:r= @zF'G[i!@\#fBrI,ii`k 萲JLl|b]vT嶟dm IU OD,A%MRйtǁJg;%<Ͽ{ǔkJNrsU"&lB¡6jݝ^^m`U;h3y#)G <P I {4%9aDW$تӒbnCH;]'"|$DWzE읨+TcD8MU B,H(x]~i?U\w?_VzB,ǭUUD<&Su|wwOo0FM\p(5236]]XtP*% ?oL@ $\N6A`"A;:pD'(=-TS,&qi!V DxR*ŒdqwH/BP-;MD@=efQ n{fqa+R(/y/7Z4 2y#Q\͸MvV5ctT1ID{DπlI܊R1d:,8sj># eG'pǛ ZR|evoF=Q5ަR)Nee5H2lA! M_35+1 1`w^<]c{sBҺkŠ`O$!WRtW8șc\wQyUin.\^[v1"AӕsPdCKLD.km!F Q 1gzv8jɗkD鳿`lЀiꫨ 7i;AupvG/gE (]X M9sRL{,]}Dď7s<䘧<Ԅ<-.zV݇ʠ)Tqqa{D 20JQjgZ7n;a`׮Z`$G LUEx%%r@-o(eJ;͂c|DicFL$,d1T)hu>׬ izvӷF0MD `Lǡa緸a&) &RGʎKM8 3dR3O+r2!c)aP-  tǘwk!*PJF޹:) )ԏT1³l_Ѭ;[7YhJAJD,XX4l[gaiPpqBd?UL0Ja}U0ވ l8 Xa,"ZdfyZ=jǾv|y 9^8!">Uz,THx@TO:".-NS`D59`Db0 d3I{.;~v l.9n%J0Ey3~j쵊gW(u;N[Z 0lǫ[}Dv"RU-± mŤD5VHZ 8[AR zYMˇPBbM%RaW i`K`01P$&,ʵ7"FEURk!!SMddAY C.a:,,XgP2_ J23yF/Ut13ߍQjlg''c4*%5vJ~%S֢: Ǘc>V}SV`ypB#CL"=UZ?nizokVMivzzUsc͝t!jdQwĈR$bdP8S- mo^=-J~۔ ʌ XJ6%kd`GYc0<1 Wg$oč!(UR=GbQyҰkHy)vaz%У{lR)r$BSPezL5!&cpуSǁ(;:j`8D##+N cdWSy8,<4~8k-%'TsM*LJ VtC<ĞtU$@ή}gbwOWI#  Iʧ9 ,iʳ0>v~~I%w6i@NJpr+ucK*gs "@0fG05 Q5p,n*2!3KAܰEZPI>dT" X!YOd^>X 222)E cl<.ppf:<#'yX B!dbڐ'G&~@!4+^>3/#N{_>Ϲx#/{{A P#K0*P `@ʝ_a]YWπۤv]$'L>ϛj6YLHBi /7mI0Q2t˨E 0R~ BuQ!5Aޚ=b$u%h(}3 >d qpH :!hK0T @Bho"=lhѭ\]*%_Oֻ0)LG 5rtreezS#!21ɈVdl>Y 2AKoeEd0-|PXb_Mr`'":zea ƽ_kA mAG.' fQ  .0x =QvvdND"QR wd¦Ԣ$QZ =}xP:%u`'bϴ%^)TT \poeQE.~P%,I_jM=guMl( bO62==m!tV1Q4)29?%D>N5E\&]T-#I׺S~_v,d  CH X*8G82;;H24&I8%QdmvG[ar.< 1g,1!Hج$ʉֶQNBm=^Siy9$cD[VͤW;QS17k;_@ހ"D@B'-qEqcRi\[aҔ{}'d*q2!q3~~bmCWց5K}vM7ediK6k =+ E"fG &e1.ݘFИEʈn2tsh\ueYl' V ^ԧl m֍(7ZqPnj;m56ȱRF42pP'fg"Dwb\54n}3.@dv~H[,@jEi$ȱ(P쭚xΒb`w>5\tʪCr96%N! "Fe0W4U>(?R[F7]?*5;T҄^z[Yt2̺rG XB]I )$6gJ[H9Z%;bK,KL^eS;Tz ܥWx4#( Љ5kA4u|p/rO% BRQ $ o.R"'e9{'=)xUIcVYxk''fSI_A'6::I[< 8䙫4p[rB@Êl.Mƹ@1dsF 3p0|]̈$lBM|`wY0J6s~}3Ef 4@l ($%ŀ.d\>nyʸIm=bO*JS!YI@{V=8rTn˝q< s߷I|'a 0Oj%Q0sѴ@`b^%Nקs}{a  Dh `\RٴXAK-B)>3LX1d1 Kk٣=T'dDVۉ7['*LR>6ND#uDvB?!h d@:,-c"`YSU .Ud3Ai2.$(=&$Q$HLjh8$Oeח#s騲*0*{(Sphp]GB2hIH22ixDV ᔆg>4+dGiD/LOO0v,8=>Em0!?M GQ7UQM(*xRJhLdYχ e1 auӘHHgieTk`(/ b4`W5S$9wyxs!Ye?Oi5J E+X"BYB 䨊^)!Q0I!hIga# 1RJ[f0ql#Vo_'Ld`HJQ@&萲!7_pM$P|yqsdKQ2bra8;' zN,* $n[)c%zkbiv)cq`l&޴BYDnQ)`(R)<~Ȁ0$*IXE4gaSy&J4&M5ln}(oJ|L+P3HȠ˒7sM=?R3&Z:fVGB}rɰ|Hō]-,*u"N-+SZ$( KAMB$n3(B@!q)a3@«ܠaWpSm6d:>vaArc^R"%3/4TF+XzkE>T? q 9I6+*;0Ie#.cU:Ymb8Cˠ*Oiq ~GΔ(G R}^.ýd)f>c"&lo 2dTJbHI$=#~d!Q)ّ S|j^eb^C3"0> Of}Tb̴R5 Ȁȸ?*aAhqRKhƊ.¦W<8u[UX.Nldj6Gcr\fc1 CO+5:8̻Es~2Vuq KG1+eGˆc?|ewl[K  /!ZA^~E&dU'g`W&>EqVRt.ۍ+{.ަy, quhUsF#r&  $Dd @]=5!+!ol3 10Q1ܐ~Z$!v>ĭ=)YV乷gz.h+;٧]Sz~L)lwפ &S 7&3*jhjdB@UI5 c)\Ew]bGhV2Q.Ai/)pjE;#ΒgXﮕExF?J^đ^-%9+>~j7 3n7(oyܠؤ\j#B0\Zx]I&r -$i z PM4\fTTheS |t7 yt,gȱh%UgWʧ%V}/%RЇ?~zT=Rh;׈2H=QSFIloRj2؝{Tk  [hivRb#@ T¦.O~\kcܼs~Vg!8(ɠ9P!%2 ɠ^a&3;(koJm:e%eBѠnYi/D->NUs6_}Ǘ65]oC|SyPE@h+ -78_"|e8m@(eL'0ا;7Dэ&i2e/Q-qU,w^C**4|xcj,2n\e80PQ *DF9UbH>)Z^φp 4e#AĮØM1B]+]B-|.;bvuWw9̉2Vʂ!^+cd_',d>Wc Jp/O4M]1l ¸qu[K0 Dx̠!N7K"b![/'nS KOC$>\:V;檟o3{PlV"(e5M0)5@ljf7TIbaTɞDTox V0KV (E5hy*!:V5jR zK hK4=7STB̖L!*?O*bKa ّ6\-M۴\ W B"$"c5~G!~=IКz2 ,#>(2f$o[)+#P<e?.pdY@5"=e8 -asʊm4ʮ+w}Ãͦ_o>^?jLN 3M FG߶ڿP@RtG&<0zf&(.`P1BTAmKISEdTd2JL%W+MJ'[!T) y_E=' f:(@\q:+ YPH @9¤ZE$`d՜G'ΨL6 %/y]a ?Bb6@%BT.VX DiLLUA8SヂY'$&[-CӰ2cr \T:/,$l}D0w,d0 ) 5Z1: #e OHҎ+p x`Ƨ3nSIGM lhA=V/ФLBY e!497FNUQC^Ih+n@Yx?p$zte%[=Yd* J QsڞX}2v} Rm- A|onb@ӅBFT d 8Bg#3ٝp;uT{˙M @zZşO?% #4ſ$͈ h Z#b$,Ypq)24ĠZ?{O@⁇ gdIY0:;_4 1a0H t78S,(e:# 9]sa<\J!ya>rS BvE˩79$nCcvWCbVg4Mf8O: bv50h.͠${*90*"XwkUm R%.` K)RTʕLͮ/lꭻbXl/7\o<YFd@LtT5Tc6K3FbYQt&߳[r*AB!)b35qg3xB2Iզn+5EB: D@RS˙KÁ+p4 ڭ U%XdbIX7:=% _ 0{@Ɖ"JƵu㷔uEjb<:%[-:i_Ί1!" ϤP~,;qҞ=AsD4, ܊ QM(S@@ {JMaU{ƀ, -2 :}3wƻP$Ghݜq>'tf[wê; <"iI{"puU(-tz]'*z1bAtT" ,T Q/]om{' >./(RO 4Yjū=(uuEH:tVEE .zsGIdyH ,05;?0(" x]s 98ׄ n+(@ j϶?!/Ӫ[nqvEj8'G_E3v2C zmF\tn3[w%/uU_op zzWMWS*F(92 Ɣի0<@V}K @(PځQV׃.b[I}/4^)a5 $ GyJ5\ga W*k\iyt~B,o&> vݐcݦLYK⊂D]bWm#m+'a1a !ؠ +F_"d6Xk 4 2ay{Wi{!`ٙ[Bن |a_[Qӻ\*| yhvr"7T\Xp$Pyn|cҸZ]$+@1ܼb dAU D03cM1y%X."U y0tWƅШFgv#+/j+7ژp> ?``@@)@uD<H%j__֓R hG+%̭?o2dMLB`*1")[ ׊+(/L/QYu"$p4KJo/.^]sλ{z1 Wq:N;#% JU %\2Y3hY7<)'ܧ; ~''@,Yb >:x")s84_X_n6"~>GE)]PhEnKw{KJUY枕ҕޭl "eA sB ",1k6"(NR&8=9Ya֒2.oG=gg!uvJӡ^ KRU$GF)B> g#(N@uhI$羓yii}+1~(pdςKVL4p1[] )_1⊫ vkӞzJw@@ ,& !x;~wq]OAR 02Ee .K EZۿ5`c`0Id]9P| n:b?:Igaܠ!hԫv{ACӿCUmQI87{;2[t$VHY2&X2HGhL[PhoB9|5j%Vh PEt6HNRfH[ G1 MI""yM9BPl4BYPqGZ|`<,8z ┠ѾhdҀ8LI3/a q(⇴# )(6c8m (ĩ1.Fu9=TSt0#s؜Bߠ0`;9 |@\i[}Gb]|R}RFM`!5$+aQ9hkU鯪TF|(p *SH Lذǭ(0"@4TXLɗmkU jO Wk*I2,o@08vFJmj},'PJ``0z,ݎ ,=)EA@qNJQzv*1=ZDJl;\(BDdۀJW *4z]rϓ! IPd/8 `!zLSYF̝q:/2'HL-&6iEJy[ޗn+q4UCWB2MԷSK̕p>F N/U"͊H7vLt , ~C;5GrCߖ9h7u /5c'<<6n8nBhLHݡom/Y3r^>r))&8̪?nE.ie$&MRv=s&tveܭ 5KQhDSڡ0 Uک_}kYz@ ](~QujLlcni2(܋D$HP" !(&&L_D.鉝ދtsMQ\ ڜ*}x<5I$ -z߾Qϵvi@9T˹25ΗX2?}R^3{T%O@,vEc>uq"*3҆)HPyK߽E KU.S%ẋY *(S1c`p(B` Cd#XB4‹ $,eqQ 1) ,?ҖoV YDrR&4*QPkG("FaE# Rs2A R=i ,rd43;8@"1*: NHt*! G kҕ[$.F9s"ҧRPpfe\g2ˤ%UB PdeKD9!wv,\U;_s` [fCᆼ’y4v;pN,.˧ U0HC:rwB"1Pa x Y&!D%gWlI4[x!a $Ćz jo%D/Wzga#JLuY=I|k1,ժ5;5,MRR@YOI$; `K󭓅=?Fߘ{(#j}B?:P۾Tթ3$;hHBfQBEo )+fk'ipT"Q-C/CNeHKsʳBq4Wa5+"Gia` kĂMJЊ@@8&c %c@qn$*^c%EhT&VQB`(:.h`@`EM㞝(W8 *ZS tΫ69$.ݷWRvڵ26ƿتEURL5I쎌ȨQ&DÀ2'Vc jf*`zMHW0g)je 9ǚCY(|=tB3Qf`g Rk4N_)ug&X,UZyV;\Z4F5f5TVF:kO\峓3!Uu-Xjbj]aPF@ 0X'mx|҇ED!ޞMg_I>x i{28+i?.g"HΨZ6\U2hNI7g joVUd*XI4* 4//Le֐M|d(y%R$Cң{S+-QgmZfL(䂱qt>fFo3QBcDOwMDVV{ jdx E O  K:vb5EbP4أUg.n7RJʳ[0.:Үߥ!$ ՙFl'!BC HIE~U iЎFnOGjH IW 8`k{{(É@s̭Pd Ox3R;::e. KM<+t'Pi,^`: >^NCpf M+e~!% ,L8+1oޯA dm &te`03k:EC0zAyٖj_s3gAdy [<3p9d|$HfCnH2#BGA4^E][Ph .FZ6~"`4I⼛^z&1v~Wuw_y^r!6K&"%aB`fQ@ P hEÈ) < bYʒo3UcR b #ԛFbd CTe7*ܓ_0#x]xYl\`@2ŰGęJi[cwpݗMwmSSrַmOzS.-|F Yo Nd!j5;aQߝϒ(XA "H @ /ɀpsT"h)`Ą$U (RDb*4b6Bz_Jv; ь1zRlu]#$ϵY O.;`(ͷs-8 ?-i6b50Cׂu"TT<#!e8dFXfĆxp7fT&ad 5n :ڪ4nhI܌lX"Vf3KU`&LQq|{*s $QEnOe&EzQi'.V5<l)``8II#]m׎釜` 4E HDUJ$9<zj(<2I/'"CK[2s[t' WP'>6lc_vym.8]DE#4bkB~s34VW60HxX*U2`9d.Y?q&4ch sR2_r9Z'vaĀWil:؆:JG@ YqQu#XO>qW{7(5V (-ähYy8a#`24 EBZN= M_:VC*Q:RD310V0P3H$=+Tdz!MZR0a%-D>nh*+[kEHBLey#%c]!2%+Lٳ1\!1ΫyX&iW$YVQn$t r|]2SM|8E ca>TLRe&f1N,H'H`z tXy@׸-JX+l UӨ an赎]wR%]Q%͈pIE$'.G=7-Z9stfΘ> :nY-m6xKXDĐBeC*t-{-er+o e3CcNV_>;6OwFb|[F[3cn^P0k@D.d7%;S)4C' PQ͍( Ri"2ȨbꉶܻtDnVJ&[a-_;P)ٯWFP:G5/F+ P>:OwRJއ]#WG 4CBeMwch b/a0 1{XlWGd)/9iSIVc RFJ# Uu.=ѕwO~];'eTVP}mShAr68<mEQo4^T1`(b ,*c&%_qV}I1&ye0K0TdMRXI\6Jae=SR <dHH/L˝{[{ȡ]i"$ab!R)("cYepX U}QppQw!0A\f B;eCm⭨Kr3ZtT"F"]Ⱦ~Nފ];\I .2vV 62UyE3W;eվ#]D~r6mҡ_̊u$K7[1ZҀɌ$jM3ێmTOL JCXɈp`9ySB ddEw/H9Y  `$J0˹je̎cbIB:cݯdf5S 5 ja/YU8׍hްFBǪmEc?Zx Ģ UsC  j. oC9j[{3 _tĎ?ȌBLN}.t(x(/M@DāU!+(QI 4.RX`Vp' 3M)|}V7!'WlCqyNB}%ߗ+wY\=cSvxsVg|1|[×w_zb=#q1Iy|AH(H`cf1qta؜BZ!5UVKѧ@du\PVoM <{eY_S .㨡3+Z"#Q%[ݾwapH (I3UG$]91I)ɚf蘨8>LHlD;"x1/2Xx'f~<錘$ vd:xw2f9[ VjrEWyOT$qCݴ@Z(6+y,1c<#U&q|\%Mu%RS"Z{0}sĶ6u=_KEA s< 's`JRwϗ4j}eWT.IuuuG:W 4!E-.Rg_zڷVҵ 籅d,.0ؿe2AJ$ \Nne!@҈f<*PX,Pt5̩<]5𢎴ǣcGƉZM1dY-T+1I 9/*k;5xD&MttjE nF #TEZJե#TmZ7˫\ !8h" 'I%딕+x`B $5g7+X6r <\ɾ뵵sq۩)Vw5IPa̠>(48iZJNħyTw,6 .My&^ścDpcاF~?ߵY!J;i^o) x¨f@c#G3*0@dBJֻf5BeV WMՉi1o>s̔9x$I&rCK"R%K Gaa:(G褷F[YYzPHY5*~-kE3{ob3&*  'E"L %$߾$ 8h|g ݠ}PɇL8 L`\%\*J[]3dR L,5;4uE{Y23˻" 8Ov+iXxCj/pb)"2OӄdC'} uӯiS^O֥]Qۿ B.!d|E9TSLA5ši#g0Fmb݇ͬ<#)<ߊ2&{}uD%0fSf Ea* a}:+҉;t-,I~GCϦE9$vH@uJհkJQp$+)+$c|K)cX]oh=@OlVR, `g"{CPNHi&r0@GBM;v$kbd$cCU:w|HbJ0A "@/~(}eoQP2H-LS:,$3s_21_H CŜ(&Ǵ@".̀P(v 1-Qd5SN[z}`ҦLH`Xf(J|J5P3(m[5LCAir1߶=ZB={Ԩ߃WᴘH2S (zYE!!qF W&$ p] "2gǯICYh1 F23n2"\ 5W#m4H.YH%K=3gS_ö 2g#CaH.׋kS|26 m3|)Y\ ST?ť|7)bL  ]!Ac_Kҕ%H9"R9ԙdyyB9g `FmP)) JR`I[2T(5'OnX(*1K ]W9֪$ #0k郶h!IҙIWXBaőFgx5'g^ 0࣎1aZ17SeBy $3Œ Әӕ!4&IH=U2YK,XMk'ClDNPf)[ORֵpY*^Z-@zbmwi|30G'9tflC@*UͶ33JM7̃/|c#a+F0@mN֏j(R=_ϵ?,Uc@ށa*jR@5&8&b<[!Ψ0NzQ8NQxl4c(k& !T)>ۣUsE_iU&ˤ`B9᳟zm&Z8zQ񅆢.8 8;%uQн@Z#gq)7p,;9Gg}_GDU15*0@4,@**q ;mZ,ƀҒ 'BnB-p31BhY\E"sE&FQfNwWSRȟ[[kRöpsG@p8f2d My"8:Za%9؉@)$hx!A/CeΊ48;jt5> K7J E<0"lЫPca`% LyLW`B> /Aa{@!P@Ibdu6p ir獐r \+#ҡz;" ?`諑P ȧpeQ!@t@ L!`fH_K."0ȬHvM;@֜*%]$wS[ 3x7(@( 踥0P(P@S[ZHrR3}ZIfH4oK\(G(Bf"o`AW݅( 4%tL d bLy?Bd[(0n gM$bA iٚRd*]BĪ4!mz$G-)5 peKHzNQ5p'H[.&h%4DjY ]we~Ƞ6 OGջdB,n-~/`ȦSڮ;R>7ѹjvcsrd !Qˆi׾rf'WrO*7{4\Dd`)&jm߿?anMOà%\?Θr @ df):UxX5]˔<9x\ڔ `k?=ד^CDN>;dNMqB s<igeF'4}*@*F.'xtVP)cOW~Z 2WEM+xX𐲐klO_ahQe:2;+ LaQtE($2Ӈ͝^*Q];Vj{NzNnysZk:wSe+M2S^'XѓB@j48\H-9~91d^aU''8:ű C<6B^Vx4($1zg2p ~D3W3oդm&c?X(~Xpت%*o;@EӺ^{\"V?FU]ei0wjBSǵn9dDvLJw6@7{ޟS/Bx})$NX)5F ,PjdKs ,p/z%c0q , Xﮫ'E*A`¡E\uUBD\41q *6+=9 Ov/'k%CDgDL V>$I Y4C,k7[W "￑$JEKoR <0hs!@=HK" “"̱df1@P, Qo@ HK֡RPn}0aR|+%+.xkyL-Ȍ):5q )VVS&Theh9ah!??'WS;l$r<,aL5WꢡH5d@Wc,J,&iM[ 1 ܈43[7B!0 P% h oCo3;JRSdžcZrlA*Pؙ%YIrIʚ^ڳn^cн}O>*rӶ#[f:ܡV~3AYWͧz8\٥, >`9$517}` RۊԨ~B t'0`j3besmPr!RQZ孉ywdeɷo YkeP,GÜ k_?yyon%H9DC?$Qky /co`z[LD-N Xr|g*>dH/cp)› G&a^ cwrZ^p35CRJnFldѬjӬQ5( 4 ~uYs]d(SU)/mfeUl3Í0 549#CGZ)aA2LEhAڞ!UU@c@y" aB(g q!赎ϼ8X'oyJXNg4?_US4iB kr*yoXSU’=+i,WIBH;gтE-jJ =.Uܣble@ZbREaruq(" 6r君ƃPxޝi_ 0;,tHO$J5G=EO,nނCֿ-3@"FhRyPXKj Gj*b3MOhdW& `KMO8̕(7.!!k 1!B4x3Kjp#WenX@5&k` }t&Wفhg=&۩Y\lkXm!dI0s5C#\ŚGh*Ik@n.RkZ@(("4Ad' xZ J_)wUʵ!fshkK %u4?lE҈:d=;YR5_$s _=ْl|m-"եɏ,!MeתQE x$ngf$/PTl|7&w2 @;77G? *4` UCi钚 ']=*jM.bb"Ƙ*$`<)/B#o1# }o'F# C0]fF!uAr`a2URaiU,Xo= $ 9S_(XnxZ@,0PUОۆK9dK=#r*Ԙ{Q>"/q="/+zd!0f` cOA; dՀ.?{5J=b: csφu2`FR` JQC:0EHjkMk1]p1š =qy3 O:,P0pQBd*Z?wsfFФw +DQa&LB+Iq wnAds?+[B~g;SN$dCeRӆ%mVw;MsbMU_77g<=gaf!QiQ^(;VAdHWK 2p3+ 0P<]Uo41RZ d %GD) nRjFUād<%YiWP<5R ( <@&efZ̬ȯ %?;-ήgˆwTvdE4 F rѐjj>އbZݸ֖*15nQ(Z ,ǁ;\)B(:qӾ(B;?2nU%@f,:"m%@Sz a#e\£0+9` 6eTLmEIi8omg}xFU / 9A1ZG2nj)*f/"D,lK!kJd$9Kl27* 1-_-0Iȷ(ǰBq9{@w%( Dv\HPwѕUDȋKOv3GRtnDز(^W!oYG*9xQ52C[n $D䏅>K'7^=9 Ӕdn_=(vzgycc+JiUzYP А|-ez:̈#@8 qgFYi_bh1hc1H[[|ɱ̘>]0< &)˫ :TQG;'| αJ8vAXI܋VLεwcxɉdHLKi3r?E`*B$ 0A-[JYVRԥ '-jeXHt5XX~kZgKX!_s }3+Mio+/޿G?_RPt*s "{mȸ)Nₘo!Ǻ_9Q%U XTU 1c5^_R,'wBMW,#c+q(+:DVR QHRS.)B"fM "YFT60SG*qdTW,r.c 59[Ͱ@4G& hĂT M|rZ#hGZAUgOgSԠ6Ìj9kd!q+X05eL N[ZFIɗ baSyv>6#DTULۻTD̟^R:]P@40#K4@B8yUeG8V_p'- ,DHawHBIA+oۅ_4$ "F˙.X /OP'f AfcOHD`@nhx] &E r DedjKJuO.PdyHX&R2a=#Z@ j(n[O(KN:tbN3"ypBŘh@oU6&G@*:R3_WuзƲٰ\nއuZ.ݽ}y2dF?Υ)Lb2һ-ߜ)v#c҃0%ʱ\WtvH0gJd!&MT)6K0}ƶ}ƶ4(??߯lD&"~ՠ*$dyzn(d:QA)TKh0H:%Y. J^}MDT$ )9FW# cwyr-nVߕnY_5Ѽ dQV,A9 }abJ cۇnjQ` Wq1Yy?D3NqMaYKLo9JKBP)+h%1v1V2J0t c(ڮa""YM\BUYSm*_&ag"?o;Nk3:4+,Q Ġbq5D+-8h g;ZjĔ;O$epG?$DG( ;*7۳("Dfs$j-#qH( bp(#]-*(P4lxk-;8EQe-Fe "ȝH/036ƘqoKPzdJWc0<&<#[l$jQagry)+H0.rД:4ryOqTB,LNIB*tD-]:v 1O|II! ,Pē#4ŠԼL<bU#^m!,8&=hLٞQb%5Q[.fX@<$Sh(ٳc@lj0ݟ]1 /*-`XqA)7aiP^#2$VEjD1T**,ԼeN5!b1 8SFҊ+/Qk](gMq~#UY[:Mb]-*Q4#dLV# Br-0"by'[l% k870 FenkW\褼ijV38a dϽ5²W 4 ;53« P.0]vAC)d{ǻa E>E)t:sxUUuۯzF*]͢50?0ՙrryIZTjVٓK#F(Yck^ @ ^(>Y 8NIX~1Sg28Xܓ}iT$*Ct +fDv&ͫ :5!Xលelb0RNW:cR'NT-&Mtm׷)9Rnώ-~(jHdlUTXHP;a1"b 1V=kdpNl. 0BdHdA Ӟ(} Ę@צG"(:mt6`SIWws} 4H^V,A܊D(}qI)SP6֬ĮYE$f{?gcfy'R0Dl )Nҗ}XcISkT ‡ "E%#:]R05]&*Яܞ>D#c t,;9"ƣ8MoyjчhWV"9t2G1 CfU2^ʭ“VB$O &đ#ޙ54aRdIU#CbC?%QQeU$mҊ*Pl1<)4K| W}FhsB @@#[/׷^ITef'_;SDjsһq]_@c@)Ѱ0g4ҠBK&j4.̂}P+3lqTP< BaF_g^XqcUEInjd/JxIOC-<[`.♈![>]*K8UYq$@ÊRB'Zl չM3hC;J:7*߫dH=fzbëSLwz.PwI!S217ц)C k]Т'ֽw[)/E]aRb3[5dS);CJ% 0 (P`-"TPh|YQBĸRE(Rp%T]A*\qzB ?PQz!(* Sj`+ (ȸe8`JJ6XdjʙCH D40Yag&JdjL\qW9gUC8s.p%2E7D?FůApʥDŽ#i[!FY8ux x-=kbA7\"uU-.xo28~+8#k 9K:6 "&yV-,;C E*Q.p *R=ML̔LRO2ĂmbO /)~DcX0n(i#jL܅Bo i1v~ mc{[dIBh:BŢE1qa % A :b( 83,DSc3Bݑ 2RT: CTH9*ū{_fQKD 0VP(_0o1E^_*3x_7k3g\ +_ʀdֵj=_.nPADh/繃ȄJ4ȹ-)YEV:tTo@nt Uƶ+d1 4,"aq0 t ) 2֙-1 Dka5DdY˚2rC⚽=#9380v/t 6q@ FفkO10N @߶wh.hַ:Vd[Y܊f4&|@bpx[t{4H5Rz4 7)AL^9A@'"Ā~ hjXHh7Γ&5g0`T PwjCz.B Ѿ'O}elx$CHΏ3ZW/||T6Ȯfdny*jYwR??d Q@"F(yt)RX!&##$ߑH;J-X:"o_H//d&RTaQfDdaINp-ca%noֳ8yG[㌧cS s 3/Š `pL#ab8IoA4^92bj.pl™ eQb2-~R@ƀPwdD`dC$p3aW,Oh (i ele #3 ;) k>B҂1aCp2F T_م&,jP" ,?Wp!!ZBnv & z\>^Rkfz$L>b;Xq:dAG&+p:AGc NOAu /BUT6$VUnYkeph :o :b <ħC`Ѝ 01`z` 4~R}.O &'vA~xhbG+Raf (0@"(1J)eTX4ndB/̛p2`)W4n텈ҍ*PQRHQ'U]L]DQc;cA75VK"0 3&"43&B1U3gc@4)B &}VRmkn,L 腈KވRgZmKSb}nӯ ˆ~߿ŹuEXά g;mz:& mC8q+s^&&Pؐ&֦Оaخ CN݁,KsG$RC+E:SS"eWLcȿjcqTA%lQ`Zel<D;dG {>M4 LeE"/lkÎgݴ`XaSoJ@CeZ3#8)07=#1HH鍸qm `Ϡbs$57L y QI=0 @7*NI ؠՊӳ)5CjfLv*m=Rn+L4 It ^J_ 1A€Ѕ ]00,,"tlBcV-\S&L<P  r" zL " a}#D/6jϿJc֞>ޜ͘eNEYS{X"B ؕ; ǮA fU@iIAT0YdB'̛b@5cza(gh>ndʌ5V@5,[pz!=3ZD>Lf'iϞSo'L=63yP#](@A,dQD5:QЌOAL4xbP$ϽTmuJNKNнXlr%9m B@EǠ#& 57$(CTKCi)x:GXcH'"139 802KwuO;xW1zBm $<gCf; QjLp'VNN9"ldK!LYr5zza-s0n kud?~֟/>ѵAX ]M))7+m YM؄j,%N%"e(.q_u2(1b_+.+Ѧ 'cVc p&z\A4U5xHt` #7Hذ 0I+#ԕdXի*5)BVWr f$1(µ3  >PqG1k ̽ |e'?6ۍ ̥3v͡&iSETAr!I'6SʃY5;- X( ! %})Fb#C,!qZЛ( DZ>dN`1ϛv5: oRi\RXUF@"ƪf,Sxt\,aI2ʹנq`gUwҬoc=*{ّmu@IFI`MASP+"m4X˻ tfG0q- !K%5vV9P0Kιb] IA{ҧ`LOP"mR)ΨCTs00bJ#d.%'_rkY tEHF/R9#Gr9L#m''50;{bEceKU2"}l)$CRlnVK&Sc9̵iw^<3]We G3@АpvB(gK3n'ؘRޝ%Gm R[fZ4d 1տm23Tofr]F Ğn- B4jbGrz 1(9YDca>6JCIl8B N$Lˋ$`Rn+1) D%ػ$-_<Y>f-ݚ8*Gx"*#.@a$C(Gݻ[ ژ1Tלl2AT&Y?UTzK؛g#H`a *:+P.i(T; . !dpuFs;aWWg(b }Jr<@ E`Xm\O2;4Crqdd]1TS+V8ac84@ `@pFL7agݍsz(ᕔ 쬾\">FDEJ T,"4 LPfOIXS}0 04`"XMd18zq>Ђ kq( QI`p.*^wuU 9! r pi!if*0Xd+lϒ&+&!";rf- %axz=J[HWUg96.„qL%<+b)wN"Ew)<3%L8v 5%Ϛ4 o20(*M2@;P]Ġ:>PI=dK=w3Jd NЛ7#)d(6ni )-'Ty߶aF+Z2KuwU/o-y~mySIH,l! P3)N SzH3z_Lb3&ՔMo@X!Qz'tGX1D061D 0:-9Ap NZa\( 䉿TU^I3+\&?̕]_/,< gƲ9;wg#5l[|B@ C#U,+(|o@*bdc֮^*W-*,oS Bײ-zqR&"cq փ# ?X+ tHNd$:;iT7e: