From cd2435edaf25530518776a9ef20647f009fb71c8 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 27 Apr 2024 21:44:37 +0530 Subject: [PATCH] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 36ae94c998874b5aaf79be0b87d1c05c605b1ff0 Merge: df0201729 9126332df Author: Aditya Date: Sat Apr 27 21:35:22 2024 +0530 Merge branch 'spacedriveapp:main' into main commit 9126332df173ddc11603b0023fa2a1919a4eca94 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Sat Apr 27 18:08:07 2024 +0300 [MOB-89] Separate headers (#2408) * separate headers improvements to headers cleanup missed cleanup documentation * Update SearchStack.tsx commit a61a7bee6557705f92b32d922e0160d7cce066bd Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Fri Apr 26 20:36:21 2024 +0300 Windows mouse resize fix (#2407) Update useMouseItemResize.ts commit 9384bade619c6916b49966e546d2ebd6778f3f86 Author: Vítor Vasconcellos Date: Thu Apr 25 21:29:55 2024 -0300 Revert OpenDAL for ephemeral location (#2399) * Revert "OpenDAL - Ephemeral Locations (#2283)" This reverts commit 2848782e8e052c9f62f7782e1cb80047f84771b7. * Format * Fix some diff problems commit e76ff78f3cee1e3b3c9f5defff5439171c0cab0b Author: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Thu Apr 25 20:29:55 2024 -0400 Alpha 0.2.13 (#2394) bump commit 476447ab705b3b7d078cf60170e6e2b925ac3b46 Author: Vítor Vasconcellos Date: Thu Apr 25 21:20:36 2024 -0300 Fix server release again (#2403) * Fix server release again * small improvement to regex commit ab46cffa11234d95eab3a1f1b884fe8657b22b8b Author: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Thu Apr 25 14:37:25 2024 -0700 Reactive file identification (#2396) * yes * Explain mysterious if * Use id alias just for consistency reasons * yes * Rust fmt * fix ts --------- Co-authored-by: Ericson "Fogo" Soares Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com> commit 64bbce32e920182fc8fd0a07ea4e7514c3fab2c8 Author: Utku <74243531+utkubakir@users.noreply.github.com> Date: Thu Apr 25 16:06:35 2024 -0400 Fix title (#2398) * fix task manager title * 2 more config item commit 310eb28e63034ab0aa6796f2cd5351c2ffd651f3 Author: Vítor Vasconcellos Date: Thu Apr 25 14:58:50 2024 -0300 Fix `cargo test` & improve `pnpm prep` native deps download (#2393) Couple of fixes - Increase `pnpm prep` connection timeout to 5min, to better handle downloading native deps under flaky network conditions - Fix `cargo test` and cache-factory CI - Clippy and fmt commit b86d3d27cbf52fb1c50cc1dc3eff84d944252e48 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Thu Apr 25 20:29:46 2024 +0300 [ENG-1762] Reverse mouse resize direction (#2395) Update useMouseItemResize.ts commit 449337285d972c254364dceac4c4bfa5726efcde Author: Artsiom Voitas Date: Thu Apr 25 19:26:36 2024 +0300 Improved translation into Belarusian and Russian (#2391) * feat: improved translation on belarusian and russian * updated keys related to vacuum * updated keys related to vacuum commit b1ffbee9b14b60dd82cf90d4c1f453d88a33ea41 Author: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Thu Apr 25 09:14:43 2024 -0700 Fix thumbnail generation reactivity (#2392) fix commit 73f521a3b8bab4ade684878e9faaf7119145f6f9 Author: Ericson "Fogo" Soares Date: Thu Apr 25 01:06:11 2024 -0300 [ENG-1629] Write new file identifier with the task system (#2334) * Introduce deep vs shallow for indexer tasks with different priorities * Make job wait to dispatch if it's paused * Extract file metadata task on file identifier job * Some initial drafts on object processor task * Object processor task for file identifier * File Identifier job and shallow commit 463babe1d48591291b0bdb5e6e7939341c9c5b2e Author: Heavysnowjakarta <54460050+HeavySnowJakarta@users.noreply.github.com> Date: Thu Apr 25 07:38:34 2024 +0800 I18n polish (zh-cn) (#2337) * i18n some polishes * reviewed 1st-100th strings of zh-cn i18n * change the indent to 2 space characters commit 2c777e53f1ffdcf1ccfba6d38c0f7c37c84dd915 Author: Vítor Vasconcellos Date: Wed Apr 24 20:37:38 2024 -0300 Fix core test (#2386) * Fix core test * Import CompressedCRDTOperations --------- Co-authored-by: Ericson "Fogo" Soares commit 57b0139240d0eeb7cd61e4b38b7415ff04c34d52 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Thu Apr 25 02:34:24 2024 +0300 [MOB-90] Visual adjustments (#2383) * Visual adjustments * Update Tags.tsx * cleanup * remove prop * remove hitslop * sectionlist commit e0f540a1be039efed7af008bee1235c51e14c648 Author: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Wed Apr 24 14:20:51 2024 -0400 Small Trash UI fixes (#2385) * Update index.tsx * More ui fixes + toast * Update index.tsx * Add Translations commit 279aaf2c5006904105da8dd5fd6572449fefda98 Author: Utku <74243531+utkubakir@users.noreply.github.com> Date: Wed Apr 24 12:48:14 2024 -0400 hide placeholders (#2384) commit 3bed56d4d9f330b59cd5353ad0e80eb46e0b81a4 Author: Utku <74243531+utkubakir@users.noreply.github.com> Date: Wed Apr 24 10:25:22 2024 -0400 Alpha 0.2.12 (#2382) * pnpm * alpha 0.2.12 * make pnpm version non strict commit 0b6bd050a0e53a8214016081e05260ec8541baf0 Author: Oscar Beaumont Date: Wed Apr 24 18:09:18 2024 +0800 Fix main (#2381) * fix * fix commit ae6c49b0ba0aef84135c540047fa941c7b6ac02b Author: Oscar Beaumont Date: Wed Apr 24 16:43:30 2024 +0800 Improved p2p settings (#2379) improved p2p settings commit 918c2a987d4078abc08c678eb1dd9ef28ce6fac9 Author: Brendan Allan Date: Wed Apr 24 16:26:50 2024 +0800 Batch ingest sync operations (#2378) batch ingest sync operations commit 643bd3a14232e1be48b7b1ed0df82d13b8871a1f Author: Oscar Beaumont Date: Wed Apr 24 16:27:31 2024 +0800 Block size (#2377) Block size + some Clippy commit e009a0478c6b17db0be828a46a66a5d2afac3fca Author: Utku <74243531+utkubakir@users.noreply.github.com> Date: Tue Apr 23 19:20:59 2024 -0400 Revert "[MOB-85] Better headers" (#2376) Revert "[MOB-85] Better headers (#2375)" This reverts commit 6a556a457d3b91b74761af905666df5f19520369. commit 6a556a457d3b91b74761af905666df5f19520369 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Wed Apr 24 01:21:31 2024 +0300 [MOB-85] Better headers (#2375) * wip * improve headers * cleanup commit b4037d65371205f36cd9f60b89dab76966badb0c Author: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Mon Apr 22 15:46:10 2024 -0400 Open Trash from the application (#2338) * Open Trash from the application * Working Trash Sidebar Button * Small UI fixes * Update common.json * Move openTrash to Tauri Command instead of RSPC * format and remove type assertion --------- Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com> commit 745399ecab3fdb6f6f8a8dc2855d68a184cc6573 Author: nikec <43032218+niikeec@users.noreply.github.com> Date: Mon Apr 22 20:54:42 2024 +0200 [ENG-1751] Improve active item handling (#2367) base commit 959ccdfd9835ce85bafbae471cac07eb5ede19fb Author: Oscar Beaumont Date: Mon Apr 22 20:43:44 2024 +0800 Reintroduce P2P Settings (#2365) * redo backend to be less cringe * fixed up commit ef969f1adadb0718fb2a389bb162474b60db4411 Author: Oscar Beaumont Date: Mon Apr 22 19:47:47 2024 +0800 Remove indexer rules from ephemeral indexer (#2319) remove indexer rules from ephemeral indexer commit 548fff1e96b9005f5eaf53d0caf14711f8156f92 Author: Brendan Allan Date: Mon Apr 22 18:29:54 2024 +0800 Ignore empty object/filepath search filters (#2371) commit 52c5c2bfe7b3417724181851d5fe8c22841b9d24 Author: Oscar Beaumont Date: Mon Apr 22 18:28:35 2024 +0800 Show errors creating P2P listeners on startup (#2372) * do it * fix accuracy * `useRef` as god intended commit 20e5430eaf3f631c70a267ad09fa79e623fdb7f1 Author: nikec <43032218+niikeec@users.noreply.github.com> Date: Mon Apr 22 12:27:30 2024 +0200 [ENG-1753] Only open quick preview when items are selected (#2374) only toggle when items are selected commit 13e4ff6107d7835b553ecbd4b2b824ac273aa58e Author: nikec <43032218+niikeec@users.noreply.github.com> Date: Mon Apr 22 12:25:53 2024 +0200 [ENG-1752] Fix explorer selection reset when closing quick preview via keybind (#2373) prevent selection reset commit 51c94c88e3689f8e3de904e742fe8e4a3b769ec8 Author: Oscar Beaumont Date: Mon Apr 22 18:12:06 2024 +0800 Fix Docker start command (#2370) commit d689e7e58ab30a96c231a52b9b1022c8b0e12ac6 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Sun Apr 21 17:28:27 2024 +0300 [ENG-1750] Update context menu colors (#2369) update context menu colors commit df0201729278dfa11126b41922e404146c151a35 Merge: 5624054f1 619a4c8b6 Author: Aditya Date: Sat Apr 20 13:14:52 2024 +0530 Merge branch 'main' of https://github.com/Raghav-45/spacedrive commit 947354f6c01577801d9903422d8ee5f27703e17e Author: Oscar Beaumont Date: Sat Apr 20 11:21:20 2024 +0800 Remove files over p2p feature (#2364) * goodbye * types * a commit f97a761346478b2b22653be88712ac0e6aa01b00 Author: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Sat Apr 20 02:18:54 2024 +0300 [ENG-1745] Mouse wheel resize (#2366) * Resize layout items with mouse wheel icon/item size using mouse wheel Update useMouseItemResize.ts Update useMouseItemResize.ts * improve comment * fb * Update useMouseItemResize.ts * Update IconSize.tsx commit 619a4c8b6dfe7239bce1b54b528a3176aa7350da Merge: df4f6279b 795bb18d1 Author: Aditya Date: Tue Aug 29 16:59:18 2023 +0530 Merge branch 'spacedriveapp:main' into main commit df4f6279bfbd7bdaa120212b19db72cfae0d17c7 Merge: dfb519206 e4b03619d Author: Aditya Date: Tue Aug 22 20:44:09 2023 +0530 Merge branch 'spacedriveapp:main' into main commit dfb51920667c24ff05b16ebc63bf4aea33225002 Merge: c1bfc3296 a0a1c6766 Author: Aditya Date: Thu Aug 17 21:22:49 2023 +0530 Merge branch 'spacedriveapp:main' into main commit c1bfc3296ee7686a6a142d74a91cf13cf4bd7677 Merge: de274c331 9c0aec816 Author: Aditya Date: Tue Aug 15 19:43:43 2023 +0530 Merge branch 'spacedriveapp:main' into main commit de274c3317cff942e9c3a4f2c8c08819a897d251 Merge: 14faf0bce c86a728a1 Author: Aditya Date: Sun Aug 13 21:54:16 2023 +0530 Merge branch 'spacedriveapp:main' into main commit 14faf0bce2ee9123bf66706812357d6aefc44dea Merge: 3e013d8bd baf032883 Author: Aditya Date: Thu Aug 10 06:54:01 2023 -0400 Merge branch 'spacedriveapp:main' into main commit 3e013d8bdef2ba59536c90044be4312336b6cd8a Merge: 2e702f2eb 7708ba585 Author: Aditya Date: Tue Aug 8 11:21:07 2023 -0400 Merge branch 'spacedriveapp:main' into main commit 2e702f2ebabcb5ccdb693f3c67223c29c9baa738 Author: Brendan Allan Date: Tue Aug 8 07:58:58 2023 -0700 Mention pnpm dev:web in CONTRIBUTING.md commit a1c5c55a37d67d41d59cabec024cc6bcf2ca0def Author: Raghav-45 <77260113+Raghav-45@users.noreply.github.com> Date: Tue Aug 8 18:49:50 2023 +0530 Update command to run server I encountered an issue where the cargo run -p server command was not functioning properly. It took me nearly an hour to pinpoint the problem, which turned out to be related to a modification in the Cargo.toml file. This change was made by @Brendonovich during their work on issue #1181, which pertained to *syncing ingestion*. Initially, I believed that re-cloning the repository from GitHub would resolve the issue. However, after attempting this solution exactly 5 times, I realized my assumption was incorrect. Despite the time and effort spent, I was able to successfully identify and rectify the problem. --- .github/actions/setup-pnpm/action.yml | 2 +- .github/workflows/server.yml | 10 +- .vscode/i18n-ally-reviews.yml | 115 ++ CONTRIBUTING.md | 2 +- Cargo.lock | Bin 289417 -> 281308 bytes Cargo.toml | 1 + apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/main.rs | 43 + apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/desktop/src/commands.ts | 11 + .../src/components/browse/BrowseLocations.tsx | 2 +- .../src/components/browse/BrowseTags.tsx | 2 +- .../src/components/header/DynamicHeader.tsx | 110 ++ apps/mobile/src/components/header/Header.tsx | 146 +-- .../src/components/header/SearchHeader.tsx | 53 + apps/mobile/src/components/layout/Fade.tsx | 13 +- .../src/components/layout/ScreenContainer.tsx | 29 - .../src/components/locations/GridLocation.tsx | 2 +- .../src/components/overview/Locations.tsx | 2 - apps/mobile/src/components/search/Search.tsx | 2 +- .../components/search/filters/FiltersBar.tsx | 2 +- apps/mobile/src/components/tags/GridTag.tsx | 2 +- apps/mobile/src/navigation/SearchStack.tsx | 2 +- .../src/navigation/tabs/BrowseStack.tsx | 38 +- .../src/navigation/tabs/NetworkStack.tsx | 4 +- .../src/navigation/tabs/OverviewStack.tsx | 13 +- .../src/navigation/tabs/SettingsStack.tsx | 11 +- apps/mobile/src/screens/browse/Tags.tsx | 25 +- apps/mobile/src/screens/search/Filters.tsx | 2 +- apps/mobile/src/screens/settings/Settings.tsx | 38 +- apps/server/src/main.rs | 14 +- core/Cargo.toml | 56 +- .../src/isolated_file_path_data.rs | 12 + core/crates/heavy-lifting/Cargo.toml | 3 + .../src/file_identifier/cas_id.rs | 68 ++ .../heavy-lifting/src/file_identifier/job.rs | 566 ++++++++++ .../heavy-lifting/src/file_identifier/mod.rs | 120 +++ .../src/file_identifier/shallow.rs | 207 ++++ .../tasks/extract_file_metadata.rs | 280 +++++ .../src/file_identifier/tasks/mod.rs | 18 + .../file_identifier/tasks/object_processor.rs | 473 +++++++++ core/crates/heavy-lifting/src/indexer/job.rs | 160 +-- core/crates/heavy-lifting/src/indexer/mod.rs | 45 +- .../heavy-lifting/src/indexer/shallow.rs | 15 +- .../heavy-lifting/src/indexer/tasks/saver.rs | 33 +- .../src/indexer/tasks/updater.rs | 43 +- .../heavy-lifting/src/indexer/tasks/walker.rs | 88 +- .../heavy-lifting/src/job_system/job.rs | 53 +- .../heavy-lifting/src/job_system/store.rs | 3 +- core/crates/heavy-lifting/src/lib.rs | 17 + core/crates/heavy-lifting/src/utils/mod.rs | 1 + .../heavy-lifting/src/utils/sub_path.rs | 93 ++ core/crates/prisma-helpers/src/lib.rs | 1 + core/crates/sync/src/ingest.rs | 332 ++++-- core/crates/sync/src/manager.rs | 12 +- core/crates/sync/tests/mock_instance.rs | 3 +- core/src/api/files.rs | 2 +- core/src/api/jobs.rs | 16 +- core/src/api/locations.rs | 17 +- core/src/api/mod.rs | 29 +- core/src/api/nodes.rs | 24 +- core/src/api/p2p.rs | 52 +- core/src/api/search/mod.rs | 238 ++--- core/src/cloud/sync/ingest.rs | 3 +- core/src/lib.rs | 2 - core/src/location/mod.rs | 58 +- core/src/location/non_indexed.rs | 385 +++++++ core/src/node/config.rs | 58 +- .../src/object/media/old_thumbnail/process.rs | 7 +- .../old_file_identifier_job.rs | 6 + core/src/p2p/manager.rs | 103 +- core/src/p2p/operations/rspc.rs | 7 +- core/src/p2p/operations/spacedrop.rs | 2 +- core/src/p2p/sync/mod.rs | 15 +- core/src/util/batched_stream.rs | 2 +- core/src/util/unsafe_streamed_query.rs | 16 +- .../src/keyring/linux/secret_service.rs | 13 +- crates/file-ext/src/kind.rs | 35 - crates/p2p-block/src/block.rs | 2 - crates/p2p-block/src/block_size.rs | 115 +- crates/p2p-block/src/lib.rs | 61 +- crates/p2p-block/src/sb_request.rs | 12 +- crates/sd-indexer/Cargo.toml | 30 - crates/sd-indexer/src/ephemeral.rs | 212 ---- crates/sd-indexer/src/lib.rs | 5 - crates/sd-indexer/src/path.rs | 57 - crates/sd-indexer/src/stream.rs | 40 - crates/sync/src/compressed.rs | 45 +- crates/task-system/src/task.rs | 31 +- .../technology/normalised-cache.mdx | 8 +- docs/developers/technology/rspc.mdx | 20 +- docs/product/getting-started/setup.mdx | 9 +- .../$libraryId/Explorer/FilePath/Original.tsx | 2 +- .../$libraryId/Explorer/FilePath/Thumb.tsx | 2 +- .../$libraryId/Explorer/Inspector/index.tsx | 3 +- .../OptionsPanel/ListView/IconSize.tsx | 12 +- .../OptionsPanel/ListView/TextSize.tsx | 10 +- .../Explorer/OptionsPanel/ListView/util.ts | 17 +- .../Explorer/OptionsPanel/index.tsx | 2 +- .../Explorer/QuickPreview/index.tsx | 10 + .../Explorer/View/{Context.ts => Context.tsx} | 8 +- .../Explorer/View/Grid/DragSelect/index.tsx | 62 +- .../Explorer/View/Grid/useKeySelection.tsx | 260 +---- .../Explorer/View/GridView/index.tsx | 4 +- .../Explorer/View/ListView/index.tsx | 37 +- .../Explorer/View/MediaView/index.tsx | 4 +- .../app/$libraryId/Explorer/View/index.tsx | 34 +- .../Explorer/View/useActiveItem.tsx | 124 +++ interface/app/$libraryId/Explorer/index.tsx | 19 +- interface/app/$libraryId/Explorer/store.ts | 5 +- .../app/$libraryId/Explorer/useExplorer.ts | 9 +- .../Explorer/useExplorerItemData.tsx | 34 + .../Explorer/useExplorerOperatingSystem.tsx | 5 +- interface/app/$libraryId/Explorer/util.ts | 28 +- .../SidebarLayout/LibrariesDropdown.tsx | 4 +- .../app/$libraryId/Layout/Sidebar/index.tsx | 3 +- .../Layout/Sidebar/sections/Local/index.tsx | 4 +- .../Layout/Sidebar/sections/Tools/index.tsx | 79 ++ interface/app/$libraryId/Spacedrop/index.tsx | 4 +- interface/app/$libraryId/ephemeral.tsx | 82 +- interface/app/$libraryId/location/$id.tsx | 2 +- interface/app/$libraryId/saved-search/$id.tsx | 2 +- .../$libraryId/settings/client/general.tsx | 229 ++-- .../settings/library/tags/EditForm.tsx | 8 +- interface/app/index.tsx | 9 + interface/app/p2p/index.tsx | 87 +- interface/hooks/index.ts | 7 +- interface/hooks/useMouseItemResize.ts | 65 ++ interface/hooks/useShortcut.ts | 9 +- interface/index.tsx | 2 - interface/locales/by/common.json | 149 +-- interface/locales/de/common.json | 11 + interface/locales/en/common.json | 995 +++++++++--------- interface/locales/es/common.json | 11 + interface/locales/fr/common.json | 11 + interface/locales/it/common.json | 11 + interface/locales/ja/common.json | 11 + interface/locales/nl/common.json | 11 + interface/locales/ru/common.json | 95 +- interface/locales/tr/common.json | 11 + interface/locales/zh-CN/common.json | 87 +- interface/locales/zh-TW/common.json | 11 + interface/util/Platform.tsx | 1 + package.json | 3 +- packages/client/src/core.ts | 33 +- packages/client/src/lib/explorerItem.ts | 51 +- packages/client/src/stores/featureFlags.tsx | 5 +- packages/ui/src/ContextMenu.tsx | 2 +- packages/ui/style/colors.scss | 8 +- pnpm-lock.yaml | Bin 935911 -> 1020933 bytes scripts/utils/fetch.mjs | 10 +- 151 files changed, 5395 insertions(+), 2560 deletions(-) create mode 100644 .vscode/i18n-ally-reviews.yml create mode 100644 apps/mobile/src/components/header/DynamicHeader.tsx create mode 100644 apps/mobile/src/components/header/SearchHeader.tsx create mode 100644 core/crates/heavy-lifting/src/file_identifier/cas_id.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/job.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/mod.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/shallow.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/tasks/extract_file_metadata.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/tasks/mod.rs create mode 100644 core/crates/heavy-lifting/src/file_identifier/tasks/object_processor.rs create mode 100644 core/crates/heavy-lifting/src/utils/mod.rs create mode 100644 core/crates/heavy-lifting/src/utils/sub_path.rs create mode 100644 core/src/location/non_indexed.rs delete mode 100644 crates/sd-indexer/Cargo.toml delete mode 100644 crates/sd-indexer/src/ephemeral.rs delete mode 100644 crates/sd-indexer/src/lib.rs delete mode 100644 crates/sd-indexer/src/path.rs delete mode 100644 crates/sd-indexer/src/stream.rs rename interface/app/$libraryId/Explorer/View/{Context.ts => Context.tsx} (63%) create mode 100644 interface/app/$libraryId/Explorer/View/useActiveItem.tsx create mode 100644 interface/app/$libraryId/Explorer/useExplorerItemData.tsx create mode 100644 interface/app/$libraryId/Layout/Sidebar/sections/Tools/index.tsx create mode 100644 interface/hooks/useMouseItemResize.ts diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index 48eabd767..89cda1ffc 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -11,7 +11,7 @@ runs: - name: Install pnpm uses: pnpm/action-setup@v3 with: - version: 9.0.2 + version: 9.0.6 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 3a5c03edb..1456307f8 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -48,8 +48,14 @@ jobs: working-directory: /tmp run: | set -euxo pipefail - curl -SsJLO https://passt.top/builds/latest/x86_64/passt_954589b-1_all.deb - sudo dpkg -i passt_954589b-1_all.deb + + deb="$( + curl -SsL https://passt.top/builds/latest/x86_64 \ + | grep -oP 'passt[^\.<>'\''"]+\.deb' | sort -u | head -n1 + )" + + curl -SsJLO "https://passt.top/builds/latest/x86_64/${deb}" + sudo dpkg -i "${deb}" - name: Determine image name & tag id: image_info diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml new file mode 100644 index 000000000..6295c3a91 --- /dev/null +++ b/.vscode/i18n-ally-reviews.yml @@ -0,0 +1,115 @@ +# Review comments generated by i18n-ally. Please commit this file. + +reviews: + about_vision_text: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: OS2GadFYJi0w8WbQ1KpUe + type: approve + comment: 疑似翻译腔。这个地方不太好译。 + time: '2024-04-16T02:03:55.931Z' + all_jobs_have_been_cleared: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: hwThsx7VP-THpRXov2MB6 + type: comment + comment: 要不要把“清除”改为“完成”? + time: '2024-04-16T10:56:22.929Z' + archive_info: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: pW79_SMSNiOyRj94kdSZO + type: comment + comment: 不太通顺。“位置”是否要加定语修饰? + time: '2024-04-16T11:03:10.218Z' + changelog_page_description: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: JN3YruMypxX5wuaMjD8Hu + type: comment + comment: 口语化显得更自然些。 + time: '2024-04-16T11:05:27.478Z' + clouds: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: ebAW-cnfA4llVgee6CRmF + type: comment + comment: 一个字太少。 + time: '2024-04-16T11:06:06.594Z' + coordinates: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: HJLIcCmrHV1ZwCsAJOSiS + type: comment + comment: 有可能应该改成“地理坐标”。 + time: '2024-04-16T11:07:21.331Z' + create_library_description: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: N01f9vhjfYidHDnkhVV4o + type: comment + comment: >- + “libraries are + databases”这一句并不容易翻译,这里把英文原文放上去的方式我觉得并不妥当,但是我想不到更好的译法了。定语往后放到谓语的位置。同时添加必要的助词。 + time: '2024-04-16T11:13:48.568Z' + create_new_library_description: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: Wb89DhKwsCB9vGBDUIgsj + type: comment + comment: 见“create_library_description”。 + time: '2024-04-16T11:14:21.837Z' + creating_your_library: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: 6q9xmFoeVizgSTBbBey9O + type: comment + comment: “您的库”是典型的翻译腔。 + time: '2024-04-16T11:15:52.949Z' + delete_warning: + locales: + zh-CN: + comments: + - user: + name: Heavysnowjakarta + email: heavysnowjakarta@gmail.com + id: 5oa5lvp8PkJDRceIenfne + type: comment + comment: 我不确定 `{{type}}` 是中文还是英文。如果是英文,前面应该加空格。 + time: '2024-04-16T11:24:52.250Z' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a27b2da..bfa3857ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ If you encounter any issues, ensure that you are using the following versions of - Rust version: **1.75** - Node version: **18.18** -- Pnpm version: **9.0.2** +- Pnpm version: **9.0.6** After cleaning out your build artifacts using `pnpm clean`, `git clean`, or `cargo clean`, it is necessary to re-run the `setup-system` script. diff --git a/Cargo.lock b/Cargo.lock index 6f1c61d3016007451761c75b571c8ec409c7106d..b2e9285de4f52ff05eceb963f6af1e199a748121 100644 GIT binary patch delta 433 zcmW;ET}YE*6u@!LIh#(~ckjCowdF>ZWOk8=Q9~l>gFuu*A!A@tU6kFltS(}RQ4pCW z4|2Sy5KJMewRnV+c+-eQcB6qwq_#-$auLR~GDy1r|Ngf>dgcc_saPwg8_?9(T1P=w zpNy2Lmae>xUL7~}%-!{^diq6MwR(FftWPa07N`O8^Xf8{tHd``4aJKX0h;xXrM))o z$i1nMdj>=O8J6oi@Pk(lM)6#cj2y-~pG?MZjHJB-_ms$Z0yS`d0=G-58%HZS_5|Cc@aaaa zKE3iw-xwB~()}JEI;DILUBm~zU>DDR#uEN+kd=$G*vJzmmCB7QmO7*^hh-KyvVuKM zd5+X*la)<0Yf>w9SszqBz=jjV)Ya+Y&WspqnbI=z4 z;~_gQ_$kAK+bG1T05$Q^3>fk^KuPQWWjaKDtAr}(88MvMFw+g_lJ!bDzmw;k;N|?RTN$l3m7sp88v`X#DRiZD|NBhZ zf@JGGo%8NF=YRgculrvQt^T9uSD(7==4In}l1$QvAI-;-el%aX`1?2iX@=$+Y43+K zGv&A@(J1Vtc9>zsDP@L-f;mP>%r0c}|vy=BM7>I`&}l)pCrqjGw_mR`OwyK%}WrFjh2 z#;9W<6lUBAs{)~36`H6hq>oN=14A(sqVQ4#sxc>mDX$aB^IAFe?a8v)TDFK6{(cNu zSDtvyOi^hCHCk|H0%I!DLR&2@wmJr{ox++GoJ%DzX9d%^&@L$9ynvr7ZORk6R_^}l zdlvuukKf#K*+k1U{M_uefg_>SuFo~buO8o4T_{OgAIT0*sUl!Z6omtFi=~tlj>)VN z(F=i%qgpEuGuA6Fs-apgena zUD_pKuc*CZI#NTig*Ad8!aJdek))uJGD@PB&e%daVwf@Q;bDz0=Vns8|PPC#c;9|-z?%T0=$2Y&TY@mCvxw_xBt9&ru zuz2f>U+M3gIWjR*Z_lR_`*SnJImK`$L3qIxwuTWb3uiGl-Vpc?QfZ2*3LFP6q7yz8 zw(wDU=ZF>M*(cTw{G#~sv1d0dj{o4xSM(fehxzoA-LQl6!7^h+(82*4jdi4mSYj6q zcEJ}`v4}aX`UrzYFdMPAQBAq*m4>$a}FA> zaV(&{PDEok*ShcuE27hb$FjBQcK4Nkphx=K&3Xe)EYhi`(Z=-LC$pYT`c&3DSP@G@8GG+R z?@0&$60J?oy`1%Ru6;GT7ri~#`M2}gpJbPQ_|o}|bnd^H_4JR-O`CU=38+plodjZ9 zq6rB~0U;QP&{xB;E(~W%QctK3%*p5sOorf$(^h#aJtkf=r+p|5ucZfC*|PH7w%&5% zl`GPx1-c@wcr6=9uU^PDr<*^LtxoY8G@PFJNIo`r*Me!x8z_X(NmkCD0kprC?M(ac zK|_75`rK^ua7e2^hPHP;@qgL9TL*0YP`xof;p>OW*?%xo-YHJBP!vb1i7TWo80L{V z>!H!a*@9@0wh)~L+(Z#f^aY_*IKm_m>BNz2eYxf118MAQXmwt0^JFZ7Ije`}RZtJAQ);4C z79aqSur|;Tu@4GBDjO7hMID8{n$0{1I9)5@$@s zgf3X(!u&!jwA0rYvkjZ(=bPQZnYlyV&sjeqmcBq+leq?smMiv*j@`9TcheJh&(3t` zTH&sC{ZJ#lG?-tNp5E0n+zVi@&n?~V_a@Pu%ll^+X6Ea&rkUP43huyxyUclKK;+<7 zHM7PO5HhN~p-`@nKmkJwX#}w{2GCZ5Jyj%NsJQ5=Tc2MNjP%Lh$u3LZxDu^PcND0v zbG<@Et=CTbNYkw^WOgd5;H4=F37C_Cfm&UlD*>cboBbE7JSVp|u@#BjW2bk}#{hRK*CVCK!~77vN!x zhyactOoUq`l3OCRq##jJ`GWG`iDZ=7N~i)a%F{<`>wRbe6N*XIZCG>KwN=Fou+Ox< zfp+GuEz;`OGrLFeB2d7zAZf&)s&@lmf4kpdeLqHKod6fDn&hkFww(Crl}4jmDA@5cI+a zNiozwqVpJ_R-U+{mJTjt1Lbv}?oVHM7_CU>Fd7 zvbNAPPtLL6XJ)LKtLZ2F{Ror^FqVmaf`aAi- zJ)8wbG$;e0DRQqlF*b4oAplZPL7<5^2FyiK1OpK(;3$gj48SskbYc{w{lK;P#`HZ0 zg7o-~e4yO6Z#4b;Vz#c-`&Mn5n+@GA9lj(CzwBmd{Mmd%dHw@q{g?D04d0TDZ3I0y z8rnc~5`Mv& zwEAz*NP6*Fw7g9FhBrZfOr@dY6w)YOLUnVx-X$C>$$8Sb?|HPj2Tjz|{`)dN#R8TZ zoJCq^l~U9~_R_%tM8N(F1q>6F3<*kgft4^aL=TA;!U%?kRC(dcYt!)$pn<`;7SJmA ziLSb)fBkK=DdGP_y`9f~3!ND1ooNI5(o0QXHP|er5d@<$URGsT2a=~iuUJqeI28yWJZgI`DYW_|UB4|j)F=W}nM(QLTkZa>;G%_az3m#m%7{}}B?>D5<&%|jQ_ z<>|J!(8_f6o9Lg@D;LrFwCktnH##5vDf(=0ckTU9TJNPL(*w_Dqv;ES`AB-;f0v%` zOTX97S8krOm+Z1J*P3mb`NQ2d&ox7%vhBxS2T#BMEqLTF-b6)un)Z=l1^m@w^I9@ETPmt<7op(Y!bP?8j(BI`F}4dD=1r zk^RFjY)>Z#^S;iXt;~C`y5vMGoss!7V^yXj@6raD_8!P?+~XY&f?^)3*o7m"] default-run = "sd-desktop" diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c925b7d95..aaaed1dbf 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -7,6 +7,7 @@ use std::{ collections::HashMap, fs, path::PathBuf, + process::Command, sync::{Arc, Mutex, PoisonError}, time::Duration, }; @@ -149,6 +150,47 @@ async fn open_logs_dir(node: tauri::State<'_, Arc>) -> Result<(), ()> { }) } +#[tauri::command(async)] +#[specta::specta] +async fn open_trash_in_os_explorer() -> Result<(), ()> { + #[cfg(target_os = "macos")] + { + let full_path = format!("{}/.Trash/", std::env::var("HOME").unwrap()); + + Command::new("open") + .arg(full_path) + .spawn() + .map_err(|err| error!("Error opening trash: {err:#?}"))? + .wait() + .map_err(|err| error!("Error opening trash: {err:#?}"))?; + + Ok(()) + } + + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .arg("shell:RecycleBinFolder") + .spawn() + .map_err(|err| error!("Error opening trash: {err:#?}"))? + .wait() + .map_err(|err| error!("Error opening trash: {err:#?}"))?; + return Ok(()); + } + + #[cfg(target_os = "linux")] + { + Command::new("xdg-open") + .arg("~/.local/share/Trash/") + .spawn() + .map_err(|err| error!("Error opening trash: {err:#?}"))? + .wait() + .map_err(|err| error!("Error opening trash: {err:#?}"))?; + + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] #[serde(tag = "type")] pub enum DragAndDropEvent { @@ -218,6 +260,7 @@ async fn main() -> tauri::Result<()> { reload_webview, set_menu_bar_item_state, request_fda_macos, + open_trash_in_os_explorer, file::open_file_paths, file::open_ephemeral_files, file::get_file_path_open_with_apps, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a4743ebe7..749bf610d 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -12,6 +12,8 @@ "macOSPrivateApi": true, "bundle": { "active": true, + "publisher": "Spacedrive Technology Inc.", + "category": "Productivity", "targets": ["deb", "msi", "dmg", "updater"], "identifier": "com.spacedrive.desktop", "icon": [ @@ -24,7 +26,7 @@ "resources": {}, "externalBin": [], "copyright": "Spacedrive Technology Inc.", - "shortDescription": "File explorer from the future.", + "shortDescription": "Spacedrive", "longDescription": "Cross-platform universal file explorer, powered by an open-source virtual distributed filesystem.", "deb": { "files": { diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 662011a6a..360b8c8a7 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -41,6 +41,17 @@ export const commands = { async requestFdaMacos(): Promise { return await TAURI_INVOKE('plugin:tauri-specta|request_fda_macos'); }, + async openTrashInOsExplorer(): Promise<__Result__> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|open_trash_in_os_explorer') + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, async openFilePaths( library: string, ids: number[] diff --git a/apps/mobile/src/components/browse/BrowseLocations.tsx b/apps/mobile/src/components/browse/BrowseLocations.tsx index 6ceadd16c..dc4020184 100644 --- a/apps/mobile/src/components/browse/BrowseLocations.tsx +++ b/apps/mobile/src/components/browse/BrowseLocations.tsx @@ -1,8 +1,8 @@ import { useNavigation } from '@react-navigation/native'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { DotsThreeOutline, Plus } from 'phosphor-react-native'; import { useRef } from 'react'; import { Text, View } from 'react-native'; -import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw } from '~/lib/tailwind'; import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack'; diff --git a/apps/mobile/src/components/browse/BrowseTags.tsx b/apps/mobile/src/components/browse/BrowseTags.tsx index fa74e98d8..9b5cc0ef0 100644 --- a/apps/mobile/src/components/browse/BrowseTags.tsx +++ b/apps/mobile/src/components/browse/BrowseTags.tsx @@ -1,8 +1,8 @@ import { useNavigation } from '@react-navigation/native'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { DotsThreeOutline, Plus } from 'phosphor-react-native'; import React, { useRef } from 'react'; import { Text, View } from 'react-native'; -import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw } from '~/lib/tailwind'; import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack'; diff --git a/apps/mobile/src/components/header/DynamicHeader.tsx b/apps/mobile/src/components/header/DynamicHeader.tsx new file mode 100644 index 000000000..5973280f3 --- /dev/null +++ b/apps/mobile/src/components/header/DynamicHeader.tsx @@ -0,0 +1,110 @@ +import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types'; +import { RouteProp, useNavigation } from '@react-navigation/native'; +import { NativeStackHeaderProps } from '@react-navigation/native-stack'; +import { ArrowLeft, DotsThreeOutline, MagnifyingGlass } from 'phosphor-react-native'; +import { Platform, Pressable, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { tw, twStyle } from '~/lib/tailwind'; +import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore'; +import { Icon } from '../icons/Icon'; + + +type Props = { + headerRoute?: NativeStackHeaderProps; //supporting title from the options object of navigation + optionsRoute?: RouteProp; //supporting params passed + kind: 'tag' | 'location'; //the kind of icon to display + explorerMenu?: boolean; //whether to show the explorer menu +}; + +export default function DynamicHeader({ + headerRoute, + optionsRoute, + kind, + explorerMenu = true +}: Props) { + const navigation = useNavigation(); + const headerHeight = useSafeAreaInsets().top; + const isAndroid = Platform.OS === 'android'; + const explorerStore = useExplorerStore(); + + return ( + + + + + navigation.goBack()} + > + + + + + + {headerRoute?.options.title} + + + + + {explorerMenu && { + getExplorerStore().toggleMenu = !explorerStore.toggleMenu; + }} + > + + } + { + navigation.navigate('SearchStack', { + screen: 'Search' + }); + }} + > + + + + + + + ); +} + +interface HeaderIconKindProps { + routeParams?: any; + kind: Props['kind']; +} + +const HeaderIconKind = ({routeParams, kind }: HeaderIconKindProps) => { + switch (kind) { + case 'location': + return ; + case 'tag': + return ( + + ); + default: + return null; + } +}; diff --git a/apps/mobile/src/components/header/Header.tsx b/apps/mobile/src/components/header/Header.tsx index 3fca1ef08..a60fd4b2e 100644 --- a/apps/mobile/src/components/header/Header.tsx +++ b/apps/mobile/src/components/header/Header.tsx @@ -1,48 +1,25 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackHeaderProps } from '@react-navigation/native-stack'; -import { ArrowLeft, DotsThreeOutline, List, MagnifyingGlass } from 'phosphor-react-native'; +import { RouteProp, useNavigation } from '@react-navigation/native'; +import { ArrowLeft, List, MagnifyingGlass } from 'phosphor-react-native'; import { Platform, Pressable, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tw, twStyle } from '~/lib/tailwind'; -import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore'; -import { Icon } from '../icons/Icon'; -import Search from '../search/Search'; - -type HeaderProps = { - title?: string; //title of the page - showSearch?: boolean; //show the search button - showDrawer?: boolean; //show the drawer button - searchType?: 'explorer' | 'location' | 'categories'; //Temporary - navBack?: boolean; //navigate back to the previous screen - headerKind?: 'default' | 'location' | 'tag'; //kind of header - route?: never; - routeTitle?: never; +type Props = { + route?: RouteProp; // supporting title from the options object of navigation + navBack?: boolean; // whether to show the back icon + search?: boolean; // whether to show the search icon + title?: string; // in some cases - we want to override the route title }; -//you can pass in a routeTitle only if route is passed in -type Props = - | HeaderProps - | ({ - route: NativeStackHeaderProps; - routeTitle?: boolean; - } & Omit); - // Default header with search bar and button to open drawer export default function Header({ - title, - searchType, - navBack, route, - routeTitle, - headerKind = 'default', - showDrawer = false, - showSearch = true + navBack, + title, + search = false }: Props) { const navigation = useNavigation(); - const explorerStore = useExplorerStore(); - const routeParams = route?.route.params as any; const headerHeight = useSafeAreaInsets().top; const isAndroid = Platform.OS === 'android'; @@ -52,38 +29,25 @@ export default function Header({ paddingTop: headerHeight + (isAndroid ? 15 : 0) })} > - + - {navBack && ( + {navBack ? ( { - navigation.goBack(); - }} - > - - - )} - - - {showDrawer && ( - navigation.openDrawer()}> - - - )} - - {title || (routeTitle && route?.options.title)} - - + hitSlop={24} + onPress={() => navigation.goBack()} + > + + + + ) : ( + navigation.openDrawer()}> + + + )} + {title || route?.name} - - {showSearch && ( - - { navigation.navigate('SearchStack', { @@ -96,67 +60,9 @@ export default function Header({ weight="bold" color={tw.color('text-zinc-300')} /> - - - )} - {(headerKind === 'location' || headerKind === 'tag') && ( - { - getExplorerStore().toggleMenu = !explorerStore.toggleMenu; - }} - > - - - )} - + } - {searchType && } ); } - -interface HeaderSearchTypeProps { - searchType: HeaderProps['searchType']; -} - -const HeaderSearchType = ({ searchType }: HeaderSearchTypeProps) => { - switch (searchType) { - case 'explorer': - return 'Explorer'; //TODO - case 'location': - return ; - case 'categories': - return ; - default: - return null; - } -}; - -interface HeaderIconKindProps { - headerKind: HeaderProps['headerKind']; - routeParams?: any; -} - -const HeaderIconKind = ({ headerKind, routeParams }: HeaderIconKindProps) => { - switch (headerKind) { - case 'location': - return ; - case 'tag': - return ( - - ); - default: - return null; - } -}; diff --git a/apps/mobile/src/components/header/SearchHeader.tsx b/apps/mobile/src/components/header/SearchHeader.tsx new file mode 100644 index 000000000..0312f6544 --- /dev/null +++ b/apps/mobile/src/components/header/SearchHeader.tsx @@ -0,0 +1,53 @@ +import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types'; +import { RouteProp, useNavigation } from '@react-navigation/native'; +import { ArrowLeft } from 'phosphor-react-native'; +import { Platform, Pressable, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { tw, twStyle } from '~/lib/tailwind'; +import Search from '../search/Search'; + + +const searchPlaceholder = { + locations: 'Search location name...', + tags: 'Search tag name...', + categories: 'Search category name...', +} + +type Props = { + route?: RouteProp; // supporting title from the options object of navigation + kind: keyof typeof searchPlaceholder; // the kind of search we are doing + title?: string; // in some cases - we want to override the route title +}; + +export default function SearchHeader({ + route, + kind, + title +}: Props) { + const navigation = useNavigation(); + const headerHeight = useSafeAreaInsets().top; + const isAndroid = Platform.OS === 'android'; + + return ( + + + + + navigation.goBack()} + > + + + {title || route?.name} + + + + + + ); +} diff --git a/apps/mobile/src/components/layout/Fade.tsx b/apps/mobile/src/components/layout/Fade.tsx index 6eab661d0..17acb3e76 100644 --- a/apps/mobile/src/components/layout/Fade.tsx +++ b/apps/mobile/src/components/layout/Fade.tsx @@ -1,9 +1,7 @@ -import { useRoute } from '@react-navigation/native'; import { DimensionValue, Platform } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import { ClassInput } from 'twrnc'; import { tw, twStyle } from '~/lib/tailwind'; -import { useExplorerStore } from '~/stores/explorerStore'; interface Props { children: React.ReactNode; // children of fade @@ -13,7 +11,6 @@ interface Props { orientation?: 'horizontal' | 'vertical'; // orientation of fade fadeSides?: 'left-right' | 'top-bottom'; // which sides to fade screenFade?: boolean; // if true, the fade will consider the bottom tab bar height - noConditions?: boolean; // if true, the fade will be rendered as is bottomFadeStyle?: ClassInput; // tailwind style for bottom fade topFadeStyle?: ClassInput; // tailwind style for top fade } @@ -25,20 +22,15 @@ const Fade = ({ height, bottomFadeStyle, topFadeStyle, - noConditions = false, screenFade = false, fadeSides = 'left-right', orientation = 'horizontal' }: Props) => { - const route = useRoute(); - const { toggleMenu } = useExplorerStore(); const bottomTabBarHeight = Platform.OS === 'ios' ? 80 : 60; const gradientStartEndMap = { 'left-right': { start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, 'top-bottom': { start: { x: 0, y: 1 }, end: { x: 0, y: 0 } } }; - const menuHeight = 57; // height of the explorer menu - const routesWithMenu = ['Location', 'Search', 'Tag']; // routes that are associated with the explorer return ( <> - { @@ -55,20 +38,9 @@ const ScreenContainer = ({ > {children} - ) : ( - {children} - ); }; diff --git a/apps/mobile/src/components/locations/GridLocation.tsx b/apps/mobile/src/components/locations/GridLocation.tsx index 19538ea58..5a9ff67c3 100644 --- a/apps/mobile/src/components/locations/GridLocation.tsx +++ b/apps/mobile/src/components/locations/GridLocation.tsx @@ -28,7 +28,7 @@ const GridLocation: React.FC = ({ location, modalRef }: GridL )} /> - modalRef.current?.present()}> + modalRef.current?.present()}> { <> - { )} /> - diff --git a/apps/mobile/src/components/search/Search.tsx b/apps/mobile/src/components/search/Search.tsx index 3b70fc2c8..a752d8040 100644 --- a/apps/mobile/src/components/search/Search.tsx +++ b/apps/mobile/src/components/search/Search.tsx @@ -18,7 +18,7 @@ export default function Search({ placeholder }: Props) { }, [searchStore]); return ( searchStore.setSearch(text)} diff --git a/apps/mobile/src/components/search/filters/FiltersBar.tsx b/apps/mobile/src/components/search/filters/FiltersBar.tsx index bf019f3e0..0dd249da4 100644 --- a/apps/mobile/src/components/search/filters/FiltersBar.tsx +++ b/apps/mobile/src/components/search/filters/FiltersBar.tsx @@ -47,7 +47,7 @@ const FiltersBar = () => { - + { backgroundColor: tag.color! })} /> - modalRef.current?.present()}> + modalRef.current?.present()}> { - return
; + return
; } }} /> diff --git a/apps/mobile/src/navigation/tabs/BrowseStack.tsx b/apps/mobile/src/navigation/tabs/BrowseStack.tsx index 6c5091013..cdf751c55 100644 --- a/apps/mobile/src/navigation/tabs/BrowseStack.tsx +++ b/apps/mobile/src/navigation/tabs/BrowseStack.tsx @@ -8,6 +8,8 @@ import LocationsScreen from '~/screens/browse/Locations'; import TagScreen from '~/screens/browse/Tag'; import TagsScreen from '~/screens/browse/Tags'; +import DynamicHeader from '~/components/header/DynamicHeader'; +import SearchHeader from '~/components/header/SearchHeader'; import { TabScreenProps } from '../TabNavigator'; const Stack = createNativeStackNavigator(); @@ -18,44 +20,44 @@ export default function BrowseStack() {
}} + options={({route}) => ({ + header: () =>
+ })} /> ( -
- ) - }} + options={({route: optionsRoute}) => ({ + header: (route) => + })} />
- }} + options={({route}) => ({ + header: () => + })} />
- }} + options={({route}) => ({ + header: () => + })} />
- }} + options={({route: optionsRoute}) => ({ + header: (route) => + })} />
- }} + options={({route}) => ({ + header: () =>
+ })} /> ); diff --git a/apps/mobile/src/navigation/tabs/NetworkStack.tsx b/apps/mobile/src/navigation/tabs/NetworkStack.tsx index 6ef75cfd3..25171b516 100644 --- a/apps/mobile/src/navigation/tabs/NetworkStack.tsx +++ b/apps/mobile/src/navigation/tabs/NetworkStack.tsx @@ -13,7 +13,9 @@ export default function NetworkStack() {
}} + options={({route}) => ({ + header: () =>
+ })} /> ); diff --git a/apps/mobile/src/navigation/tabs/OverviewStack.tsx b/apps/mobile/src/navigation/tabs/OverviewStack.tsx index 3837d8e32..ac0dd6068 100644 --- a/apps/mobile/src/navigation/tabs/OverviewStack.tsx +++ b/apps/mobile/src/navigation/tabs/OverviewStack.tsx @@ -1,9 +1,10 @@ import { CompositeScreenProps } from '@react-navigation/native'; import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack'; -import Header from '~/components/header/Header'; import CategoriesScreen from '~/screens/overview/Categories'; import OverviewScreen from '~/screens/overview/Overview'; +import Header from '~/components/header/Header'; +import SearchHeader from '~/components/header/SearchHeader'; import { TabScreenProps } from '../TabNavigator'; const Stack = createNativeStackNavigator(); @@ -14,14 +15,16 @@ export default function OverviewStack() {
}} + options={({route}) => ({ + header: () =>
+ })} />
- }} + options={({route}) => ({ + header: () => + })} /> ); diff --git a/apps/mobile/src/navigation/tabs/SettingsStack.tsx b/apps/mobile/src/navigation/tabs/SettingsStack.tsx index 7655a0764..1ace6dded 100644 --- a/apps/mobile/src/navigation/tabs/SettingsStack.tsx +++ b/apps/mobile/src/navigation/tabs/SettingsStack.tsx @@ -18,6 +18,7 @@ import NodesSettingsScreen from '~/screens/settings/library/NodesSettings'; import TagsSettingsScreen from '~/screens/settings/library/TagsSettings'; import SettingsScreen from '~/screens/settings/Settings'; +import SearchHeader from '~/components/header/SearchHeader'; import { TabScreenProps } from '../TabNavigator'; const Stack = createNativeStackNavigator(); @@ -28,7 +29,9 @@ export default function SettingsStack() {
}} + options={({route}) => ({ + header: () =>
+ })} /> {/* Client */}
- }} + options={() => ({ + header: () => + })} /> ['navigation']>(); const modalRef = useRef(null); + const {search} = useSearchStore(); const tags = useLibraryQuery(['tags.list']); useNodes(tags.data?.nodes); const tagData = useCache(tags.data?.items); + const [debouncedSearch] = useDebounce(search, 200); + + const filteredTags = useMemo( + () => + tagData?.filter((location) => + location.name?.toLowerCase().includes(debouncedSearch.toLowerCase()) + ) ?? [], + [debouncedSearch, tagData] + ); return ( @@ -36,15 +47,8 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) { > - ( - ); diff --git a/apps/mobile/src/screens/search/Filters.tsx b/apps/mobile/src/screens/search/Filters.tsx index d7cb48798..bed24f492 100644 --- a/apps/mobile/src/screens/search/Filters.tsx +++ b/apps/mobile/src/screens/search/Filters.tsx @@ -5,7 +5,7 @@ import SaveAdd from '~/components/search/filters/SaveAdd'; const FiltersScreen = () => { return ( <> - + diff --git a/apps/mobile/src/screens/settings/Settings.tsx b/apps/mobile/src/screens/settings/Settings.tsx index 2579eafa8..bfd6fc588 100644 --- a/apps/mobile/src/screens/settings/Settings.tsx +++ b/apps/mobile/src/screens/settings/Settings.tsx @@ -129,7 +129,7 @@ function renderSectionHeader({ section }: { section: { title: string } }) { {section.title} @@ -142,24 +142,24 @@ export default function SettingsScreen({ navigation }: SettingsStackScreenProps< return ( - ( - navigation.navigate(item.navigateTo as any)} - rounded={item.rounded} - /> - )} - renderSectionHeader={renderSectionHeader} - ListFooterComponent={} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - initialNumToRender={50} - /> - + ( + navigation.navigate(item.navigateTo as any)} + rounded={item.rounded} + /> + )} + renderSectionHeader={renderSectionHeader} + ListFooterComponent={} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + initialNumToRender={50} + /> + ); } diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 0c96b46d0..f1df4dab7 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -4,7 +4,7 @@ use axum::{ extract::{FromRequestParts, State}, headers::{authorization::Basic, Authorization}, http::Request, - middleware::{self, Next}, + middleware::Next, response::{IntoResponse, Response}, routing::get, TypedHeader, @@ -24,12 +24,13 @@ pub struct AppState { auth: HashMap, } +#[allow(unused)] async fn basic_auth( State(state): State, request: Request, next: Next, ) -> Response { - let request = if state.auth.len() != 0 { + let request = if !state.auth.is_empty() { let (mut parts, body) = request.into_parts(); let Ok(TypedHeader(Authorization(hdr))) = @@ -46,7 +47,7 @@ async fn basic_auth( if state .auth .get(hdr.username()) - .and_then(|pass| Some(*pass == SecStr::from(hdr.password()))) + .map(|pass| *pass == SecStr::from(hdr.password())) != Some(true) { return Response::builder() @@ -110,7 +111,7 @@ async fn main() { .into_iter() .enumerate() .filter_map(|(i, s)| { - if s.len() == 0 { + if s.is_empty() { return None; } @@ -133,7 +134,7 @@ async fn main() { }; // We require credentials in production builds (unless explicitly disabled) - if auth.len() == 0 && !disabled { + if auth.is_empty() && !disabled { #[cfg(not(debug_assertions))] { warn!("The 'SD_AUTH' environment variable is not set!"); @@ -143,6 +144,7 @@ async fn main() { } } + #[cfg(not(feature = "assets"))] let state = AppState { auth }; let (node, router) = match Node::new( @@ -243,7 +245,7 @@ async fn main() { let app = app .route("/", get(|| async { "Spacedrive Server!" })) .fallback(|| async { "404 Not Found: We're past the event horizon..." }) - .layer(middleware::from_fn_with_state(state, basic_auth)); + .layer(axum::middleware::from_fn_with_state(state, basic_auth)); let mut addr = "[::]:8080".parse::().unwrap(); // This listens on IPv6 and IPv4 addr.set_port(port); diff --git a/core/Cargo.toml b/core/Cargo.toml index e6c75fcfd..d7cf9815f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sd-core" -version = "0.2.11" +version = "0.2.13" description = "Virtual distributed filesystem engine that powers Spacedrive." authors = ["Spacedrive Technology Inc."] rust-version = "1.75.0" @@ -32,15 +32,15 @@ sd-ai = { path = "../crates/ai", optional = true } sd-cache = { path = "../crates/cache" } sd-cloud-api = { version = "0.1.0", path = "../crates/cloud-api" } sd-crypto = { path = "../crates/crypto", features = [ - "sys", - "tokio", + "sys", + "tokio", ], optional = true } sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } sd-file-ext = { path = "../crates/file-ext" } sd-images = { path = "../crates/images", features = [ - "rspc", - "serde", - "specta", + "rspc", + "serde", + "specta", ] } sd-media-metadata = { path = "../crates/media-metadata" } sd-p2p = { path = "../crates/p2p", features = ["specta"] } @@ -50,7 +50,6 @@ sd-p2p-tunnel = { path = "../crates/p2p-tunnel" } sd-prisma = { path = "../crates/prisma" } sd-sync = { path = "../crates/sync" } sd-utils = { path = "../crates/utils" } -sd-indexer = { path = "../crates/sd-indexer" } # Workspace dependencies async-channel = { workspace = true } @@ -72,27 +71,28 @@ reqwest = { workspace = true, features = ["json", "native-tls-vendored"] } rmp-serde = { workspace = true } rmpv = { workspace = true } rspc = { workspace = true, features = [ - "axum", - "uuid", - "chrono", - "tracing", - "alpha", - "unstable", + "axum", + "uuid", + "chrono", + "tracing", + "alpha", + "unstable", ] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } specta = { workspace = true } +static_assertions = { workspace = true } strum = { workspace = true, features = ["derive"] } strum_macros = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = [ - "sync", - "rt-multi-thread", - "io-util", - "macros", - "time", - "process", + "sync", + "rt-multi-thread", + "io-util", + "macros", + "time", + "process", ] } tokio-stream = { workspace = true, features = ["fs"] } tokio-util = { workspace = true, features = ["io"] } @@ -102,6 +102,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } uuid = { workspace = true, features = ["v4", "serde"] } webp = { workspace = true } + # Specific Core dependencies async-recursion = "1.0.5" async-stream = "0.3.5" @@ -121,23 +122,16 @@ int-enum = "0.5.0" libc = "0.2.153" mini-moka = "0.10.2" notify = { git = "https://github.com/notify-rs/notify.git", rev = "c3929ed114fbb0bc7457a9a498260461596b00ca", default-features = false, features = [ - "macos_fsevent", + "macos_fsevent", ] } rmp = "0.8.12" serde-hashkey = "0.4.5" serde_repr = "0.1" serde_with = "3.4.0" slotmap = "1.0.6" -static_assertions = "1.1.0" sysinfo = "0.29.10" tar = "0.4.40" tower-service = "0.3.2" -opendal = { version = "0.45.1", features = [ - "services-gdrive", - "services-s3", - "services-fs", -] } -sync_wrapper = { version = "1.0.1", features = ["futures"] } # Override features of transitive dependencies [dependencies.openssl] @@ -160,10 +154,10 @@ trash = "4.1.0" [target.'cfg(target_os = "ios")'.dependencies] icrate = { version = "0.1.0", features = [ - "Foundation", - "Foundation_NSFileManager", - "Foundation_NSString", - "Foundation_NSNumber", + "Foundation", + "Foundation_NSFileManager", + "Foundation_NSString", + "Foundation_NSNumber", ] } [dev-dependencies] diff --git a/core/crates/file-path-helper/src/isolated_file_path_data.rs b/core/crates/file-path-helper/src/isolated_file_path_data.rs index 21852fe18..ba321dbbc 100644 --- a/core/crates/file-path-helper/src/isolated_file_path_data.rs +++ b/core/crates/file-path-helper/src/isolated_file_path_data.rs @@ -100,6 +100,18 @@ impl<'a> IsolatedFilePathData<'a> { self.extension.as_ref() } + #[must_use] + pub fn to_owned(self) -> IsolatedFilePathData<'static> { + IsolatedFilePathData { + location_id: self.location_id, + materialized_path: Cow::Owned(self.materialized_path.to_string()), + is_dir: self.is_dir, + name: Cow::Owned(self.name.to_string()), + extension: Cow::Owned(self.extension.to_string()), + relative_path: Cow::Owned(self.relative_path.to_string()), + } + } + #[must_use] pub const fn is_dir(&self) -> bool { self.is_dir diff --git a/core/crates/heavy-lifting/Cargo.toml b/core/crates/heavy-lifting/Cargo.toml index a1bd037e1..fb73a63fd 100644 --- a/core/crates/heavy-lifting/Cargo.toml +++ b/core/crates/heavy-lifting/Cargo.toml @@ -16,6 +16,7 @@ sd-core-prisma-helpers = { path = "../prisma-helpers" } sd-core-sync = { path = "../sync" } # Sub-crates +sd-file-ext = { path = "../../../crates/file-ext" } sd-prisma = { path = "../../../crates/prisma" } sd-sync = { path = "../../../crates/sync" } sd-task-system = { path = "../../../crates/task-system" } @@ -24,6 +25,7 @@ sd-utils = { path = "../../../crates/utils" } async-channel = { workspace = true } async-trait = { workspace = true } +blake3 = { workspace = true } chrono = { workspace = true, features = ["serde"] } futures = { workspace = true } futures-concurrency = { workspace = true } @@ -37,6 +39,7 @@ rspc = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } specta = { workspace = true } +static_assertions = { workspace = true } strum = { workspace = true, features = ["derive", "phf"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "sync", "parking_lot"] } diff --git a/core/crates/heavy-lifting/src/file_identifier/cas_id.rs b/core/crates/heavy-lifting/src/file_identifier/cas_id.rs new file mode 100644 index 000000000..5ad5a9456 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/cas_id.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +use blake3::Hasher; +use static_assertions::const_assert; +use tokio::{ + fs::{self, File}, + io::{self, AsyncReadExt, AsyncSeekExt, SeekFrom}, +}; + +const SAMPLE_COUNT: u64 = 4; +const SAMPLE_SIZE: u64 = 1024 * 10; +const HEADER_OR_FOOTER_SIZE: u64 = 1024 * 8; + +// minimum file size of 100KiB, to avoid sample hashing for small files as they can be smaller than the total sample size +const MINIMUM_FILE_SIZE: u64 = 1024 * 100; + +// Asserting that nobody messed up our consts +const_assert!((HEADER_OR_FOOTER_SIZE * 2 + SAMPLE_COUNT * SAMPLE_SIZE) < MINIMUM_FILE_SIZE); + +// Asserting that the sample size is larger than header/footer size, as the same buffer is used for both +const_assert!(SAMPLE_SIZE > HEADER_OR_FOOTER_SIZE); + +// SAFETY: Casts here are safe, they're hardcoded values we have some const assertions above to make sure they're correct +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_possible_wrap)] +pub async fn generate_cas_id( + path: impl AsRef + Send, + size: u64, +) -> Result { + let mut hasher = Hasher::new(); + hasher.update(&size.to_le_bytes()); + + if size <= MINIMUM_FILE_SIZE { + // For small files, we hash the whole file + hasher.update(&fs::read(path).await?); + } else { + let mut file = File::open(path).await?; + let mut buf = vec![0; SAMPLE_SIZE as usize].into_boxed_slice(); + + // Hashing the header + let mut current_pos = file + .read_exact(&mut buf[..HEADER_OR_FOOTER_SIZE as usize]) + .await? as u64; + hasher.update(&buf[..HEADER_OR_FOOTER_SIZE as usize]); + + // Sample hashing the inner content of the file + let seek_jump = (size - HEADER_OR_FOOTER_SIZE * 2) / SAMPLE_COUNT; + loop { + file.read_exact(&mut buf).await?; + hasher.update(&buf); + + if current_pos >= (HEADER_OR_FOOTER_SIZE + seek_jump * (SAMPLE_COUNT - 1)) { + break; + } + + current_pos = file.seek(SeekFrom::Start(current_pos + seek_jump)).await?; + } + + // Hashing the footer + file.seek(SeekFrom::End(-(HEADER_OR_FOOTER_SIZE as i64))) + .await?; + file.read_exact(&mut buf[..HEADER_OR_FOOTER_SIZE as usize]) + .await?; + hasher.update(&buf[..HEADER_OR_FOOTER_SIZE as usize]); + } + + Ok(hasher.finalize().to_hex()[..16].to_string()) +} diff --git a/core/crates/heavy-lifting/src/file_identifier/job.rs b/core/crates/heavy-lifting/src/file_identifier/job.rs new file mode 100644 index 000000000..d01a55f50 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/job.rs @@ -0,0 +1,566 @@ +use crate::{ + job_system::{ + job::{Job, JobReturn, JobTaskDispatcher, ReturnStatus}, + report::ReportOutputMetadata, + utils::cancel_pending_tasks, + SerializableJob, SerializedTasks, + }, + utils::sub_path::maybe_get_iso_file_path_from_sub_path, + Error, JobContext, JobName, LocationScanState, NonCriticalJobError, ProgressUpdate, +}; + +use sd_core_file_path_helper::IsolatedFilePathData; +use sd_core_prisma_helpers::file_path_for_file_identifier; + +use sd_prisma::prisma::{file_path, location, SortOrder}; +use sd_task_system::{ + AnyTaskOutput, IntoTask, SerializableTask, Task, TaskDispatcher, TaskHandle, TaskId, + TaskOutput, TaskStatus, +}; +use sd_utils::db::maybe_missing; + +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + mem, + path::PathBuf, + sync::Arc, + time::Duration, +}; + +use futures::{stream::FuturesUnordered, StreamExt}; +use futures_concurrency::future::TryJoin; +use prisma_client_rust::or; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::time::Instant; +use tracing::warn; + +use super::{ + tasks::{ + ExtractFileMetadataTask, ExtractFileMetadataTaskOutput, ObjectProcessorTask, + ObjectProcessorTaskMetrics, + }, + FileIdentifierError, CHUNK_SIZE, +}; + +#[derive(Debug)] +pub struct FileIdentifierJob { + location: Arc, + location_path: Arc, + sub_path: Option, + + metadata: Metadata, + + errors: Vec, + + pending_tasks_on_resume: Vec>, + tasks_for_shutdown: Vec>>, +} + +impl Hash for FileIdentifierJob { + fn hash(&self, state: &mut H) { + self.location.id.hash(state); + if let Some(ref sub_path) = self.sub_path { + sub_path.hash(state); + } + } +} + +impl Job for FileIdentifierJob { + const NAME: JobName = JobName::FileIdentifier; + + async fn resume_tasks( + &mut self, + dispatcher: &JobTaskDispatcher, + ctx: &impl JobContext, + SerializedTasks(serialized_tasks): SerializedTasks, + ) -> Result<(), Error> { + self.pending_tasks_on_resume = dispatcher + .dispatch_many_boxed( + rmp_serde::from_slice::)>>(&serialized_tasks) + .map_err(FileIdentifierError::from)? + .into_iter() + .map(|(task_kind, task_bytes)| async move { + match task_kind { + TaskKind::ExtractFileMetadata => { + >::deserialize( + &task_bytes, + (), + ) + .await + .map(IntoTask::into_task) + } + + TaskKind::ObjectProcessor => ObjectProcessorTask::deserialize( + &task_bytes, + (Arc::clone(ctx.db()), Arc::clone(ctx.sync())), + ) + .await + .map(IntoTask::into_task), + } + }) + .collect::>() + .try_join() + .await + .map_err(FileIdentifierError::from)?, + ) + .await; + + Ok(()) + } + + async fn run( + mut self, + dispatcher: JobTaskDispatcher, + ctx: impl JobContext, + ) -> Result { + let mut pending_running_tasks = FuturesUnordered::new(); + + self.init_or_resume(&mut pending_running_tasks, &ctx, &dispatcher) + .await?; + + while let Some(task) = pending_running_tasks.next().await { + match task { + Ok(TaskStatus::Done((task_id, TaskOutput::Out(out)))) => { + if let Some(new_object_processor_task) = self + .process_task_output(task_id, out, &ctx, &dispatcher) + .await + { + pending_running_tasks.push(new_object_processor_task); + }; + } + + Ok(TaskStatus::Done((task_id, TaskOutput::Empty))) => { + warn!("Task returned an empty output"); + } + + Ok(TaskStatus::Shutdown(task)) => { + self.tasks_for_shutdown.push(task); + } + + Ok(TaskStatus::Error(e)) => { + cancel_pending_tasks(&pending_running_tasks).await; + + return Err(e); + } + + Ok(TaskStatus::Canceled | TaskStatus::ForcedAbortion) => { + cancel_pending_tasks(&pending_running_tasks).await; + + return Ok(ReturnStatus::Canceled); + } + + Err(e) => { + cancel_pending_tasks(&pending_running_tasks).await; + + return Err(e.into()); + } + } + } + + if !self.tasks_for_shutdown.is_empty() { + return Ok(ReturnStatus::Shutdown(self.serialize().await)); + } + + // From this point onward, we are done with the job and it can't be interrupted anymore + let Self { + location, + metadata, + errors, + .. + } = self; + + ctx.db() + .location() + .update( + location::id::equals(location.id), + vec![location::scan_state::set( + LocationScanState::FilesIdentified as i32, + )], + ) + .exec() + .await + .map_err(FileIdentifierError::from)?; + + Ok(ReturnStatus::Completed( + JobReturn::builder() + .with_metadata(metadata) + .with_non_critical_errors(errors) + .build(), + )) + } +} + +impl FileIdentifierJob { + pub fn new( + location: location::Data, + sub_path: Option, + ) -> Result { + Ok(Self { + location_path: maybe_missing(&location.path, "location.path") + .map(PathBuf::from) + .map(Arc::new)?, + location: Arc::new(location), + sub_path, + metadata: Metadata::default(), + errors: Vec::new(), + pending_tasks_on_resume: Vec::new(), + tasks_for_shutdown: Vec::new(), + }) + } + + async fn init_or_resume( + &mut self, + pending_running_tasks: &mut FuturesUnordered>, + job_ctx: &impl JobContext, + dispatcher: &JobTaskDispatcher, + ) -> Result<(), FileIdentifierError> { + // if we don't have any pending task, then this is a fresh job + if self.pending_tasks_on_resume.is_empty() { + let db = job_ctx.db(); + let maybe_sub_iso_file_path = maybe_get_iso_file_path_from_sub_path( + self.location.id, + &self.sub_path, + &*self.location_path, + db, + ) + .await?; + + let mut orphans_count = 0; + let mut last_orphan_file_path_id = None; + + let start = Instant::now(); + + loop { + #[allow(clippy::cast_possible_wrap)] + // SAFETY: we know that CHUNK_SIZE is a valid i64 + let orphan_paths = db + .file_path() + .find_many(orphan_path_filters( + self.location.id, + last_orphan_file_path_id, + &maybe_sub_iso_file_path, + )) + .order_by(file_path::id::order(SortOrder::Asc)) + .take(CHUNK_SIZE as i64) + .select(file_path_for_file_identifier::select()) + .exec() + .await?; + + if orphan_paths.is_empty() { + break; + } + + orphans_count += orphan_paths.len() as u64; + last_orphan_file_path_id = + Some(orphan_paths.last().expect("orphan_paths is not empty").id); + + job_ctx.progress(vec![ + ProgressUpdate::TaskCount(orphans_count), + ProgressUpdate::Message(format!("{orphans_count} files to be identified")), + ]); + + pending_running_tasks.push( + dispatcher + .dispatch(ExtractFileMetadataTask::new_deep( + Arc::clone(&self.location), + Arc::clone(&self.location_path), + orphan_paths, + )) + .await, + ); + } + + self.metadata.seeking_orphans_time = start.elapsed(); + self.metadata.total_found_orphans = orphans_count; + } else { + pending_running_tasks.extend(mem::take(&mut self.pending_tasks_on_resume)); + } + + Ok(()) + } + + /// Process output of tasks, according to the downcasted output type + /// + /// # Panics + /// Will panic if another task type is added in the job, but this function wasn't updated to handle it + /// + async fn process_task_output( + &mut self, + task_id: TaskId, + any_task_output: Box, + job_ctx: &impl JobContext, + dispatcher: &JobTaskDispatcher, + ) -> Option> { + if any_task_output.is::() { + return self + .process_extract_file_metadata_output( + *any_task_output + .downcast::() + .expect("just checked"), + job_ctx, + dispatcher, + ) + .await; + } else if any_task_output.is::() { + self.process_object_processor_output( + *any_task_output + .downcast::() + .expect("just checked"), + job_ctx, + ); + } else { + unreachable!("Unexpected task output type: "); + } + + None + } + + async fn process_extract_file_metadata_output( + &mut self, + ExtractFileMetadataTaskOutput { + identified_files, + extract_metadata_time, + errors, + }: ExtractFileMetadataTaskOutput, + job_ctx: &impl JobContext, + dispatcher: &JobTaskDispatcher, + ) -> Option> { + self.metadata.extract_metadata_time += extract_metadata_time; + self.errors.extend(errors); + + if identified_files.is_empty() { + self.metadata.completed_tasks += 1; + + job_ctx.progress(vec![ProgressUpdate::CompletedTaskCount( + self.metadata.completed_tasks, + )]); + + None + } else { + job_ctx.progress_msg(format!("Identified {} files", identified_files.len())); + + Some( + dispatcher + .dispatch(ObjectProcessorTask::new_deep( + identified_files, + Arc::clone(job_ctx.db()), + Arc::clone(job_ctx.sync()), + )) + .await, + ) + } + } + + fn process_object_processor_output( + &mut self, + ObjectProcessorTaskMetrics { + assign_cas_ids_time, + fetch_existing_objects_time, + assign_to_existing_object_time, + create_object_time, + created_objects_count, + linked_objects_count, + }: ObjectProcessorTaskMetrics, + job_ctx: &impl JobContext, + ) { + self.metadata.assign_cas_ids_time += assign_cas_ids_time; + self.metadata.fetch_existing_objects_time += fetch_existing_objects_time; + self.metadata.assign_to_existing_object_time += assign_to_existing_object_time; + self.metadata.create_object_time += create_object_time; + self.metadata.created_objects_count += created_objects_count; + self.metadata.linked_objects_count += linked_objects_count; + + self.metadata.completed_tasks += 1; + + job_ctx.progress(vec![ + ProgressUpdate::CompletedTaskCount(self.metadata.completed_tasks), + ProgressUpdate::Message(format!( + "Processed {} of {} objects", + self.metadata.created_objects_count + self.metadata.linked_objects_count, + self.metadata.total_found_orphans + )), + ]); + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +enum TaskKind { + ExtractFileMetadata, + ObjectProcessor, +} + +#[derive(Serialize, Deserialize)] +struct SaveState { + location: Arc, + location_path: Arc, + sub_path: Option, + + metadata: Metadata, + + errors: Vec, + + tasks_for_shutdown_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Metadata { + extract_metadata_time: Duration, + assign_cas_ids_time: Duration, + fetch_existing_objects_time: Duration, + assign_to_existing_object_time: Duration, + create_object_time: Duration, + seeking_orphans_time: Duration, + total_found_orphans: u64, + created_objects_count: u64, + linked_objects_count: u64, + completed_tasks: u64, +} + +impl From for ReportOutputMetadata { + fn from(value: Metadata) -> Self { + Self::Metrics(HashMap::from([ + ( + "extract_metadata_time".into(), + json!(value.extract_metadata_time), + ), + ( + "assign_cas_ids_time".into(), + json!(value.assign_cas_ids_time), + ), + ( + "fetch_existing_objects_time".into(), + json!(value.fetch_existing_objects_time), + ), + ( + "assign_to_existing_object_time".into(), + json!(value.assign_to_existing_object_time), + ), + ("create_object_time".into(), json!(value.create_object_time)), + ( + "seeking_orphans_time".into(), + json!(value.seeking_orphans_time), + ), + ( + "total_found_orphans".into(), + json!(value.total_found_orphans), + ), + ( + "created_objects_count".into(), + json!(value.created_objects_count), + ), + ( + "linked_objects_count".into(), + json!(value.linked_objects_count), + ), + ("total_tasks".into(), json!(value.completed_tasks)), + ])) + } +} + +impl SerializableJob for FileIdentifierJob { + async fn serialize(self) -> Result>, rmp_serde::encode::Error> { + let Self { + location, + location_path, + sub_path, + metadata, + errors, + tasks_for_shutdown, + .. + } = self; + + rmp_serde::to_vec_named(&SaveState { + location, + location_path, + sub_path, + metadata, + tasks_for_shutdown_bytes: Some(SerializedTasks(rmp_serde::to_vec_named( + &tasks_for_shutdown + .into_iter() + .map(|task| async move { + if task.is::() { + SerializableTask::serialize( + *task + .downcast::() + .expect("just checked"), + ) + .await + .map(|bytes| (TaskKind::ExtractFileMetadata, bytes)) + } else if task.is::() { + task.downcast::() + .expect("just checked") + .serialize() + .await + .map(|bytes| (TaskKind::ObjectProcessor, bytes)) + } else { + unreachable!("Unexpected task type") + } + }) + .collect::>() + .try_join() + .await?, + )?)), + errors, + }) + .map(Some) + } + + async fn deserialize( + serialized_job: &[u8], + _: &impl JobContext, + ) -> Result)>, rmp_serde::decode::Error> { + let SaveState { + location, + location_path, + sub_path, + metadata, + + errors, + tasks_for_shutdown_bytes, + } = rmp_serde::from_slice::(serialized_job)?; + + Ok(Some(( + Self { + location, + location_path, + sub_path, + metadata, + errors, + pending_tasks_on_resume: Vec::new(), + tasks_for_shutdown: Vec::new(), + }, + tasks_for_shutdown_bytes, + ))) + } +} + +fn orphan_path_filters( + location_id: location::id::Type, + file_path_id: Option, + maybe_sub_iso_file_path: &Option>, +) -> Vec { + sd_utils::chain_optional_iter( + [ + or!( + file_path::object_id::equals(None), + file_path::cas_id::equals(None) + ), + file_path::is_dir::equals(Some(false)), + file_path::location_id::equals(Some(location_id)), + file_path::size_in_bytes_bytes::not(Some(0u64.to_be_bytes().to_vec())), + ], + [ + // this is a workaround for the cursor not working properly + file_path_id.map(file_path::id::gte), + maybe_sub_iso_file_path.as_ref().map(|sub_iso_file_path| { + file_path::materialized_path::starts_with( + sub_iso_file_path + .materialized_path_for_children() + .expect("sub path iso_file_path must be a directory"), + ) + }), + ], + ) +} diff --git a/core/crates/heavy-lifting/src/file_identifier/mod.rs b/core/crates/heavy-lifting/src/file_identifier/mod.rs new file mode 100644 index 000000000..6659ef375 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/mod.rs @@ -0,0 +1,120 @@ +use crate::utils::sub_path::SubPathError; + +use sd_core_file_path_helper::{FilePathError, IsolatedFilePathData}; + +use sd_file_ext::{extensions::Extension, kind::ObjectKind}; +use sd_utils::{db::MissingFieldError, error::FileIOError}; + +use std::{fs::Metadata, path::Path}; + +use prisma_client_rust::QueryError; +use rspc::ErrorCode; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tokio::fs; +use tracing::trace; + +mod cas_id; +mod job; +mod shallow; +mod tasks; + +use cas_id::generate_cas_id; + +pub use job::FileIdentifierJob; +pub use shallow::shallow; + +// we break these tasks into chunks of 100 to improve performance +const CHUNK_SIZE: usize = 100; + +#[derive(thiserror::Error, Debug)] +pub enum FileIdentifierError { + #[error("missing field on database: {0}")] + MissingField(#[from] MissingFieldError), + #[error("failed to deserialized stored tasks for job resume: {0}")] + DeserializeTasks(#[from] rmp_serde::decode::Error), + #[error("database error: {0}")] + Database(#[from] QueryError), + + #[error(transparent)] + FilePathError(#[from] FilePathError), + #[error(transparent)] + SubPath(#[from] SubPathError), +} + +impl From for rspc::Error { + fn from(err: FileIdentifierError) -> Self { + match err { + FileIdentifierError::SubPath(sub_path_err) => sub_path_err.into(), + + _ => Self::with_cause(ErrorCode::InternalServerError, err.to_string(), err), + } + } +} + +#[derive(thiserror::Error, Debug, Serialize, Deserialize, Type)] +pub enum NonCriticalFileIdentifierError { + #[error("failed to extract file metadata: {0}")] + FailedToExtractFileMetadata(String), + #[cfg(target_os = "windows")] + #[error("failed to extract metadata from on-demand file: {0}")] + FailedToExtractMetadataFromOnDemandFile(String), + #[error("failed to extract isolated file path data: {0}")] + FailedToExtractIsolatedFilePathData(String), +} + +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub cas_id: Option, + pub kind: ObjectKind, + pub fs_metadata: Metadata, +} + +impl FileMetadata { + /// Fetch metadata from the file system and generate a cas id for the file + /// if it's not empty. + /// + /// # Panics + /// Will panic if the file is a directory. + pub async fn new( + location_path: impl AsRef + Send, + iso_file_path: &IsolatedFilePathData<'_>, + ) -> Result { + let path = location_path.as_ref().join(iso_file_path); + + let fs_metadata = fs::metadata(&path) + .await + .map_err(|e| FileIOError::from((&path, e)))?; + + assert!( + !fs_metadata.is_dir(), + "We can't generate cas_id for directories" + ); + + // derive Object kind + let kind = Extension::resolve_conflicting(&path, false) + .await + .map_or(ObjectKind::Unknown, Into::into); + + let cas_id = if fs_metadata.len() != 0 { + generate_cas_id(&path, fs_metadata.len()) + .await + .map(Some) + .map_err(|e| FileIOError::from((&path, e)))? + } else { + // We can't do shit with empty files + None + }; + + trace!( + "Analyzed file: ", + path.display() + ); + + Ok(Self { + cas_id, + kind, + fs_metadata, + }) + } +} diff --git a/core/crates/heavy-lifting/src/file_identifier/shallow.rs b/core/crates/heavy-lifting/src/file_identifier/shallow.rs new file mode 100644 index 000000000..ef85a07b8 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/shallow.rs @@ -0,0 +1,207 @@ +use crate::{utils::sub_path::maybe_get_iso_file_path_from_sub_path, Error, NonCriticalJobError}; + +use sd_core_file_path_helper::IsolatedFilePathData; +use sd_core_prisma_helpers::file_path_for_file_identifier; +use sd_core_sync::Manager as SyncManager; + +use sd_prisma::prisma::{file_path, location, PrismaClient, SortOrder}; +use sd_task_system::{ + BaseTaskDispatcher, CancelTaskOnDrop, TaskDispatcher, TaskOutput, TaskStatus, +}; +use sd_utils::db::maybe_missing; + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use futures_concurrency::future::FutureGroup; +use lending_stream::{LendingStream, StreamExt}; +use prisma_client_rust::or; +use tracing::{debug, warn}; + +use super::{ + tasks::{ExtractFileMetadataTask, ExtractFileMetadataTaskOutput, ObjectProcessorTask}, + FileIdentifierError, CHUNK_SIZE, +}; + +pub async fn shallow( + location: location::Data, + sub_path: impl AsRef + Send, + dispatcher: BaseTaskDispatcher, + db: Arc, + sync: Arc, + invalidate_query: impl Fn(&'static str) + Send + Sync, +) -> Result, Error> { + let sub_path = sub_path.as_ref(); + + let location_path = maybe_missing(&location.path, "location.path") + .map(PathBuf::from) + .map(Arc::new) + .map_err(FileIdentifierError::from)?; + + let location = Arc::new(location); + + let sub_iso_file_path = + maybe_get_iso_file_path_from_sub_path(location.id, &Some(sub_path), &*location_path, &db) + .await + .map_err(FileIdentifierError::from)? + .map_or_else( + || { + IsolatedFilePathData::new(location.id, &*location_path, &*location_path, true) + .map_err(FileIdentifierError::from) + }, + Ok, + )?; + + let mut orphans_count = 0; + let mut last_orphan_file_path_id = None; + + let mut pending_running_tasks = FutureGroup::new(); + + loop { + #[allow(clippy::cast_possible_wrap)] + // SAFETY: we know that CHUNK_SIZE is a valid i64 + let orphan_paths = db + .file_path() + .find_many(orphan_path_filters( + location.id, + last_orphan_file_path_id, + &sub_iso_file_path, + )) + .order_by(file_path::id::order(SortOrder::Asc)) + .take(CHUNK_SIZE as i64) + .select(file_path_for_file_identifier::select()) + .exec() + .await + .map_err(FileIdentifierError::from)?; + + let Some(last_orphan) = orphan_paths.last() else { + // No orphans here! + break; + }; + + orphans_count += orphan_paths.len() as u64; + last_orphan_file_path_id = Some(last_orphan.id); + + pending_running_tasks.insert(CancelTaskOnDrop( + dispatcher + .dispatch(ExtractFileMetadataTask::new_shallow( + Arc::clone(&location), + Arc::clone(&location_path), + orphan_paths, + )) + .await, + )); + } + + if orphans_count == 0 { + debug!( + "No orphans found on ", + location.id, + sub_path.display() + ); + return Ok(vec![]); + } + + let errors = process_tasks(pending_running_tasks, dispatcher, db, sync).await?; + + invalidate_query("search.paths"); + invalidate_query("search.objects"); + + Ok(errors) +} + +async fn process_tasks( + pending_running_tasks: FutureGroup>, + dispatcher: BaseTaskDispatcher, + db: Arc, + sync: Arc, +) -> Result, Error> { + let mut pending_running_tasks = pending_running_tasks.lend_mut(); + + let mut errors = vec![]; + + while let Some((pending_running_tasks, task_result)) = pending_running_tasks.next().await { + match task_result { + Ok(TaskStatus::Done((_, TaskOutput::Out(any_task_output)))) => { + // We only care about ExtractFileMetadataTaskOutput because we need to dispatch further tasks + // and the ObjectProcessorTask only gives back some metrics not much important for + // shallow file identifier + if any_task_output.is::() { + let ExtractFileMetadataTaskOutput { + identified_files, + errors: more_errors, + .. + } = *any_task_output + .downcast::() + .expect("just checked"); + + errors.extend(more_errors); + + if !identified_files.is_empty() { + pending_running_tasks.insert(CancelTaskOnDrop( + dispatcher + .dispatch(ObjectProcessorTask::new_shallow( + identified_files, + Arc::clone(&db), + Arc::clone(&sync), + )) + .await, + )); + } + } + } + + Ok(TaskStatus::Done((task_id, TaskOutput::Empty))) => { + warn!("Task returned an empty output"); + } + + Ok(TaskStatus::Shutdown(_)) => { + debug!( + "Spacedrive is shutting down while a shallow file identifier was in progress" + ); + return Ok(vec![]); + } + + Ok(TaskStatus::Error(e)) => { + return Err(e); + } + + Ok(TaskStatus::Canceled | TaskStatus::ForcedAbortion) => { + warn!("Task was cancelled or aborted on shallow file identifier"); + return Ok(vec![]); + } + + Err(e) => { + return Err(e.into()); + } + } + } + + Ok(errors) +} + +fn orphan_path_filters( + location_id: location::id::Type, + file_path_id: Option, + sub_iso_file_path: &IsolatedFilePathData<'_>, +) -> Vec { + sd_utils::chain_optional_iter( + [ + or!( + file_path::object_id::equals(None), + file_path::cas_id::equals(None) + ), + file_path::is_dir::equals(Some(false)), + file_path::location_id::equals(Some(location_id)), + file_path::materialized_path::equals(Some( + sub_iso_file_path + .materialized_path_for_children() + .expect("sub path for shallow identifier must be a directory"), + )), + file_path::size_in_bytes_bytes::not(Some(0u64.to_be_bytes().to_vec())), + ], + [file_path_id.map(file_path::id::gte)], + ) +} diff --git a/core/crates/heavy-lifting/src/file_identifier/tasks/extract_file_metadata.rs b/core/crates/heavy-lifting/src/file_identifier/tasks/extract_file_metadata.rs new file mode 100644 index 000000000..ef9b2af9b --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/tasks/extract_file_metadata.rs @@ -0,0 +1,280 @@ +use crate::{ + file_identifier::{FileMetadata, NonCriticalFileIdentifierError}, + Error, NonCriticalJobError, +}; + +use sd_core_file_path_helper::IsolatedFilePathData; +use sd_core_prisma_helpers::file_path_for_file_identifier; + +use sd_prisma::prisma::location; +use sd_task_system::{ + ExecStatus, Interrupter, InterruptionKind, IntoAnyTaskOutput, SerializableTask, Task, TaskId, +}; +use sd_utils::error::FileIOError; + +use std::{ + collections::HashMap, future::IntoFuture, mem, path::PathBuf, pin::pin, sync::Arc, + time::Duration, +}; + +use futures::stream::{self, FuturesUnordered, StreamExt}; +use futures_concurrency::stream::Merge; +use serde::{Deserialize, Serialize}; +use tokio::time::Instant; +use tracing::error; +use uuid::Uuid; + +use super::IdentifiedFile; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ExtractFileMetadataTask { + id: TaskId, + location: Arc, + location_path: Arc, + file_paths_by_id: HashMap, + identified_files: HashMap, + extract_metadata_time: Duration, + errors: Vec, + is_shallow: bool, +} + +#[derive(Debug)] +pub struct ExtractFileMetadataTaskOutput { + pub identified_files: HashMap, + pub extract_metadata_time: Duration, + pub errors: Vec, +} + +impl ExtractFileMetadataTask { + fn new( + location: Arc, + location_path: Arc, + file_paths: Vec, + is_shallow: bool, + ) -> Self { + Self { + id: TaskId::new_v4(), + location, + location_path, + identified_files: HashMap::with_capacity(file_paths.len()), + file_paths_by_id: file_paths + .into_iter() + .map(|file_path| { + // SAFETY: This should never happen + ( + Uuid::from_slice(&file_path.pub_id).expect("file_path.pub_id is invalid!"), + file_path, + ) + }) + .collect(), + extract_metadata_time: Duration::ZERO, + errors: Vec::new(), + is_shallow, + } + } + + #[must_use] + pub fn new_deep( + location: Arc, + location_path: Arc, + file_paths: Vec, + ) -> Self { + Self::new(location, location_path, file_paths, false) + } + + #[must_use] + pub fn new_shallow( + location: Arc, + location_path: Arc, + file_paths: Vec, + ) -> Self { + Self::new(location, location_path, file_paths, true) + } +} + +#[async_trait::async_trait] +impl Task for ExtractFileMetadataTask { + fn id(&self) -> TaskId { + self.id + } + + fn with_priority(&self) -> bool { + self.is_shallow + } + + async fn run(&mut self, interrupter: &Interrupter) -> Result { + enum StreamMessage { + Processed(Uuid, Result), + Interrupt(InterruptionKind), + } + + let Self { + location, + location_path, + file_paths_by_id, + identified_files, + extract_metadata_time, + errors, + .. + } = self; + + let start_time = Instant::now(); + + if !file_paths_by_id.is_empty() { + let extraction_futures = file_paths_by_id + .iter() + .filter_map(|(file_path_id, file_path)| { + try_iso_file_path_extraction( + location.id, + *file_path_id, + file_path, + Arc::clone(location_path), + errors, + ) + }) + .map(|(file_path_id, iso_file_path, location_path)| async move { + StreamMessage::Processed( + file_path_id, + FileMetadata::new(&*location_path, &iso_file_path).await, + ) + }) + .collect::>(); + + let mut msg_stream = pin!(( + extraction_futures, + stream::once(interrupter.into_future()).map(StreamMessage::Interrupt) + ) + .merge()); + + while let Some(msg) = msg_stream.next().await { + match msg { + StreamMessage::Processed(file_path_pub_id, res) => { + let file_path = file_paths_by_id + .remove(&file_path_pub_id) + .expect("file_path must be here"); + + match res { + Ok(FileMetadata { cas_id, kind, .. }) => { + identified_files.insert( + file_path_pub_id, + IdentifiedFile { + file_path, + cas_id, + kind, + }, + ); + } + Err(e) => { + handle_non_critical_errors( + location.id, + file_path_pub_id, + &e, + errors, + ); + } + } + + if file_paths_by_id.is_empty() { + // All files have been processed so we can end this merged stream and don't keep waiting an + // interrupt signal + break; + } + } + + StreamMessage::Interrupt(kind) => { + *extract_metadata_time += start_time.elapsed(); + return Ok(match kind { + InterruptionKind::Pause => ExecStatus::Paused, + InterruptionKind::Cancel => ExecStatus::Canceled, + }); + } + } + } + } + + Ok(ExecStatus::Done( + ExtractFileMetadataTaskOutput { + identified_files: mem::take(identified_files), + extract_metadata_time: *extract_metadata_time + start_time.elapsed(), + errors: mem::take(errors), + } + .into_output(), + )) + } +} + +fn handle_non_critical_errors( + location_id: location::id::Type, + file_path_pub_id: Uuid, + e: &FileIOError, + errors: &mut Vec, +) { + error!("Failed to extract file metadata : {e:#?}"); + + let formatted_error = format!(""); + + #[cfg(target_os = "windows")] + { + // Handle case where file is on-demand (NTFS only) + if e.source.raw_os_error().map_or(false, |code| code == 362) { + errors.push( + NonCriticalFileIdentifierError::FailedToExtractMetadataFromOnDemandFile( + formatted_error, + ) + .into(), + ); + } else { + errors.push( + NonCriticalFileIdentifierError::FailedToExtractFileMetadata(formatted_error).into(), + ); + } + } + + #[cfg(not(target_os = "windows"))] + { + errors.push( + NonCriticalFileIdentifierError::FailedToExtractFileMetadata(formatted_error).into(), + ); + } +} + +fn try_iso_file_path_extraction( + location_id: location::id::Type, + file_path_pub_id: Uuid, + file_path: &file_path_for_file_identifier::Data, + location_path: Arc, + errors: &mut Vec, +) -> Option<(Uuid, IsolatedFilePathData<'static>, Arc)> { + IsolatedFilePathData::try_from((location_id, file_path)) + .map(IsolatedFilePathData::to_owned) + .map(|iso_file_path| (file_path_pub_id, iso_file_path, location_path)) + .map_err(|e| { + error!("Failed to extract isolated file path data: {e:#?}"); + errors.push( + NonCriticalFileIdentifierError::FailedToExtractIsolatedFilePathData(format!( + "" + )) + .into(), + ); + }) + .ok() +} + +impl SerializableTask for ExtractFileMetadataTask { + type SerializeError = rmp_serde::encode::Error; + + type DeserializeError = rmp_serde::decode::Error; + + type DeserializeCtx = (); + + async fn serialize(self) -> Result, Self::SerializeError> { + rmp_serde::to_vec_named(&self) + } + + async fn deserialize( + data: &[u8], + (): Self::DeserializeCtx, + ) -> Result { + rmp_serde::from_slice(data) + } +} diff --git a/core/crates/heavy-lifting/src/file_identifier/tasks/mod.rs b/core/crates/heavy-lifting/src/file_identifier/tasks/mod.rs new file mode 100644 index 000000000..c5bac9fb1 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/tasks/mod.rs @@ -0,0 +1,18 @@ +use sd_core_prisma_helpers::file_path_for_file_identifier; + +use sd_file_ext::kind::ObjectKind; + +use serde::{Deserialize, Serialize}; + +mod extract_file_metadata; +mod object_processor; + +pub use extract_file_metadata::{ExtractFileMetadataTask, ExtractFileMetadataTaskOutput}; +pub use object_processor::{ObjectProcessorTask, ObjectProcessorTaskMetrics}; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct IdentifiedFile { + pub(super) file_path: file_path_for_file_identifier::Data, + pub(super) cas_id: Option, + pub(super) kind: ObjectKind, +} diff --git a/core/crates/heavy-lifting/src/file_identifier/tasks/object_processor.rs b/core/crates/heavy-lifting/src/file_identifier/tasks/object_processor.rs new file mode 100644 index 000000000..cdb9f0842 --- /dev/null +++ b/core/crates/heavy-lifting/src/file_identifier/tasks/object_processor.rs @@ -0,0 +1,473 @@ +use crate::{file_identifier::FileIdentifierError, Error}; + +use sd_core_prisma_helpers::{ + file_path_for_file_identifier, file_path_pub_id, object_for_file_identifier, +}; +use sd_core_sync::Manager as SyncManager; + +use sd_prisma::{ + prisma::{file_path, object, PrismaClient}, + prisma_sync, +}; +use sd_sync::{CRDTOperation, OperationFactory}; +use sd_task_system::{ + check_interruption, ExecStatus, Interrupter, IntoAnyTaskOutput, SerializableTask, Task, TaskId, +}; +use sd_utils::{msgpack, uuid_to_bytes}; + +use std::{ + collections::{HashMap, HashSet}, + mem, + sync::Arc, + time::Duration, +}; + +use prisma_client_rust::Select; +use serde::{Deserialize, Serialize}; +use tokio::time::Instant; +use tracing::{debug, trace}; +use uuid::Uuid; + +use super::IdentifiedFile; + +#[derive(Debug)] +pub struct ObjectProcessorTask { + id: TaskId, + db: Arc, + sync: Arc, + identified_files: HashMap, + metrics: ObjectProcessorTaskMetrics, + stage: Stage, + is_shallow: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SaveState { + id: TaskId, + identified_files: HashMap, + metrics: ObjectProcessorTaskMetrics, + stage: Stage, + is_shallow: bool, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ObjectProcessorTaskMetrics { + pub assign_cas_ids_time: Duration, + pub fetch_existing_objects_time: Duration, + pub assign_to_existing_object_time: Duration, + pub create_object_time: Duration, + pub created_objects_count: u64, + pub linked_objects_count: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +enum Stage { + Starting, + FetchExistingObjects, + AssignFilePathsToExistingObjects { + existing_objects_by_cas_id: HashMap, + }, + CreateObjects, +} + +impl ObjectProcessorTask { + fn new( + identified_files: HashMap, + db: Arc, + sync: Arc, + is_shallow: bool, + ) -> Self { + Self { + id: TaskId::new_v4(), + db, + sync, + identified_files, + stage: Stage::Starting, + metrics: ObjectProcessorTaskMetrics::default(), + is_shallow, + } + } + + pub fn new_deep( + identified_files: HashMap, + db: Arc, + sync: Arc, + ) -> Self { + Self::new(identified_files, db, sync, false) + } + + pub fn new_shallow( + identified_files: HashMap, + db: Arc, + sync: Arc, + ) -> Self { + Self::new(identified_files, db, sync, true) + } +} + +#[async_trait::async_trait] +impl Task for ObjectProcessorTask { + fn id(&self) -> TaskId { + self.id + } + + fn with_priority(&self) -> bool { + self.is_shallow + } + + async fn run(&mut self, interrupter: &Interrupter) -> Result { + let Self { + db, + sync, + identified_files, + stage, + metrics: + ObjectProcessorTaskMetrics { + assign_cas_ids_time, + fetch_existing_objects_time, + assign_to_existing_object_time, + create_object_time, + created_objects_count, + linked_objects_count, + }, + .. + } = self; + + loop { + match stage { + Stage::Starting => { + let start = Instant::now(); + assign_cas_id_to_file_paths(identified_files, db, sync).await?; + *assign_cas_ids_time = start.elapsed(); + *stage = Stage::FetchExistingObjects; + } + + Stage::FetchExistingObjects => { + let start = Instant::now(); + let existing_objects_by_cas_id = + fetch_existing_objects_by_cas_id(identified_files, db).await?; + *fetch_existing_objects_time = start.elapsed(); + *stage = Stage::AssignFilePathsToExistingObjects { + existing_objects_by_cas_id, + }; + } + + Stage::AssignFilePathsToExistingObjects { + existing_objects_by_cas_id, + } => { + let start = Instant::now(); + let assigned_file_path_pub_ids = assign_existing_objects_to_file_paths( + identified_files, + existing_objects_by_cas_id, + db, + sync, + ) + .await?; + *assign_to_existing_object_time = start.elapsed(); + *linked_objects_count = assigned_file_path_pub_ids.len() as u64; + + debug!( + "Found {} existing Objects, linked file paths to them", + existing_objects_by_cas_id.len() + ); + + for file_path_pub_id::Data { pub_id } in assigned_file_path_pub_ids { + let pub_id = Uuid::from_slice(&pub_id).expect("uuid bytes are invalid"); + trace!("Assigned file path to existing object"); + + identified_files + .remove(&pub_id) + .expect("file_path must be here"); + } + + *stage = Stage::CreateObjects; + + if identified_files.is_empty() { + // No objects to be created, we're good to finish already + break; + } + } + + Stage::CreateObjects => { + let start = Instant::now(); + *created_objects_count = create_objects(identified_files, db, sync).await?; + *create_object_time = start.elapsed(); + + break; + } + } + + check_interruption!(interrupter); + } + + Ok(ExecStatus::Done(mem::take(&mut self.metrics).into_output())) + } +} + +async fn assign_cas_id_to_file_paths( + identified_files: &HashMap, + db: &PrismaClient, + sync: &SyncManager, +) -> Result<(), FileIdentifierError> { + // Assign cas_id to each file path + sync.write_ops( + db, + identified_files + .iter() + .map(|(pub_id, IdentifiedFile { cas_id, .. })| { + ( + sync.shared_update( + prisma_sync::file_path::SyncId { + pub_id: uuid_to_bytes(*pub_id), + }, + file_path::cas_id::NAME, + msgpack!(cas_id), + ), + db.file_path() + .update( + file_path::pub_id::equals(uuid_to_bytes(*pub_id)), + vec![file_path::cas_id::set(cas_id.clone())], + ) + // We don't need any data here, just the id avoids receiving the entire object + // as we can't pass an empty select macro call + .select(file_path::select!({ id })), + ) + }) + .unzip::<_, _, _, Vec<_>>(), + ) + .await?; + + Ok(()) +} + +async fn fetch_existing_objects_by_cas_id( + identified_files: &HashMap, + db: &PrismaClient, +) -> Result, FileIdentifierError> { + // Retrieves objects that are already connected to file paths with the same id + db.object() + .find_many(vec![object::file_paths::some(vec![ + file_path::cas_id::in_vec( + identified_files + .values() + .filter_map(|IdentifiedFile { cas_id, .. }| cas_id.as_ref()) + .cloned() + .collect::>() + .into_iter() + .collect(), + ), + ])]) + .select(object_for_file_identifier::select()) + .exec() + .await + .map_err(Into::into) + .map(|objects| { + objects + .into_iter() + .filter_map(|object| { + object + .file_paths + .first() + .and_then(|file_path| file_path.cas_id.clone()) + .map(|cas_id| (cas_id, object)) + }) + .collect() + }) +} + +async fn assign_existing_objects_to_file_paths( + identified_files: &HashMap, + objects_by_cas_id: &HashMap, + db: &PrismaClient, + sync: &SyncManager, +) -> Result, FileIdentifierError> { + // Attempt to associate each file path with an object that has been + // connected to file paths with the same cas_id + sync.write_ops( + db, + identified_files + .iter() + .filter_map(|(pub_id, IdentifiedFile { cas_id, .. })| { + objects_by_cas_id + // Filtering out files without cas_id due to being empty + .get(cas_id.as_ref()?) + .map(|object| (*pub_id, object)) + }) + .map(|(pub_id, object)| { + connect_file_path_to_object( + pub_id, + // SAFETY: This pub_id is generated by the uuid lib, but we have to store bytes in sqlite + Uuid::from_slice(&object.pub_id).expect("uuid bytes are invalid"), + sync, + db, + ) + }) + .unzip::<_, _, Vec<_>, Vec<_>>(), + ) + .await + .map_err(Into::into) +} + +fn connect_file_path_to_object<'db>( + file_path_pub_id: Uuid, + object_pub_id: Uuid, + sync: &SyncManager, + db: &'db PrismaClient, +) -> (CRDTOperation, Select<'db, file_path_pub_id::Data>) { + trace!("Connecting to "); + + let vec_id = object_pub_id.as_bytes().to_vec(); + + ( + sync.shared_update( + prisma_sync::file_path::SyncId { + pub_id: uuid_to_bytes(file_path_pub_id), + }, + file_path::object::NAME, + msgpack!(prisma_sync::object::SyncId { + pub_id: vec_id.clone() + }), + ), + db.file_path() + .update( + file_path::pub_id::equals(uuid_to_bytes(file_path_pub_id)), + vec![file_path::object::connect(object::pub_id::equals(vec_id))], + ) + .select(file_path_pub_id::select()), + ) +} + +async fn create_objects( + identified_files: &HashMap, + db: &PrismaClient, + sync: &SyncManager, +) -> Result { + trace!("Creating {} new Objects", identified_files.len(),); + + let (object_create_args, file_path_update_args) = identified_files + .iter() + .map( + |( + file_path_pub_id, + IdentifiedFile { + file_path: file_path_for_file_identifier::Data { date_created, .. }, + kind, + .. + }, + )| { + let object_pub_id = Uuid::new_v4(); + + let kind = *kind as i32; + + let (sync_params, db_params) = [ + ( + (object::date_created::NAME, msgpack!(date_created)), + object::date_created::set(*date_created), + ), + ( + (object::kind::NAME, msgpack!(kind)), + object::kind::set(Some(kind)), + ), + ] + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + ( + ( + sync.shared_create( + prisma_sync::object::SyncId { + pub_id: uuid_to_bytes(object_pub_id), + }, + sync_params, + ), + object::create_unchecked(uuid_to_bytes(object_pub_id), db_params), + ), + connect_file_path_to_object(*file_path_pub_id, object_pub_id, sync, db), + ) + }, + ) + .unzip::<_, _, Vec<_>, Vec<_>>(); + + // create new object records with assembled values + let total_created_files = sync + .write_ops(db, { + let (sync, db_params) = object_create_args + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + ( + sync.into_iter().flatten().collect(), + db.object().create_many(db_params), + ) + }) + .await?; + + trace!("Created {total_created_files} new Objects"); + + if total_created_files > 0 { + trace!("Updating file paths with created objects"); + + sync.write_ops( + db, + file_path_update_args + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(), + ) + .await?; + + trace!("Updated file paths with created objects"); + } + + #[allow(clippy::cast_sign_loss)] // SAFETY: We're sure the value is positive + Ok(total_created_files as u64) +} + +impl SerializableTask for ObjectProcessorTask { + type SerializeError = rmp_serde::encode::Error; + + type DeserializeError = rmp_serde::decode::Error; + + type DeserializeCtx = (Arc, Arc); + + async fn serialize(self) -> Result, Self::SerializeError> { + let Self { + id, + identified_files, + metrics, + stage, + is_shallow, + .. + } = self; + + rmp_serde::to_vec_named(&SaveState { + id, + identified_files, + metrics, + stage, + is_shallow, + }) + } + + async fn deserialize( + data: &[u8], + (db, sync): Self::DeserializeCtx, + ) -> Result { + rmp_serde::from_slice(data).map( + |SaveState { + id, + identified_files, + metrics, + stage, + is_shallow, + }| Self { + id, + db, + sync, + identified_files, + metrics, + stage, + is_shallow, + }, + ) + } +} diff --git a/core/crates/heavy-lifting/src/indexer/job.rs b/core/crates/heavy-lifting/src/indexer/job.rs index d85f2fd32..dacea9ab5 100644 --- a/core/crates/heavy-lifting/src/indexer/job.rs +++ b/core/crates/heavy-lifting/src/indexer/job.rs @@ -8,13 +8,15 @@ use crate::{ utils::cancel_pending_tasks, SerializableJob, SerializedTasks, }, - Error, NonCriticalJobError, + utils::sub_path::get_full_path_from_sub_path, + Error, LocationScanState, NonCriticalJobError, }; use sd_core_file_path_helper::IsolatedFilePathData; use sd_core_indexer_rules::{IndexerRule, IndexerRuler}; use sd_core_prisma_helpers::location_with_indexer_rules; +use sd_prisma::prisma::location; use sd_task_system::{ AnyTaskOutput, IntoTask, SerializableTask, Task, TaskDispatcher, TaskHandle, TaskId, TaskOutput, TaskStatus, @@ -39,7 +41,7 @@ use tokio::time::Instant; use tracing::warn; use super::{ - determine_initial_walk_path, remove_non_existing_file_paths, reverse_update_directories_sizes, + remove_non_existing_file_paths, reverse_update_directories_sizes, tasks::{ saver::{SaveTask, SaveTaskOutput}, updater::{UpdateTask, UpdateTaskOutput}, @@ -70,6 +72,64 @@ pub struct IndexerJob { impl Job for IndexerJob { const NAME: JobName = JobName::Indexer; + async fn resume_tasks( + &mut self, + dispatcher: &JobTaskDispatcher, + ctx: &impl JobContext, + SerializedTasks(serialized_tasks): SerializedTasks, + ) -> Result<(), Error> { + let location_id = self.location.id; + + self.pending_tasks_on_resume = dispatcher + .dispatch_many_boxed( + rmp_serde::from_slice::)>>(&serialized_tasks) + .map_err(IndexerError::from)? + .into_iter() + .map(|(task_kind, task_bytes)| { + let indexer_ruler = self.indexer_ruler.clone(); + let iso_file_path_factory = self.iso_file_path_factory.clone(); + async move { + match task_kind { + TaskKind::Walk => WalkDirTask::deserialize( + &task_bytes, + ( + indexer_ruler.clone(), + WalkerDBProxy { + location_id, + db: Arc::clone(ctx.db()), + }, + iso_file_path_factory.clone(), + dispatcher.clone(), + ), + ) + .await + .map(IntoTask::into_task), + + TaskKind::Save => SaveTask::deserialize( + &task_bytes, + (Arc::clone(ctx.db()), Arc::clone(ctx.sync())), + ) + .await + .map(IntoTask::into_task), + TaskKind::Update => UpdateTask::deserialize( + &task_bytes, + (Arc::clone(ctx.db()), Arc::clone(ctx.sync())), + ) + .await + .map(IntoTask::into_task), + } + } + }) + .collect::>() + .try_join() + .await + .map_err(IndexerError::from)?, + ) + .await; + + Ok(()) + } + async fn run( mut self, dispatcher: JobTaskDispatcher, @@ -102,7 +162,7 @@ impl Job for IndexerJob { self.metadata.total_paths += chunked_saves.len() as u64; self.metadata.total_save_steps += 1; - SaveTask::new( + SaveTask::new_deep( self.location.id, self.location.pub_id.clone(), chunked_saves, @@ -162,6 +222,10 @@ impl Job for IndexerJob { metadata.db_write_time += start_size_update_time.elapsed(); } + if metadata.removed_count > 0 { + // TODO: Dispatch a task to remove orphan objects + } + if metadata.indexed_count > 0 || metadata.removed_count > 0 { ctx.invalidate_query("search.paths"); } @@ -171,6 +235,16 @@ impl Job for IndexerJob { "all tasks must be completed here" ); + ctx.db() + .location() + .update( + location::id::equals(location.id), + vec![location::scan_state::set(LocationScanState::Indexed as i32)], + ) + .exec() + .await + .map_err(IndexerError::from)?; + Ok(ReturnStatus::Completed( JobReturn::builder() .with_metadata(metadata) @@ -178,64 +252,6 @@ impl Job for IndexerJob { .build(), )) } - - async fn resume_tasks( - &mut self, - dispatcher: &JobTaskDispatcher, - ctx: &impl JobContext, - SerializedTasks(serialized_tasks): SerializedTasks, - ) -> Result<(), Error> { - let location_id = self.location.id; - - self.pending_tasks_on_resume = dispatcher - .dispatch_many_boxed( - rmp_serde::from_slice::)>>(&serialized_tasks) - .map_err(IndexerError::from)? - .into_iter() - .map(|(task_kind, task_bytes)| { - let indexer_ruler = self.indexer_ruler.clone(); - let iso_file_path_factory = self.iso_file_path_factory.clone(); - async move { - match task_kind { - TaskKind::Walk => WalkDirTask::deserialize( - &task_bytes, - ( - indexer_ruler.clone(), - WalkerDBProxy { - location_id, - db: Arc::clone(ctx.db()), - }, - iso_file_path_factory.clone(), - dispatcher.clone(), - ), - ) - .await - .map(IntoTask::into_task), - - TaskKind::Save => SaveTask::deserialize( - &task_bytes, - (Arc::clone(ctx.db()), Arc::clone(ctx.sync())), - ) - .await - .map(IntoTask::into_task), - TaskKind::Update => UpdateTask::deserialize( - &task_bytes, - (Arc::clone(ctx.db()), Arc::clone(ctx.sync())), - ) - .await - .map(IntoTask::into_task), - } - } - }) - .collect::>() - .try_join() - .await - .map_err(IndexerError::from)?, - ) - .await; - - Ok(()) - } } impl IndexerJob { @@ -282,6 +298,12 @@ impl IndexerJob { job_ctx: &impl JobContext, dispatcher: &JobTaskDispatcher, ) -> Result>, IndexerError> { + self.metadata.completed_tasks += 1; + + job_ctx.progress(vec![ProgressUpdate::CompletedTaskCount( + self.metadata.completed_tasks, + )]); + if any_task_output.is::() { return self .process_walk_output( @@ -310,12 +332,6 @@ impl IndexerJob { unreachable!("Unexpected task output type: "); } - self.metadata.completed_tasks += 1; - - job_ctx.progress(vec![ProgressUpdate::CompletedTaskCount( - self.metadata.completed_tasks, - )]); - Ok(Vec::new()) } @@ -394,7 +410,7 @@ impl IndexerJob { self.metadata.total_paths += chunked_saves.len() as u64; self.metadata.total_save_steps += 1; - SaveTask::new( + SaveTask::new_deep( self.location.id, self.location.pub_id.clone(), chunked_saves, @@ -413,7 +429,7 @@ impl IndexerJob { self.metadata.total_updated_paths += chunked_updates.len() as u64; self.metadata.total_update_steps += 1; - UpdateTask::new( + UpdateTask::new_deep( chunked_updates, Arc::clone(job_ctx.db()), Arc::clone(job_ctx.sync()), @@ -528,7 +544,7 @@ impl IndexerJob { // if we don't have any pending task, then this is a fresh job if self.pending_tasks_on_resume.is_empty() { let walker_root_path = Arc::new( - determine_initial_walk_path( + get_full_path_from_sub_path( self.location.id, &self.sub_path, &*self.iso_file_path_factory.location_path, @@ -539,7 +555,7 @@ impl IndexerJob { pending_running_tasks.push( dispatcher - .dispatch(WalkDirTask::new( + .dispatch(WalkDirTask::new_deep( walker_root_path.as_ref(), Arc::clone(&walker_root_path), self.indexer_ruler.clone(), @@ -548,7 +564,7 @@ impl IndexerJob { location_id: self.location.id, db: Arc::clone(job_ctx.db()), }, - Some(dispatcher.clone()), + dispatcher.clone(), )?) .await, ); diff --git a/core/crates/heavy-lifting/src/indexer/mod.rs b/core/crates/heavy-lifting/src/indexer/mod.rs index 12d27b337..2bac41b1b 100644 --- a/core/crates/heavy-lifting/src/indexer/mod.rs +++ b/core/crates/heavy-lifting/src/indexer/mod.rs @@ -1,9 +1,6 @@ -use crate::NonCriticalJobError; +use crate::{utils::sub_path::SubPathError, NonCriticalJobError}; -use sd_core_file_path_helper::{ - ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, - FilePathError, IsolatedFilePathData, -}; +use sd_core_file_path_helper::{FilePathError, IsolatedFilePathData}; use sd_core_indexer_rules::IndexerRuleError; use sd_core_prisma_helpers::{ file_path_pub_and_cas_ids, file_path_to_isolate_with_pub_id, file_path_walker, @@ -53,8 +50,8 @@ pub enum IndexerError { // Not Found errors #[error("indexer rule not found: ")] IndexerRuleNotFound(i32), - #[error("received sub path not in database: ", .0.display())] - SubPathNotFound(Box), + #[error(transparent)] + SubPath(#[from] SubPathError), // Internal Errors #[error("database Error: {0}")] @@ -78,10 +75,12 @@ pub enum IndexerError { impl From for rspc::Error { fn from(err: IndexerError) -> Self { match err { - IndexerError::IndexerRuleNotFound(_) | IndexerError::SubPathNotFound(_) => { + IndexerError::IndexerRuleNotFound(_) => { Self::with_cause(ErrorCode::NotFound, err.to_string(), err) } + IndexerError::SubPath(sub_path_err) => sub_path_err.into(), + IndexerError::Rules(rule_err) => rule_err.into(), _ => Self::with_cause(ErrorCode::InternalServerError, err.to_string(), err), @@ -111,36 +110,6 @@ pub enum NonCriticalIndexerError { MissingFilePathData(String), } -async fn determine_initial_walk_path( - location_id: location::id::Type, - sub_path: &Option + Send + Sync>, - location_path: impl AsRef + Send, - db: &PrismaClient, -) -> Result { - let location_path = location_path.as_ref(); - - match sub_path { - Some(sub_path) if sub_path.as_ref() != Path::new("") => { - let sub_path = sub_path.as_ref(); - let full_path = ensure_sub_path_is_in_location(location_path, sub_path).await?; - - ensure_sub_path_is_directory(location_path, sub_path).await?; - - ensure_file_path_exists( - sub_path, - &IsolatedFilePathData::new(location_id, location_path, &full_path, true) - .map_err(IndexerError::from)?, - db, - IndexerError::SubPathNotFound, - ) - .await?; - - Ok(full_path) - } - _ => Ok(location_path.to_path_buf()), - } -} - fn chunk_db_queries<'db, 'iso>( iso_file_paths: impl IntoIterator>, db: &'db PrismaClient, diff --git a/core/crates/heavy-lifting/src/indexer/shallow.rs b/core/crates/heavy-lifting/src/indexer/shallow.rs index a39d37bbf..96eaf4398 100644 --- a/core/crates/heavy-lifting/src/indexer/shallow.rs +++ b/core/crates/heavy-lifting/src/indexer/shallow.rs @@ -1,4 +1,4 @@ -use crate::{Error, NonCriticalJobError}; +use crate::{utils::sub_path::get_full_path_from_sub_path, Error, NonCriticalJobError}; use sd_core_indexer_rules::{IndexerRule, IndexerRuler}; use sd_core_prisma_helpers::location_with_indexer_rules; @@ -19,7 +19,7 @@ use itertools::Itertools; use tracing::{debug, warn}; use super::{ - determine_initial_walk_path, remove_non_existing_file_paths, reverse_update_directories_sizes, + remove_non_existing_file_paths, reverse_update_directories_sizes, tasks::{ saver::{SaveTask, SaveTaskOutput}, updater::{UpdateTask, UpdateTaskOutput}, @@ -45,7 +45,9 @@ pub async fn shallow( .map_err(IndexerError::from)?; let to_walk_path = Arc::new( - determine_initial_walk_path(location.id, &Some(sub_path), &*location_path, &db).await?, + get_full_path_from_sub_path(location.id, &Some(sub_path), &*location_path, &db) + .await + .map_err(IndexerError::from)?, ); let Some(WalkTaskOutput { @@ -124,7 +126,7 @@ async fn walk( dispatcher: &BaseTaskDispatcher, ) -> Result, Error> { match dispatcher - .dispatch(WalkDirTask::new( + .dispatch(WalkDirTask::new_shallow( ToWalkEntry::from(&*to_walk_path), to_walk_path, location @@ -142,7 +144,6 @@ async fn walk( location_id: location.id, db, }, - None::>, )?) .await .await? @@ -186,7 +187,7 @@ async fn save_and_update( .chunks(BATCH_SIZE) .into_iter() .map(|chunk| { - SaveTask::new( + SaveTask::new_shallow( location.id, location.pub_id.clone(), chunk.collect::>(), @@ -201,7 +202,7 @@ async fn save_and_update( .chunks(BATCH_SIZE) .into_iter() .map(|chunk| { - UpdateTask::new( + UpdateTask::new_shallow( chunk.collect::>(), Arc::clone(&db), Arc::clone(&sync), diff --git a/core/crates/heavy-lifting/src/indexer/tasks/saver.rs b/core/crates/heavy-lifting/src/indexer/tasks/saver.rs index 2f1f6d433..715fc770c 100644 --- a/core/crates/heavy-lifting/src/indexer/tasks/saver.rs +++ b/core/crates/heavy-lifting/src/indexer/tasks/saver.rs @@ -28,11 +28,12 @@ pub struct SaveTask { walked_entries: Vec, db: Arc, sync: Arc, + is_shallow: bool, } impl SaveTask { #[must_use] - pub fn new( + pub fn new_deep( location_id: location::id::Type, location_pub_id: location::pub_id::Type, walked_entries: Vec, @@ -46,6 +47,26 @@ impl SaveTask { walked_entries, db, sync, + is_shallow: false, + } + } + + #[must_use] + pub fn new_shallow( + location_id: location::id::Type, + location_pub_id: location::pub_id::Type, + walked_entries: Vec, + db: Arc, + sync: Arc, + ) -> Self { + Self { + id: TaskId::new_v4(), + location_id, + location_pub_id, + walked_entries, + db, + sync, + is_shallow: true, } } } @@ -56,6 +77,7 @@ struct SaveTaskSaveState { location_id: location::id::Type, location_pub_id: location::pub_id::Type, walked_entries: Vec, + is_shallow: bool, } impl SerializableTask for SaveTask { @@ -71,6 +93,7 @@ impl SerializableTask for SaveTask { location_id, location_pub_id, walked_entries, + is_shallow, .. } = self; rmp_serde::to_vec_named(&SaveTaskSaveState { @@ -78,6 +101,7 @@ impl SerializableTask for SaveTask { location_id, location_pub_id, walked_entries, + is_shallow, }) } @@ -91,6 +115,7 @@ impl SerializableTask for SaveTask { location_id, location_pub_id, walked_entries, + is_shallow, }| Self { id, location_id, @@ -98,6 +123,7 @@ impl SerializableTask for SaveTask { walked_entries, db, sync, + is_shallow, }, ) } @@ -115,6 +141,11 @@ impl Task for SaveTask { self.id } + fn with_priority(&self) -> bool { + // If we're running in shallow mode, then we want priority + self.is_shallow + } + async fn run(&mut self, _: &Interrupter) -> Result { use file_path::{ create_unchecked, date_created, date_indexed, date_modified, extension, hidden, inode, diff --git a/core/crates/heavy-lifting/src/indexer/tasks/updater.rs b/core/crates/heavy-lifting/src/indexer/tasks/updater.rs index f7e99e800..f4cf0d7fd 100644 --- a/core/crates/heavy-lifting/src/indexer/tasks/updater.rs +++ b/core/crates/heavy-lifting/src/indexer/tasks/updater.rs @@ -28,11 +28,12 @@ pub struct UpdateTask { object_ids_that_should_be_unlinked: HashSet, db: Arc, sync: Arc, + is_shallow: bool, } impl UpdateTask { #[must_use] - pub fn new( + pub fn new_deep( walked_entries: Vec, db: Arc, sync: Arc, @@ -43,6 +44,23 @@ impl UpdateTask { db, sync, object_ids_that_should_be_unlinked: HashSet::new(), + is_shallow: false, + } + } + + #[must_use] + pub fn new_shallow( + walked_entries: Vec, + db: Arc, + sync: Arc, + ) -> Self { + Self { + id: TaskId::new_v4(), + walked_entries, + db, + sync, + object_ids_that_should_be_unlinked: HashSet::new(), + is_shallow: true, } } } @@ -52,6 +70,7 @@ struct UpdateTaskSaveState { id: TaskId, walked_entries: Vec, object_ids_that_should_be_unlinked: HashSet, + is_shallow: bool, } impl SerializableTask for UpdateTask { @@ -62,10 +81,19 @@ impl SerializableTask for UpdateTask { type DeserializeCtx = (Arc, Arc); async fn serialize(self) -> Result, Self::SerializeError> { + let Self { + id, + walked_entries, + object_ids_that_should_be_unlinked, + is_shallow, + .. + } = self; + rmp_serde::to_vec_named(&UpdateTaskSaveState { - id: self.id, - walked_entries: self.walked_entries, - object_ids_that_should_be_unlinked: self.object_ids_that_should_be_unlinked, + id, + walked_entries, + object_ids_that_should_be_unlinked, + is_shallow, }) } @@ -78,12 +106,14 @@ impl SerializableTask for UpdateTask { id, walked_entries, object_ids_that_should_be_unlinked, + is_shallow, }| Self { id, walked_entries, object_ids_that_should_be_unlinked, db, sync, + is_shallow, }, ) } @@ -101,6 +131,11 @@ impl Task for UpdateTask { self.id } + fn with_priority(&self) -> bool { + // If we're running in shallow mode, then we want priority + self.is_shallow + } + async fn run(&mut self, interrupter: &Interrupter) -> Result { use file_path::{ cas_id, date_created, date_modified, hidden, inode, is_dir, object, object_id, diff --git a/core/crates/heavy-lifting/src/indexer/tasks/walker.rs b/core/crates/heavy-lifting/src/indexer/tasks/walker.rs index 7b8eefd4d..3ed771e2e 100644 --- a/core/crates/heavy-lifting/src/indexer/tasks/walker.rs +++ b/core/crates/heavy-lifting/src/indexer/tasks/walker.rs @@ -9,8 +9,8 @@ use sd_core_prisma_helpers::{file_path_pub_and_cas_ids, file_path_walker}; use sd_prisma::prisma::file_path; use sd_task_system::{ - check_interruption, ExecStatus, Interrupter, IntoAnyTaskOutput, SerializableTask, Task, - TaskDispatcher, TaskHandle, TaskId, + check_interruption, BaseTaskDispatcher, ExecStatus, Interrupter, IntoAnyTaskOutput, + SerializableTask, Task, TaskDispatcher, TaskHandle, TaskId, }; use sd_utils::{db::inode_from_db, error::FileIOError}; @@ -239,6 +239,7 @@ struct WalkDirSaveState { stage: WalkerStageSaveState, errors: Vec, scan_time: Duration, + is_shallow: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -351,7 +352,7 @@ impl From for WalkerStage { } #[derive(Debug)] -pub struct WalkDirTask +pub struct WalkDirTask> where DBProxy: WalkerDBProxy, IsoPathFactory: IsoFilePathFactory, @@ -368,6 +369,7 @@ where maybe_dispatcher: Option, errors: Vec, scan_time: Duration, + is_shallow: bool, } impl WalkDirTask @@ -376,13 +378,13 @@ where IsoPathFactory: IsoFilePathFactory, Dispatcher: TaskDispatcher, { - pub fn new( + pub fn new_deep( entry: impl Into + Send, root: Arc, indexer_ruler: IndexerRuler, iso_file_path_factory: IsoPathFactory, db_proxy: DBProxy, - maybe_dispatcher: Option, + dispatcher: Dispatcher, ) -> Result { let entry = entry.into(); Ok(Self { @@ -394,7 +396,38 @@ where db_proxy, stage: WalkerStage::Start, entry, - maybe_dispatcher, + maybe_dispatcher: Some(dispatcher), + is_shallow: false, + errors: Vec::new(), + scan_time: Duration::ZERO, + }) + } +} + +impl WalkDirTask> +where + DBProxy: WalkerDBProxy, + IsoPathFactory: IsoFilePathFactory, +{ + pub fn new_shallow( + entry: impl Into + Send, + root: Arc, + indexer_ruler: IndexerRuler, + iso_file_path_factory: IsoPathFactory, + db_proxy: DBProxy, + ) -> Result { + let entry = entry.into(); + Ok(Self { + id: TaskId::new_v4(), + root, + indexer_ruler, + entry_iso_file_path: iso_file_path_factory.build(&entry.path, true)?, + iso_file_path_factory, + db_proxy, + stage: WalkerStage::Start, + entry, + maybe_dispatcher: None, + is_shallow: true, errors: Vec::new(), scan_time: Duration::ZERO, }) @@ -413,14 +446,26 @@ where type DeserializeCtx = (IndexerRuler, DBProxy, IsoPathFactory, Dispatcher); async fn serialize(self) -> Result, Self::SerializeError> { + let Self { + id, + entry, + root, + entry_iso_file_path, + stage, + errors, + scan_time, + is_shallow, + .. + } = self; rmp_serde::to_vec_named(&WalkDirSaveState { - id: self.id, - entry: self.entry, - root: self.root, - entry_iso_file_path: self.entry_iso_file_path, - stage: self.stage.into(), - errors: self.errors, - scan_time: self.scan_time, + id, + entry, + root, + entry_iso_file_path, + stage: stage.into(), + errors, + scan_time, + is_shallow, }) } @@ -437,6 +482,7 @@ where stage, errors, scan_time, + is_shallow, }| Self { id, entry, @@ -446,9 +492,10 @@ where iso_file_path_factory, db_proxy, stage: stage.into(), - maybe_dispatcher: Some(dispatcher), + maybe_dispatcher: is_shallow.then_some(dispatcher), errors, scan_time, + is_shallow, }, ) } @@ -466,6 +513,11 @@ where self.id } + fn with_priority(&self) -> bool { + // If we're running in shallow mode, then we want priority + self.is_shallow + } + #[allow(clippy::too_many_lines)] async fn run(&mut self, interrupter: &Interrupter) -> Result { let Self { @@ -747,13 +799,13 @@ async fn keep_walking( to_keep_walking .drain(..) .map(|entry| { - WalkDirTask::new( + WalkDirTask::new_deep( entry, Arc::clone(root), indexer_ruler.clone(), iso_file_path_factory.clone(), db_proxy.clone(), - Some(dispatcher.clone()), + dispatcher.clone(), ) .map_err(|e| NonCriticalIndexerError::DispatchKeepWalking(e.to_string())) }) @@ -1226,7 +1278,7 @@ mod tests { let handle = system .dispatch( - WalkDirTask::new( + WalkDirTask::new_deep( root_path.to_path_buf(), Arc::new(root_path.to_path_buf()), indexer_ruler, @@ -1234,7 +1286,7 @@ mod tests { root_path: Arc::new(root_path.to_path_buf()), }, DummyDBProxy, - Some(system.get_dispatcher()), + system.get_dispatcher(), ) .unwrap(), ) diff --git a/core/crates/heavy-lifting/src/job_system/job.rs b/core/crates/heavy-lifting/src/job_system/job.rs index 5dfdddfcb..191d71148 100644 --- a/core/crates/heavy-lifting/src/job_system/job.rs +++ b/core/crates/heavy-lifting/src/job_system/job.rs @@ -25,7 +25,10 @@ use futures_concurrency::{ use serde::{Deserialize, Serialize}; use specta::Type; use strum::{Display, EnumString}; -use tokio::spawn; +use tokio::{ + spawn, + sync::{watch, Mutex}, +}; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -42,6 +45,7 @@ use super::{ #[strum(use_phf, serialize_all = "snake_case")] pub enum JobName { Indexer, + FileIdentifier, // TODO: Add more job names as needed } @@ -631,7 +635,10 @@ async fn to_spawn_job( let mut remote_controllers = vec![]; - let (dispatcher, remote_controllers_rx) = JobTaskDispatcher::new(base_dispatcher); + let (running_state_tx, running_state_rx) = watch::channel(JobRunningState::Running); + + let (dispatcher, remote_controllers_rx) = + JobTaskDispatcher::new(base_dispatcher, running_state_rx); if let Some(existing_tasks) = existing_tasks { if let Err(e) = job @@ -664,6 +671,7 @@ async fn to_spawn_job( match command { Command::Pause => { + running_state_tx.send_modify(|state| *state = JobRunningState::Paused); remote_controllers .iter() .map(TaskRemoteController::pause) @@ -680,6 +688,8 @@ async fn to_spawn_job( }); } Command::Resume => { + running_state_tx.send_modify(|state| *state = JobRunningState::Running); + remote_controllers .iter() .map(TaskRemoteController::resume) @@ -726,14 +736,29 @@ async fn to_spawn_job( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JobRunningState { + Running, + Paused, +} + +impl Default for JobRunningState { + fn default() -> Self { + Self::Running + } +} + #[derive(Debug, Clone)] pub struct JobTaskDispatcher { dispatcher: BaseTaskDispatcher, remote_controllers_tx: chan::Sender, + running_state: Arc>>, } impl TaskDispatcher for JobTaskDispatcher { async fn dispatch_boxed(&self, boxed_task: Box>) -> TaskHandle { + self.wait_for_dispatch_approval().await; + let handle = self.dispatcher.dispatch_boxed(boxed_task).await; self.remote_controllers_tx @@ -748,14 +773,9 @@ impl TaskDispatcher for JobTaskDispatcher { &self, boxed_tasks: impl IntoIterator>> + Send, ) -> Vec> { - let handles = self.dispatcher.dispatch_many_boxed(boxed_tasks).await; + self.wait_for_dispatch_approval().await; - for handle in &handles { - self.remote_controllers_tx - .send(handle.remote_controller()) - .await - .expect("remote controllers tx closed"); - } + let handles = self.dispatcher.dispatch_many_boxed(boxed_tasks).await; handles .iter() @@ -770,15 +790,28 @@ impl TaskDispatcher for JobTaskDispatcher { } impl JobTaskDispatcher { - fn new(dispatcher: BaseTaskDispatcher) -> (Self, chan::Receiver) { + fn new( + dispatcher: BaseTaskDispatcher, + running_state_rx: watch::Receiver, + ) -> (Self, chan::Receiver) { let (remote_controllers_tx, remote_controllers_rx) = chan::unbounded(); ( Self { dispatcher, remote_controllers_tx, + running_state: Arc::new(Mutex::new(running_state_rx)), }, remote_controllers_rx, ) } + + async fn wait_for_dispatch_approval(&self) { + self.running_state + .lock() + .await + .wait_for(|state| *state == JobRunningState::Running) + .await + .expect("job running state watch channel unexpectedly closed"); + } } diff --git a/core/crates/heavy-lifting/src/job_system/store.rs b/core/crates/heavy-lifting/src/job_system/store.rs index 93728030c..4d1cb9485 100644 --- a/core/crates/heavy-lifting/src/job_system/store.rs +++ b/core/crates/heavy-lifting/src/job_system/store.rs @@ -1,4 +1,4 @@ -use crate::indexer::IndexerJob; +use crate::{file_identifier::FileIdentifierJob, indexer::IndexerJob}; use sd_prisma::prisma::{job, location}; use sd_utils::uuid_to_bytes; @@ -212,6 +212,7 @@ async fn load_job( Ctx, [ IndexerJob, + FileIdentifierJob, // TODO: Add more jobs here // e.g.: FileIdentifierJob, MediaProcessorJob, etc., ] diff --git a/core/crates/heavy-lifting/src/lib.rs b/core/crates/heavy-lifting/src/lib.rs index 3675cdedb..1cc079f8d 100644 --- a/core/crates/heavy-lifting/src/lib.rs +++ b/core/crates/heavy-lifting/src/lib.rs @@ -33,9 +33,12 @@ use serde::{Deserialize, Serialize}; use specta::Type; use thiserror::Error; +pub mod file_identifier; pub mod indexer; pub mod job_system; +pub mod utils; +use file_identifier::{FileIdentifierError, NonCriticalFileIdentifierError}; use indexer::{IndexerError, NonCriticalIndexerError}; pub use job_system::{ @@ -47,6 +50,8 @@ pub use job_system::{ pub enum Error { #[error(transparent)] Indexer(#[from] IndexerError), + #[error(transparent)] + FileIdentifier(#[from] FileIdentifierError), #[error(transparent)] TaskSystem(#[from] TaskSystemError), @@ -56,6 +61,7 @@ impl From for rspc::Error { fn from(e: Error) -> Self { match e { Error::Indexer(e) => e.into(), + Error::FileIdentifier(e) => e.into(), Error::TaskSystem(e) => { Self::with_cause(rspc::ErrorCode::InternalServerError, e.to_string(), e) } @@ -68,4 +74,15 @@ pub enum NonCriticalJobError { // TODO: Add variants as needed #[error(transparent)] Indexer(#[from] NonCriticalIndexerError), + #[error(transparent)] + FileIdentifier(#[from] NonCriticalFileIdentifierError), +} + +#[repr(i32)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, Eq, PartialEq)] +pub enum LocationScanState { + Pending = 0, + Indexed = 1, + FilesIdentified = 2, + Completed = 3, } diff --git a/core/crates/heavy-lifting/src/utils/mod.rs b/core/crates/heavy-lifting/src/utils/mod.rs new file mode 100644 index 000000000..538257e2c --- /dev/null +++ b/core/crates/heavy-lifting/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod sub_path; diff --git a/core/crates/heavy-lifting/src/utils/sub_path.rs b/core/crates/heavy-lifting/src/utils/sub_path.rs new file mode 100644 index 000000000..6461ccdb7 --- /dev/null +++ b/core/crates/heavy-lifting/src/utils/sub_path.rs @@ -0,0 +1,93 @@ +use rspc::ErrorCode; +use sd_core_file_path_helper::{ + ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, + FilePathError, IsolatedFilePathData, +}; + +use sd_prisma::prisma::{location, PrismaClient}; + +use std::path::{Path, PathBuf}; + +use prisma_client_rust::QueryError; + +#[derive(thiserror::Error, Debug)] +pub enum SubPathError { + #[error("received sub path not in database: ", .0.display())] + SubPathNotFound(Box), + + #[error("database error: {0}")] + Database(#[from] QueryError), + + #[error(transparent)] + IsoFilePath(#[from] FilePathError), +} + +impl From for rspc::Error { + fn from(err: SubPathError) -> Self { + match err { + SubPathError::SubPathNotFound(_) => { + Self::with_cause(ErrorCode::NotFound, err.to_string(), err) + } + + _ => Self::with_cause(ErrorCode::InternalServerError, err.to_string(), err), + } + } +} + +pub async fn get_full_path_from_sub_path( + location_id: location::id::Type, + sub_path: &Option + Send + Sync>, + location_path: impl AsRef + Send, + db: &PrismaClient, +) -> Result { + let location_path = location_path.as_ref(); + + match sub_path { + Some(sub_path) if sub_path.as_ref() != Path::new("") => { + let sub_path = sub_path.as_ref(); + let full_path = ensure_sub_path_is_in_location(location_path, sub_path).await?; + + ensure_sub_path_is_directory(location_path, sub_path).await?; + + ensure_file_path_exists( + sub_path, + &IsolatedFilePathData::new(location_id, location_path, &full_path, true)?, + db, + SubPathError::SubPathNotFound, + ) + .await?; + + Ok(full_path) + } + _ => Ok(location_path.to_path_buf()), + } +} + +pub async fn maybe_get_iso_file_path_from_sub_path( + location_id: location::id::Type, + sub_path: &Option + Send + Sync>, + location_path: impl AsRef + Send, + db: &PrismaClient, +) -> Result>, SubPathError> { + let location_path = location_path.as_ref(); + + match sub_path { + Some(sub_path) if sub_path.as_ref() != Path::new("") => { + let full_path = ensure_sub_path_is_in_location(location_path, sub_path).await?; + ensure_sub_path_is_directory(location_path, sub_path).await?; + + let sub_iso_file_path = + IsolatedFilePathData::new(location_id, location_path, &full_path, true)?; + + ensure_file_path_exists( + sub_path, + &sub_iso_file_path, + db, + SubPathError::SubPathNotFound, + ) + .await + .map(|()| Some(sub_iso_file_path)) + } + _ => Ok(None), + } +} diff --git a/core/crates/prisma-helpers/src/lib.rs b/core/crates/prisma-helpers/src/lib.rs index 26d67a4bf..fe830b59f 100644 --- a/core/crates/prisma-helpers/src/lib.rs +++ b/core/crates/prisma-helpers/src/lib.rs @@ -30,6 +30,7 @@ use sd_prisma::prisma::{self, file_path, job, label, location, object}; // File Path selectables! +file_path::select!(file_path_pub_id { pub_id }); file_path::select!(file_path_pub_and_cas_ids { id pub_id cas_id }); file_path::select!(file_path_just_pub_id_materialized_path { pub_id diff --git a/core/crates/sync/src/ingest.rs b/core/crates/sync/src/ingest.rs index ae6710207..8bfbe54c9 100644 --- a/core/crates/sync/src/ingest.rs +++ b/core/crates/sync/src/ingest.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, ops::Deref, sync::{atomic::Ordering, Arc}, }; @@ -7,7 +8,10 @@ use sd_prisma::{ prisma::{crdt_operation, SortOrder}, prisma_sync::ModelSyncData, }; -use sd_sync::{CRDTOperation, OperationKind}; +use sd_sync::{ + CRDTOperation, CRDTOperationData, CompressedCRDTOperation, CompressedCRDTOperations, + OperationKind, +}; use tokio::sync::{mpsc, oneshot, Mutex}; use tracing::debug; use uhlc::{Timestamp, NTP64}; @@ -106,16 +110,20 @@ impl Actor { } } State::Ingesting(event) => { - if !event.messages.is_empty() { - debug!( - "ingesting {} operations: {} to {}", - event.messages.len(), - event.messages.first().unwrap().timestamp.as_u64(), - event.messages.last().unwrap().timestamp.as_u64(), - ); + debug!( + "ingesting {} operations: {} to {}", + event.messages.len(), + event.messages.first().unwrap().3.timestamp.as_u64(), + event.messages.last().unwrap().3.timestamp.as_u64(), + ); - for op in event.messages { - self.receive_crdt_operation(op).await; + for (instance, data) in event.messages.0 { + for (model, data) in data { + for (record, ops) in data { + self.receive_crdt_operations(instance, model, record, ops) + .await + .expect("sync ingest failed"); + } } } @@ -161,105 +169,245 @@ impl Actor { } // where the magic happens - async fn receive_crdt_operation( + async fn receive_crdt_operations( &mut self, - mut op: CRDTOperation, + instance: Uuid, + model: u16, + record_id: rmpv::Value, + mut ops: Vec, ) -> prisma_client_rust::Result<()> { let db = &self.db; + ops.sort_by_key(|op| op.timestamp); + + let new_timestamp = ops.last().expect("Empty ops array").timestamp; + // first, we update the HLC's timestamp with the incoming one. // this involves a drift check + sets the last time of the clock self.clock - .update_with_timestamp(&Timestamp::new(op.timestamp, op.instance.into())) + .update_with_timestamp(&Timestamp::new(new_timestamp, instance.into())) .expect("timestamp has too much drift!"); // read the timestamp for the operation's instance, or insert one if it doesn't exist - let timestamp = self.timestamps.read().await.get(&op.instance).cloned(); + let timestamp = self.timestamps.read().await.get(&instance).cloned(); - // copy some fields bc rust ownership - let op_instance = op.instance; - let op_timestamp = op.timestamp; - - // resolve conflicts - // this can be outside the transaction as there's only ever one ingester - match &mut op.data { - // don't apply Create operations if the record has been deleted - sd_sync::CRDTOperationData::Create(_) => { - let delete = db - .crdt_operation() - .find_first(vec![ - crdt_operation::model::equals(op.model as i32), - crdt_operation::record_id::equals( - rmp_serde::to_vec(&op.record_id).unwrap(), - ), - crdt_operation::kind::equals(OperationKind::Delete.to_string()), - ]) - .order_by(crdt_operation::timestamp::order(SortOrder::Desc)) - .exec() - .await?; - - if delete.is_some() { - return Ok(()); - } - } - // don't apply Update operations if the record hasn't been created, or a newer Update for the same field has been applied - sd_sync::CRDTOperationData::Update { field, .. } => { - let (create, update) = db - ._batch(( - db.crdt_operation() - .find_first(vec![ - crdt_operation::model::equals(op.model as i32), - crdt_operation::record_id::equals( - rmp_serde::to_vec(&op.record_id).unwrap(), - ), - crdt_operation::kind::equals(OperationKind::Create.to_string()), - ]) - .order_by(crdt_operation::timestamp::order(SortOrder::Desc)), - db.crdt_operation() - .find_first(vec![ - crdt_operation::timestamp::gt(op.timestamp.as_u64() as i64), - crdt_operation::model::equals(op.model as i32), - crdt_operation::record_id::equals( - rmp_serde::to_vec(&op.record_id).unwrap(), - ), - crdt_operation::kind::equals( - OperationKind::Update(field).to_string(), - ), - ]) - .order_by(crdt_operation::timestamp::order(SortOrder::Desc)), - )) - .await?; - - // we don't care about the contents of the create operation, just that it exists - // - all update operations come after creates, no check is necessary - if create.is_none() || update.is_some() { - return Ok(()); - } - } + // Delete - ignores all other messages + if let Some(delete_op) = ops + .iter() + .rev() + .find(|op| matches!(op.data, sd_sync::CRDTOperationData::Delete)) + { // deletes are the be all and end all, no need to check anything - sd_sync::CRDTOperationData::Delete => {} - }; - // we don't want these writes to not apply together! - self.db - ._transaction() - .with_timeout(30 * 1000) - .run(|db| async move { - // apply the operation to the actual record - ModelSyncData::from_op(op.clone()) + let op = CRDTOperation { + instance, + model, + record_id, + timestamp: delete_op.timestamp, + data: CRDTOperationData::Delete, + }; + + self.db + ._transaction() + .with_timeout(30 * 1000) + .run(|db| async move { + ModelSyncData::from_op(op.clone()) + .unwrap() + .exec(&db) + .await?; + write_crdt_op_to_db(&op, &db).await?; + + Ok(()) + }) + .await?; + } + // Create + > 0 Update - overwrites the create's data with the updates + else if let Some(timestamp) = ops.iter().rev().find_map(|op| { + if let sd_sync::CRDTOperationData::Create(_) = &op.data { + return Some(op.timestamp); + } + + None + }) { + // conflict resolution + let delete = db + .crdt_operation() + .find_first(vec![ + crdt_operation::model::equals(model as i32), + crdt_operation::record_id::equals(rmp_serde::to_vec(&record_id).unwrap()), + crdt_operation::kind::equals(OperationKind::Delete.to_string()), + ]) + .order_by(crdt_operation::timestamp::order(SortOrder::Desc)) + .exec() + .await?; + + if delete.is_some() { + return Ok(()); + } + + let mut data = BTreeMap::new(); + + let mut applied_ops = vec![]; + + // search for all Updates until a Create is found + for op in ops.iter().rev() { + match &op.data { + CRDTOperationData::Delete => unreachable!("Delete can't exist here!"), + CRDTOperationData::Create(create_data) => { + for (k, v) in create_data { + data.entry(k).or_insert(v); + } + + applied_ops.push(op); + + break; + } + CRDTOperationData::Update { field, value } => { + applied_ops.push(op); + data.insert(field, value); + } + } + } + + self.db + ._transaction() + .with_timeout(30 * 1000) + .run(|db| async move { + // fake a create with a bunch of data rather than individual insert + ModelSyncData::from_op(CRDTOperation { + instance, + model, + record_id: record_id.clone(), + timestamp, + data: CRDTOperationData::Create( + data.into_iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + }) .unwrap() .exec(&db) - .await - .unwrap(); + .await?; - // write the operation to the operations table - write_crdt_op_to_db(&op, &db).await - }) - .await?; + for op in applied_ops { + write_crdt_op_to_db( + &CRDTOperation { + instance, + model, + record_id: record_id.clone(), + timestamp: op.timestamp, + data: op.data.clone(), + }, + &db, + ) + .await?; + } + + Ok(()) + }) + .await?; + } + // > 0 Update - batches updates with a fake Create op + else { + let mut data = BTreeMap::new(); + + for op in ops.into_iter().rev() { + let CRDTOperationData::Update { field, value } = op.data else { + unreachable!("Create + Delete should be filtered out!"); + }; + + data.insert(field, (value, op.timestamp)); + } + + // conflict resolution + let (create, updates) = db + ._batch(( + db.crdt_operation() + .find_first(vec![ + crdt_operation::model::equals(model as i32), + crdt_operation::record_id::equals( + rmp_serde::to_vec(&record_id).unwrap(), + ), + crdt_operation::kind::equals(OperationKind::Create.to_string()), + ]) + .order_by(crdt_operation::timestamp::order(SortOrder::Desc)), + data.iter() + .map(|(k, (_, timestamp))| { + db.crdt_operation() + .find_first(vec![ + crdt_operation::timestamp::gt(timestamp.as_u64() as i64), + crdt_operation::model::equals(model as i32), + crdt_operation::record_id::equals( + rmp_serde::to_vec(&record_id).unwrap(), + ), + crdt_operation::kind::equals( + OperationKind::Update(k).to_string(), + ), + ]) + .order_by(crdt_operation::timestamp::order(SortOrder::Desc)) + }) + .collect::>(), + )) + .await?; + + if create.is_none() { + return Ok(()); + } + + // does the same thing as processing ops one-by-one and returning early if a newer op was found + for (update, key) in updates + .into_iter() + .zip(data.keys().cloned().collect::>()) + { + if update.is_some() { + data.remove(&key); + } + } + + self.db + ._transaction() + .with_timeout(30 * 1000) + .run(|db| async move { + // fake operation to batch them all at once + ModelSyncData::from_op(CRDTOperation { + instance, + model, + record_id: record_id.clone(), + timestamp: NTP64(0), + data: CRDTOperationData::Create( + data.iter() + .map(|(k, (data, _))| (k.to_string(), data.clone())) + .collect(), + ), + }) + .unwrap() + .exec(&db) + .await?; + + // need to only apply ops that haven't been filtered out + for (field, (value, timestamp)) in data { + write_crdt_op_to_db( + &CRDTOperation { + instance, + model, + record_id: record_id.clone(), + timestamp, + data: CRDTOperationData::Update { field, value }, + }, + &db, + ) + .await?; + } + + Ok(()) + }) + .await?; + } // update the stored timestamp for this instance - will be derived from the crdt operations table on restart - let new_ts = NTP64::max(timestamp.unwrap_or_default(), op_timestamp); - self.timestamps.write().await.insert(op_instance, new_ts); + let new_ts = NTP64::max(timestamp.unwrap_or_default(), new_timestamp); + + self.timestamps.write().await.insert(instance, new_ts); self.io.req_tx.send(Request::Ingested).await.ok(); @@ -283,7 +431,7 @@ pub struct Handler { #[derive(Debug)] pub struct MessagesEvent { pub instance_id: Uuid, - pub messages: Vec, + pub messages: CompressedCRDTOperations, pub has_more: bool, } diff --git a/core/crates/sync/src/manager.rs b/core/crates/sync/src/manager.rs index 53324ea7e..daed3db92 100644 --- a/core/crates/sync/src/manager.rs +++ b/core/crates/sync/src/manager.rs @@ -104,11 +104,13 @@ impl Manager { )) .await?; - self.shared - .timestamps - .write() - .await - .insert(self.instance, ops.last().unwrap().timestamp); + if let Some(last) = ops.last() { + self.shared + .timestamps + .write() + .await + .insert(self.instance, last.timestamp); + } self.tx.send(SyncMessage::Created).ok(); diff --git a/core/crates/sync/tests/mock_instance.rs b/core/crates/sync/tests/mock_instance.rs index e3bf0fe99..c4f5fc13e 100644 --- a/core/crates/sync/tests/mock_instance.rs +++ b/core/crates/sync/tests/mock_instance.rs @@ -1,5 +1,6 @@ use sd_core_sync::*; use sd_prisma::prisma; +use sd_sync::CompressedCRDTOperations; use sd_utils::uuid_to_bytes; use prisma_client_rust::chrono::Utc; @@ -122,7 +123,7 @@ impl Instance { ingest .event_tx .send(ingest::Event::Messages(ingest::MessagesEvent { - messages, + messages: CompressedCRDTOperations::new(messages), has_more: false, instance_id: instance1.id, })) diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 63ede3af3..684406272 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -561,7 +561,7 @@ pub(crate) fn mount() -> AlphaRouter { ); #[cfg(not(any(target_os = "ios", target_os = "android")))] - trash::delete(&full_path).unwrap(); + trash::delete(full_path).unwrap(); Ok(()) } diff --git a/core/src/api/jobs.rs b/core/src/api/jobs.rs index 8144b4c12..6c3f57d6b 100644 --- a/core/src/api/jobs.rs +++ b/core/src/api/jobs.rs @@ -343,7 +343,6 @@ pub(crate) fn mount() -> AlphaRouter { R.with2(library()) .subscription(|(node, _), _: ()| async move { // TODO: Only return event for the library that was subscribed to - let mut event_bus_rx = node.event_bus.0.subscribe(); async_stream::stream! { while let Ok(event) = event_bus_rx.recv().await { @@ -355,4 +354,19 @@ pub(crate) fn mount() -> AlphaRouter { } }) }) + .procedure("newFilePathIdentified", { + R.with2(library()) + .subscription(|(node, _), _: ()| async move { + // TODO: Only return event for the library that was subscribed to + let mut event_bus_rx = node.event_bus.0.subscribe(); + async_stream::stream! { + while let Ok(event) = event_bus_rx.recv().await { + match event { + CoreEvent::NewIdentifiedObjects { file_path_ids } => yield file_path_ids, + _ => {} + } + } + } + }) + }) } diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index cdd30762d..381765d85 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -2,8 +2,8 @@ use crate::{ invalidate_query, location::{ delete_location, find_location, indexer::OldIndexerJobInit, light_scan_location, - relink_location, scan_location, scan_location_sub_path, LocationCreateArgs, LocationError, - LocationUpdateArgs, ScanState, + non_indexed::NonIndexedPathItem, relink_location, scan_location, scan_location_sub_path, + LocationCreateArgs, LocationError, LocationUpdateArgs, ScanState, }, object::old_file_identifier::old_file_identifier_job::OldFileIdentifierJobInit, old_job::StatefulJob, @@ -17,7 +17,6 @@ use sd_core_prisma_helpers::{ }; use sd_cache::{CacheNode, Model, Normalise, NormalisedResult, NormalisedResults, Reference}; -use sd_indexer::NonIndexedPathItem; use sd_prisma::prisma::{file_path, indexer_rule, indexer_rules_in_location, location, SortOrder}; use std::path::{Path, PathBuf}; @@ -39,20 +38,26 @@ pub type ThumbnailKey = Vec; #[serde(tag = "type")] pub enum ExplorerItem { Path { + // provide the frontend with the thumbnail key explicitly thumbnail: Option, + // this tells the frontend if a thumbnail actually exists or not + has_created_thumbnail: bool, + // we can't actually modify data from PCR types, thats why computed properties are used on ExplorerItem item: file_path_with_object::Data, }, Object { thumbnail: Option, + has_created_thumbnail: bool, item: object_with_file_paths::Data, }, - Location { - item: location::Data, - }, NonIndexedPath { thumbnail: Option, + has_created_thumbnail: bool, item: NonIndexedPathItem, }, + Location { + item: location::Data, + }, SpacedropPeer { item: PeerMetadata, }, diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 70836b6fc..48c35a35a 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -1,16 +1,17 @@ use crate::{ invalidate_query, node::{ - config::{NodeConfig, NodePreferences, P2PDiscoveryState, Port}, + config::{NodeConfig, NodeConfigP2P, NodePreferences}, get_hardware_model_name, HardwareModel, }, old_job::JobProgressEvent, - p2p::{into_listener2, Listener2}, Node, }; use sd_cache::patch_typedef; use sd_p2p::RemoteIdentity; +use sd_prisma::prisma::file_path; + use std::sync::{atomic::Ordering, Arc}; use itertools::Itertools; @@ -53,7 +54,12 @@ pub type Router = rspc::Router; /// Represents an internal core event, these are exposed to client via a rspc subscription. #[derive(Debug, Clone, Serialize, Type)] pub enum CoreEvent { - NewThumbnail { thumb_key: Vec }, + NewThumbnail { + thumb_key: Vec, + }, + NewIdentifiedObjects { + file_path_ids: Vec, + }, JobProgress(JobProgressEvent), InvalidateOperation(InvalidateOperationEvent), } @@ -64,16 +70,12 @@ pub enum CoreEvent { #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum BackendFeature { - FilesOverP2P, CloudSync, } impl BackendFeature { pub fn restore(&self, node: &Node) { match self { - BackendFeature::FilesOverP2P => { - node.files_over_p2p_flag.store(true, Ordering::Relaxed); - } BackendFeature::CloudSync => { node.cloud_sync_flag.store(true, Ordering::Relaxed); } @@ -89,9 +91,7 @@ pub struct SanitisedNodeConfig { /// name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record pub name: String, pub identity: RemoteIdentity, - pub p2p_ipv4_port: Port, - pub p2p_ipv6_port: Port, - pub p2p_discovery: P2PDiscoveryState, + pub p2p: NodeConfigP2P, pub features: Vec, pub preferences: NodePreferences, pub image_labeler_version: Option, @@ -103,9 +103,7 @@ impl From for SanitisedNodeConfig { id: value.id, name: value.name, identity: value.identity.to_remote_identity(), - p2p_ipv4_port: value.p2p_ipv4_port, - p2p_ipv6_port: value.p2p_ipv6_port, - p2p_discovery: value.p2p_discovery, + p2p: value.p2p, features: value.features, preferences: value.preferences, image_labeler_version: value.image_labeler_version, @@ -118,7 +116,6 @@ struct NodeState { #[serde(flatten)] config: SanitisedNodeConfig, data_path: String, - listeners: Vec, device_model: Option, } @@ -154,7 +151,6 @@ pub(crate) fn mount() -> Arc { .to_str() .expect("Found non-UTF-8 path") .to_string(), - listeners: into_listener2(&node.p2p.p2p.listeners()), device_model: Some(device_model), }) }) @@ -181,9 +177,6 @@ pub(crate) fn mount() -> Arc { .map_err(|err| rspc::Error::new(ErrorCode::InternalServerError, err.to_string()))?; match feature { - BackendFeature::FilesOverP2P => { - node.files_over_p2p_flag.store(enabled, Ordering::Relaxed); - } BackendFeature::CloudSync => { node.cloud_sync_flag.store(enabled, Ordering::Relaxed); } diff --git a/core/src/api/nodes.rs b/core/src/api/nodes.rs index b77c71f4b..b5be20f3d 100644 --- a/core/src/api/nodes.rs +++ b/core/src/api/nodes.rs @@ -19,9 +19,11 @@ pub(crate) fn mount() -> AlphaRouter { #[derive(Deserialize, Type)] pub struct ChangeNodeNameArgs { pub name: Option, - pub p2p_ipv4_port: Option, - pub p2p_ipv6_port: Option, + pub p2p_port: Option, + pub p2p_ipv4_enabled: Option, + pub p2p_ipv6_enabled: Option, pub p2p_discovery: Option, + pub p2p_remote_access: Option, pub image_labeler_version: Option, } R.mutation(|node, args: ChangeNodeNameArgs| async move { @@ -43,14 +45,20 @@ pub(crate) fn mount() -> AlphaRouter { config.name = name; } - if let Some(port) = args.p2p_ipv4_port { - config.p2p_ipv4_port = port; + if let Some(port) = args.p2p_port { + config.p2p.port = port; }; - if let Some(port) = args.p2p_ipv6_port { - config.p2p_ipv6_port = port; + if let Some(enabled) = args.p2p_ipv4_enabled { + config.p2p.ipv4 = enabled; }; - if let Some(v) = args.p2p_discovery { - config.p2p_discovery = v; + if let Some(enabled) = args.p2p_ipv6_enabled { + config.p2p.ipv6 = enabled; + }; + if let Some(discovery) = args.p2p_discovery { + config.p2p.discovery = discovery; + }; + if let Some(remote_access) = args.p2p_remote_access { + config.p2p.remote_access = remote_access; }; #[cfg(feature = "ai")] diff --git a/core/src/api/p2p.rs b/core/src/api/p2p.rs index 90082d9fb..89c88ea17 100644 --- a/core/src/api/p2p.rs +++ b/core/src/api/p2p.rs @@ -3,9 +3,9 @@ use crate::p2p::{operations, ConnectionMethod, DiscoveryMethod, Header, P2PEvent use sd_p2p::{PeerConnectionCandidate, RemoteIdentity}; use rspc::{alpha::AlphaRouter, ErrorCode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use specta::Type; -use std::path::PathBuf; +use std::{path::PathBuf, sync::PoisonError}; use tokio::io::AsyncWriteExt; use uuid::Uuid; @@ -58,6 +58,54 @@ pub(crate) fn mount() -> AlphaRouter { .procedure("state", { R.query(|node, _: ()| async move { Ok(node.p2p.state().await) }) }) + .procedure("listeners", { + #[derive(Serialize, Type)] + #[serde(tag = "type")] + pub enum ListenerState { + Listening, + Error { error: String }, + Disabled, + } + + #[derive(Serialize, Type)] + pub struct Listeners { + ipv4: ListenerState, + ipv6: ListenerState, + } + + R.query(|node, _: ()| async move { + let addrs = node + .p2p + .p2p + .listeners() + .iter() + .flat_map(|l| l.addrs.clone()) + .collect::>(); + + let errors = node + .p2p + .listener_errors + .lock() + .unwrap_or_else(PoisonError::into_inner); + + Ok(Listeners { + ipv4: match errors.ipv4 { + Some(ref err) => ListenerState::Error { error: err.clone() }, + None => match addrs.iter().any(|f| f.is_ipv4()) { + true => ListenerState::Listening, + false => ListenerState::Disabled, + }, + }, + ipv6: match errors.ipv6 { + Some(ref err) => ListenerState::Error { error: err.clone() }, + None => match addrs.iter().any(|f| f.is_ipv6()) { + true => ListenerState::Listening, + false => ListenerState::Disabled, + }, + }, + }) + }) + }) .procedure("debugConnect", { R.mutation(|node, identity: RemoteIdentity| async move { let peer = { node.p2p.p2p.peers().get(&identity).cloned() }; diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index d4eb96b7c..d4e75aeb2 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -1,34 +1,24 @@ -use std::{collections::HashMap, path::PathBuf}; - use crate::{ api::{locations::ExplorerItem, utils::library}, library::Library, - location::LocationError, - object::{ - cas::generate_cas_id, - media::old_thumbnail::{ - get_ephemeral_thumb_key, get_indexed_thumb_key, BatchToProcess, GenerateThumbnailArgs, - }, - }, + location::{non_indexed, LocationError}, + object::media::old_thumbnail::get_indexed_thumb_key, util::{unsafe_streamed_query, BatchedStream}, }; -use opendal::{services::Fs, Operator}; +use sd_core_prisma_helpers::{file_path_with_object, object_with_file_paths}; use sd_cache::{CacheNode, Model, Normalise, Reference}; -use sd_core_indexer_rules::seed::{no_hidden, no_os_protected}; -use sd_core_indexer_rules::IndexerRule; -use sd_core_prisma_helpers::{file_path_with_object, object_with_file_paths}; -use sd_file_ext::kind::ObjectKind; -use sd_prisma::prisma::{self, location, PrismaClient}; -use sd_utils::chain_optional_iter; +use sd_prisma::prisma::{self, PrismaClient}; + +use std::path::PathBuf; use async_stream::stream; use futures::StreamExt; +use itertools::Either; use rspc::{alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; use specta::Type; -use tracing::{error, warn}; pub mod file_path; pub mod media_data; @@ -69,170 +59,98 @@ impl SearchFilterArgs { file_path: &mut Vec, object: &mut Vec, ) -> Result<(), rspc::Error> { - Ok(match self { + match self { Self::FilePath(v) => file_path.extend(v.into_params(db).await?), Self::Object(v) => object.extend(v.into_params()), - }) + }; + Ok(()) } } pub fn mount() -> AlphaRouter { R.router() .procedure("ephemeralPaths", { - #[derive(Deserialize, Type, Debug, PartialEq, Eq)] - #[serde(rename_all = "camelCase")] - enum PathFrom { - Path, - // TODO: FTP + S3 + GDrive + #[derive(Serialize, Deserialize, Type, Debug, Clone)] + #[serde(rename_all = "camelCase", tag = "field", content = "value")] + enum EphemeralPathOrder { + Name(SortOrder), + SizeInBytes(SortOrder), + DateCreated(SortOrder), + DateModified(SortOrder), } #[derive(Deserialize, Type, Debug)] #[serde(rename_all = "camelCase")] struct EphemeralPathSearchArgs { - from: PathFrom, - path: String, + path: PathBuf, with_hidden_files: bool, + #[specta(optional)] + order: Option, } - #[derive(Serialize, Type, Debug)] struct EphemeralPathsResultItem { pub entries: Vec>, - pub errors: Vec, + pub errors: Vec, pub nodes: Vec, } R.with2(library()).subscription( |(node, library), EphemeralPathSearchArgs { - from, - mut path, + path, with_hidden_files, + order, }| async move { - let service = match from { - PathFrom::Path => { - let mut fs = Fs::default(); - fs.root("/"); - Operator::new(fs) - .map_err(|err| { - rspc::Error::new( - ErrorCode::InternalServerError, - err.to_string(), - ) - })? - .finish() - } - }; + let paths = + non_indexed::walk(path, with_hidden_files, node, library, |entries| { + macro_rules! order_match { + ($order:ident, [$(($variant:ident, |$i:ident| $func:expr)),+]) => {{ + match $order { + $(EphemeralPathOrder::$variant(order) => { + entries.sort_unstable_by(|path1, path2| { + let func = |$i: &non_indexed::Entry| $func; - let rules = chain_optional_iter( - [IndexerRule::from(no_os_protected())], - [(!with_hidden_files).then(|| IndexerRule::from(no_hidden()))], - ); + let one = func(path1); + let two = func(path2); - // OpenDAL is specific about paths (and the rest of Spacedrive is not) - if !path.ends_with('/') { - path.push('/'); - } + match order { + SortOrder::Desc => two.cmp(&one), + SortOrder::Asc => one.cmp(&two), + } + }); + })+ + } + }}; + } - let stream = - sd_indexer::ephemeral(service, rules, &path) - .await - .map_err(|err| { - rspc::Error::new(ErrorCode::InternalServerError, err.to_string()) - })?; + if let Some(order) = order { + order_match!( + order, + [ + (Name, |p| p.name().to_lowercase()), + (SizeInBytes, |p| p.size_in_bytes()), + (DateCreated, |p| p.date_created()), + (DateModified, |p| p.date_modified()) + ] + ) + } + }) + .await?; - let mut stream = BatchedStream::new(stream); + let mut stream = BatchedStream::new(paths); Ok(unsafe_streamed_query(stream! { - let mut to_generate = vec![]; - while let Some(result) = stream.next().await { // We optimize for the case of no errors because it should be way more common. let mut entries = Vec::with_capacity(result.len()); let mut errors = Vec::with_capacity(0); - // For this batch we check if any directories are actually locations, so the UI can link directly to them - let locations = library - .db - .location() - .find_many(vec![location::path::in_vec( - result.iter().filter_map(|e| match e { - Ok(e) if ObjectKind::from_i32(e.kind) == ObjectKind::Folder => Some(e.path.clone()), - _ => None - }).collect::>() - )]) - .exec() - .await - .and_then(|l| { - Ok(l.into_iter() - .filter_map(|item| item.path.clone().map(|l| (l, item))) - .collect::>()) - }) - .map_err(|err| error!("Looking up locations failed: {err:?}")) - .unwrap_or_default(); - for item in result { match item { - Ok(item) => { - let kind = ObjectKind::from_i32(item.kind); - let should_generate_thumbnail = { - #[cfg(feature = "ffmpeg")] - { - matches!( - kind, - ObjectKind::Image | ObjectKind::Video | ObjectKind::Document - ) - } - - #[cfg(not(feature = "ffmpeg"))] - { - matches!(kind, ObjectKind::Image | ObjectKind::Document) - } - }; - - // TODO: This requires all paths to be loaded before thumbnailing starts. - // TODO: This copies the existing functionality but will not fly with Cloud locations (as loading paths will be *way* slower) - // TODO: https://linear.app/spacedriveapp/issue/ENG-1719/cloud-thumbnailer - let thumbnail = if should_generate_thumbnail { - if from == PathFrom::Path { - let size = u64::from_be_bytes((&*item.size_in_bytes_bytes).try_into().expect("Invalid size")); - if let Ok(cas_id) = generate_cas_id(&item.path, size).await.map_err(|err| error!("Error generating cas id for '{:?}': {err:?}", item.path)) { - if ObjectKind::from_i32(item.kind) == ObjectKind::Document { - to_generate.push(GenerateThumbnailArgs::new( - item.extension.clone(), - cas_id.clone(), - PathBuf::from(&item.path), - )); - } else { - to_generate.push(GenerateThumbnailArgs::new( - item.extension.clone(), - cas_id.clone(), - PathBuf::from(&item.path), - )); - } - - Some(get_ephemeral_thumb_key(&cas_id)) - } else { - None - } - } else { - warn!("Thumbnailer not supported for cloud locations"); - None - } - } else { - None - }; - - entries.push(if let Some(item) = locations.get(&item.path) { - ExplorerItem::Location { - item: item.clone(), - } - } else { - ExplorerItem::NonIndexedPath { - thumbnail, - item, - } - }); + Ok(item) => entries.push(item), + Err(e) => match e { + Either::Left(e) => errors.push(e), + Either::Right(e) => errors.push(e.into()), }, - Err(e) => errors.push(e.to_string()), } } @@ -244,16 +162,6 @@ pub fn mount() -> AlphaRouter { nodes, }; } - - if to_generate.len() > 0 { - node.thumbnailer - .new_ephemeral_thumbnails_batch(BatchToProcess::new( - to_generate, - false, - false, - )) - .await; - } })) }, ) @@ -289,7 +197,9 @@ pub fn mount() -> AlphaRouter { let params = { let (mut fp, obj) = merge_filters(filters, db).await?; - fp.push(prisma::file_path::object::is(obj)); + if !obj.is_empty() { + fp.push(prisma::file_path::object::is(obj)); + } fp }; @@ -319,7 +229,7 @@ pub fn mount() -> AlphaRouter { let mut items = Vec::with_capacity(file_paths.len()); for file_path in file_paths { - let thumbnail_exists_locally = if let Some(cas_id) = &file_path.cas_id { + let has_created_thumbnail = if let Some(cas_id) = &file_path.cas_id { library .thumbnail_exists(&node, cas_id) .await @@ -332,8 +242,9 @@ pub fn mount() -> AlphaRouter { thumbnail: file_path .cas_id .as_ref() - .filter(|_| thumbnail_exists_locally) + // .filter(|_| thumbnail_exists_locally) .map(|i| get_indexed_thumb_key(i, library.id)), + has_created_thumbnail, item: file_path, }) } @@ -366,7 +277,9 @@ pub fn mount() -> AlphaRouter { .count({ let (mut fp, obj) = merge_filters(filters, db).await?; - fp.push(prisma::file_path::object::is(obj)); + if !obj.is_empty() { + fp.push(prisma::file_path::object::is(obj)); + } fp }) @@ -401,7 +314,9 @@ pub fn mount() -> AlphaRouter { .find_many({ let (fp, mut obj) = merge_filters(filters, db).await?; - obj.push(prisma::object::file_paths::some(fp)); + if !fp.is_empty() { + obj.push(prisma::object::file_paths::some(fp)); + } obj }) @@ -434,7 +349,7 @@ pub fn mount() -> AlphaRouter { .map(|fp| fp.cas_id.as_ref()) .find_map(|c| c); - let thumbnail_exists_locally = if let Some(cas_id) = cas_id { + let has_created_thumbnail = if let Some(cas_id) = cas_id { library.thumbnail_exists(&node, cas_id).await.map_err(|e| { rspc::Error::with_cause( ErrorCode::InternalServerError, @@ -448,9 +363,10 @@ pub fn mount() -> AlphaRouter { items.push(ExplorerItem::Object { thumbnail: cas_id - .filter(|_| thumbnail_exists_locally) + // .filter(|_| thumbnail_exists_locally) .map(|cas_id| get_indexed_thumb_key(cas_id, library.id)), item: object, + has_created_thumbnail, }); } @@ -482,7 +398,9 @@ pub fn mount() -> AlphaRouter { .count({ let (fp, mut obj) = merge_filters(filters, db).await?; - obj.push(prisma::object::file_paths::some(fp)); + if !fp.is_empty() { + obj.push(prisma::object::file_paths::some(fp)); + } obj }) diff --git a/core/src/cloud/sync/ingest.rs b/core/src/cloud/sync/ingest.rs index 1d08db285..dda8dc336 100644 --- a/core/src/cloud/sync/ingest.rs +++ b/core/src/cloud/sync/ingest.rs @@ -1,3 +1,4 @@ +use sd_sync::CompressedCRDTOperations; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -68,7 +69,7 @@ pub async fn run_actor( .send(sd_core_sync::Event::Messages(MessagesEvent { instance_id: sync.instance, has_more: ops.len() == OPS_PER_REQUEST as usize, - messages: ops, + messages: CompressedCRDTOperations::new(ops), })) .await ); diff --git a/core/src/lib.rs b/core/src/lib.rs index 3a3306545..17213e723 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -64,7 +64,6 @@ pub struct Node { pub event_bus: (broadcast::Sender, broadcast::Receiver), pub notifications: Notifications, pub thumbnailer: OldThumbnailer, - pub files_over_p2p_flag: Arc, pub cloud_sync_flag: Arc, pub env: Arc, pub http: reqwest::Client, @@ -135,7 +134,6 @@ impl Node { config, event_bus, libraries, - files_over_p2p_flag: Arc::new(AtomicBool::new(false)), cloud_sync_flag: Arc::new(AtomicBool::new(false)), http: reqwest::Client::new(), env, diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index f99418ce9..cb27fad56 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -14,7 +14,6 @@ use sd_core_file_path_helper::{ }; use sd_core_prisma_helpers::location_with_indexer_rules; -use sd_indexer::path::normalize_path; use sd_prisma::{ prisma::{file_path, indexer_rules_in_location, location, PrismaClient}, prisma_sync, @@ -23,17 +22,18 @@ use sd_sync::*; use sd_utils::{ db::{maybe_missing, MissingFieldError}, error::{FileIOError, NonUtf8PathError}, - msgpack, uuid_to_bytes, + msgpack, }; use std::{ collections::HashSet, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::Arc, }; use chrono::Utc; use futures::future::TryFutureExt; +use normpath::PathExt; use prisma_client_rust::{operator::and, or, QueryError}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -46,6 +46,7 @@ mod error; pub mod indexer; mod manager; pub mod metadata; +pub mod non_indexed; pub use error::LocationError; use indexer::OldIndexerJobInit; @@ -653,6 +654,57 @@ pub struct CreatedLocationResult { pub data: location_with_indexer_rules::Data, } +pub(crate) fn normalize_path(path: impl AsRef) -> io::Result<(String, String)> { + let mut path = path.as_ref().to_path_buf(); + let (location_path, normalized_path) = path + // Normalize path and also check if it exists + .normalize() + .and_then(|normalized_path| { + if cfg!(windows) { + // Use normalized path as main path on Windows + // This ensures we always receive a valid windows formatted path + // ex: /Users/JohnDoe/Downloads will become C:\Users\JohnDoe\Downloads + // Internally `normalize` calls `GetFullPathNameW` on Windows + // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfullpathnamew + path = normalized_path.as_path().to_path_buf(); + } + + Ok(( + // TODO: Maybe save the path bytes instead of the string representation to avoid depending on UTF-8 + path.to_str().map(str::to_string).ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Found non-UTF-8 path", + ))?, + normalized_path, + )) + })?; + + // Not needed on Windows because the normalization already handles it + if cfg!(not(windows)) { + // Replace location_path with normalize_path, when the first one ends in `.` or `..` + // This is required so localize_name doesn't panic + if let Some(component) = path.components().next_back() { + if matches!(component, Component::CurDir | Component::ParentDir) { + path = normalized_path.as_path().to_path_buf(); + } + } + } + + // Use `to_string_lossy` because a partially corrupted but identifiable name is better than nothing + let mut name = path.localize_name().to_string_lossy().to_string(); + + // Windows doesn't have a root directory + if cfg!(not(windows)) && name == "/" { + name = "Root".to_string() + } + + if name.replace(char::REPLACEMENT_CHARACTER, "") == "" { + name = "Unknown".to_string() + } + + Ok((location_path, name)) +} + async fn create_location( library @ Library { db, sync, .. }: &Library, location_pub_id: Uuid, diff --git a/core/src/location/non_indexed.rs b/core/src/location/non_indexed.rs new file mode 100644 index 000000000..c4ace38cc --- /dev/null +++ b/core/src/location/non_indexed.rs @@ -0,0 +1,385 @@ +use crate::{ + api::locations::ExplorerItem, + library::Library, + object::{ + cas::generate_cas_id, + media::old_thumbnail::{get_ephemeral_thumb_key, BatchToProcess, GenerateThumbnailArgs}, + }, + Node, +}; + +use sd_core_file_path_helper::{path_is_hidden, MetadataExt}; +use sd_core_indexer_rules::{ + seed::{no_hidden, no_os_protected}, + IndexerRule, RuleKind, +}; + +use sd_file_ext::{extensions::Extension, kind::ObjectKind}; +use sd_prisma::prisma::location; +use sd_utils::{chain_optional_iter, error::FileIOError}; + +use std::{ + collections::HashMap, + io::ErrorKind, + path::{Path, PathBuf}, + sync::Arc, +}; + +use chrono::{DateTime, Utc}; +use futures::Stream; +use itertools::Either; +use rspc::ErrorCode; +use serde::Serialize; +use specta::Type; +use thiserror::Error; +use tokio::{io, sync::mpsc, task::JoinError}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{error, span, warn, Level}; + +use super::normalize_path; + +#[derive(Debug, Error)] +pub enum NonIndexedLocationError { + #[error("path not found: {}", .0.display())] + NotFound(PathBuf), + + #[error(transparent)] + FileIO(#[from] FileIOError), + + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), + + #[error("error joining tokio task: {0}")] + TaskJoinError(#[from] JoinError), + + #[error("receiver shutdown error")] + SendError, +} + +impl From> for NonIndexedLocationError { + fn from(_: mpsc::error::SendError) -> Self { + Self::SendError + } +} + +impl From for rspc::Error { + fn from(err: NonIndexedLocationError) -> Self { + match err { + NonIndexedLocationError::NotFound(_) => { + rspc::Error::with_cause(ErrorCode::NotFound, err.to_string(), err) + } + _ => rspc::Error::with_cause(ErrorCode::InternalServerError, err.to_string(), err), + } + } +} + +impl> From<(P, io::Error)> for NonIndexedLocationError { + fn from((path, source): (P, io::Error)) -> Self { + if source.kind() == io::ErrorKind::NotFound { + Self::NotFound(path.as_ref().into()) + } else { + Self::FileIO(FileIOError::from((path, source))) + } + } +} + +#[derive(Serialize, Type, Debug)] +pub struct NonIndexedPathItem { + pub path: String, + pub name: String, + pub extension: String, + pub kind: i32, + pub is_dir: bool, + pub date_created: DateTime, + pub date_modified: DateTime, + pub size_in_bytes_bytes: Vec, + pub hidden: bool, +} + +// #[instrument(name = "non_indexed::walk", skip(sort_fn))] +pub async fn walk( + path: PathBuf, + with_hidden_files: bool, + node: Arc, + library: Arc, + sort_fn: impl FnOnce(&mut Vec) + Send, +) -> Result< + impl Stream>> + Send, + NonIndexedLocationError, +> { + let mut entries = get_all_entries(path.clone()).await?; + + { + let span = span!(Level::INFO, "sort_fn"); + let _enter = span.enter(); + + sort_fn(&mut entries); + } + + let (tx, rx) = mpsc::channel(128); + let tx2 = tx.clone(); + + // We wanna process and let the caller use the stream. + let task = tokio::spawn(async move { + let path = &path; + let rules = chain_optional_iter( + [IndexerRule::from(no_os_protected())], + [(!with_hidden_files).then(|| IndexerRule::from(no_hidden()))], + ); + + let mut thumbnails_to_generate = vec![]; + // Generating thumbnails for PDFs is kinda slow, so we're leaving them for last in the batch + let mut document_thumbnails_to_generate = vec![]; + let mut directories = vec![]; + + for entry in entries.into_iter() { + let (entry_path, name) = match normalize_path(entry.path) { + Ok(v) => v, + Err(e) => { + tx.send(Err(Either::Left( + NonIndexedLocationError::from((path, e)).into(), + ))) + .await?; + continue; + } + }; + + match IndexerRule::apply_all(&rules, &entry_path).await { + Ok(rule_results) => { + // No OS Protected and No Hidden rules, must always be from this kind, should panic otherwise + if rule_results[&RuleKind::RejectFilesByGlob] + .iter() + .any(|reject| !reject) + { + continue; + } + } + Err(e) => { + tx.send(Err(Either::Left(e.into()))).await?; + continue; + } + }; + + if entry.metadata.is_dir() { + directories.push((entry_path, name, entry.metadata)); + } else { + let path = Path::new(&entry_path); + + let Some(name) = path + .file_stem() + .and_then(|s| s.to_str().map(str::to_string)) + else { + warn!("Failed to extract name from path: {}", &entry_path); + continue; + }; + + let extension = path + .extension() + .and_then(|s| s.to_str().map(str::to_string)) + .unwrap_or_default(); + + let kind = Extension::resolve_conflicting(&path, false) + .await + .map(Into::into) + .unwrap_or(ObjectKind::Unknown); + + let should_generate_thumbnail = { + #[cfg(feature = "ffmpeg")] + { + matches!( + kind, + ObjectKind::Image | ObjectKind::Video | ObjectKind::Document + ) + } + + #[cfg(not(feature = "ffmpeg"))] + { + matches!(kind, ObjectKind::Image | ObjectKind::Document) + } + }; + + let thumbnail_key = if should_generate_thumbnail { + if let Ok(cas_id) = + generate_cas_id(&path, entry.metadata.len()) + .await + .map_err(|e| { + tx.send(Err(Either::Left( + NonIndexedLocationError::from((path, e)).into(), + ))) + }) { + if kind == ObjectKind::Document { + document_thumbnails_to_generate.push(GenerateThumbnailArgs::new( + extension.clone(), + cas_id.clone(), + path.to_path_buf(), + )); + } else { + thumbnails_to_generate.push(GenerateThumbnailArgs::new( + extension.clone(), + cas_id.clone(), + path.to_path_buf(), + )); + } + + Some(get_ephemeral_thumb_key(&cas_id)) + } else { + None + } + } else { + None + }; + + tx.send(Ok(ExplorerItem::NonIndexedPath { + thumbnail: thumbnail_key, + item: NonIndexedPathItem { + hidden: path_is_hidden(Path::new(&entry_path), &entry.metadata), + path: entry_path, + name, + extension, + kind: kind as i32, + is_dir: false, + date_created: entry.metadata.created_or_now().into(), + date_modified: entry.metadata.modified_or_now().into(), + size_in_bytes_bytes: entry.metadata.len().to_be_bytes().to_vec(), + }, + has_created_thumbnail: false, + })) + .await?; + } + } + + thumbnails_to_generate.extend(document_thumbnails_to_generate); + + node.thumbnailer + .new_ephemeral_thumbnails_batch(BatchToProcess::new( + thumbnails_to_generate, + false, + false, + )) + .await; + + let mut locations = library + .db + .location() + .find_many(vec![location::path::in_vec( + directories + .iter() + .map(|(path, _, _)| path.clone()) + .collect(), + )]) + .exec() + .await? + .into_iter() + .flat_map(|location| { + location + .path + .clone() + .map(|location_path| (location_path, location)) + }) + .collect::>(); + + for (directory, name, metadata) in directories { + if let Some(location) = locations.remove(&directory) { + tx.send(Ok(ExplorerItem::Location { item: location })) + .await?; + } else { + tx.send(Ok(ExplorerItem::NonIndexedPath { + thumbnail: None, + item: NonIndexedPathItem { + hidden: path_is_hidden(Path::new(&directory), &metadata), + path: directory, + name, + extension: String::new(), + kind: ObjectKind::Folder as i32, + is_dir: true, + date_created: metadata.created_or_now().into(), + date_modified: metadata.modified_or_now().into(), + size_in_bytes_bytes: metadata.len().to_be_bytes().to_vec(), + }, + has_created_thumbnail: false, + })) + .await?; + } + } + + Ok::<_, NonIndexedLocationError>(()) + }); + + tokio::spawn(async move { + match task.await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + let _ = tx2.send(Err(Either::Left(e.into()))).await; + } + Err(e) => error!("error joining tokio task: {}", e), + } + }); + + Ok(ReceiverStream::new(rx)) +} + +#[derive(Debug)] +pub struct Entry { + path: PathBuf, + name: String, + // size_in_bytes: u64, + // date_created: + metadata: std::fs::Metadata, +} + +impl Entry { + pub fn name(&self) -> &str { + &self.name + } + + pub fn size_in_bytes(&self) -> u64 { + self.metadata.len() + } + + pub fn date_created(&self) -> DateTime { + self.metadata.created_or_now().into() + } + + pub fn date_modified(&self) -> DateTime { + self.metadata.modified_or_now().into() + } +} + +/// We get all of the FS entries first before we start processing on each of them. +/// +/// From my M1 Macbook Pro this: +/// - takes 11ms per 10 000 files +/// and +/// - consumes 0.16MB of RAM per 10 000 entries. +/// +/// The reason we collect these all up is so we can apply ordering, and then begin streaming the data as it's processed to the frontend. +// #[instrument(name = "get_all_entries")] +pub async fn get_all_entries(path: PathBuf) -> Result, NonIndexedLocationError> { + tokio::task::spawn_blocking(move || { + let path = &path; + let dir = std::fs::read_dir(path).map_err(|e| (path, e))?; + let mut entries = Vec::new(); + for entry in dir { + let entry = entry.map_err(|e| (path, e))?; + + // We must not keep `entry` around as we will quickly hit the OS limit on open file descriptors + entries.push(Entry { + path: entry.path(), + name: entry + .file_name() + .to_str() + .ok_or_else(|| { + ( + path, + io::Error::new(ErrorKind::Other, "error non UTF-8 path"), + ) + })? + .to_string(), + metadata: entry.metadata().map_err(|e| (path, e))?, + }); + } + + Ok(entries) + }) + .await? +} diff --git a/core/src/node/config.rs b/core/src/node/config.rs index 4518a38f8..25e225af7 100644 --- a/core/src/node/config.rs +++ b/core/src/node/config.rs @@ -37,20 +37,64 @@ pub enum P2PDiscoveryState { } #[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Type)] -#[serde(rename_all = "snake_case", untagged)] +#[serde(rename_all = "snake_case", tag = "type", content = "value")] pub enum Port { - Disabled, #[default] Random, Discrete(u16), } impl Port { + pub fn get(&self) -> u16 { + match self { + Port::Random => 0, + Port::Discrete(port) => *port, + } + } + pub fn is_random(&self) -> bool { matches!(self, Port::Random) } } +fn default_as_true() -> bool { + true +} + +fn skip_if_true(value: &bool) -> bool { + *value +} + +fn skip_if_false(value: &bool) -> bool { + !*value +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct NodeConfigP2P { + #[serde(default)] + pub discovery: P2PDiscoveryState, + #[serde(default, skip_serializing_if = "Port::is_random")] + pub port: Port, + #[serde(default = "default_as_true", skip_serializing_if = "skip_if_true")] + pub ipv4: bool, + #[serde(default = "default_as_true", skip_serializing_if = "skip_if_true")] + pub ipv6: bool, + #[serde(default, skip_serializing_if = "skip_if_false")] + pub remote_access: bool, +} + +impl Default for NodeConfigP2P { + fn default() -> Self { + Self { + discovery: P2PDiscoveryState::Everyone, + port: Port::Random, + ipv4: true, + ipv6: true, + remote_access: false, + } + } +} + /// NodeConfig is the configuration for a node. This is shared between all libraries and is stored in a JSON file on disk. #[derive(Debug, Clone, Serialize, Deserialize)] // If you are adding `specta::Type` on this your probably about to leak the P2P private key pub struct NodeConfig { @@ -66,12 +110,8 @@ pub struct NodeConfig { #[serde(with = "identity_serde")] pub identity: Identity, /// P2P config - #[serde(default, skip_serializing_if = "Port::is_random")] - pub p2p_ipv4_port: Port, - #[serde(default, skip_serializing_if = "Port::is_random")] - pub p2p_ipv6_port: Port, #[serde(default)] - pub p2p_discovery: P2PDiscoveryState, + pub p2p: NodeConfigP2P, /// Feature flags enabled on the node #[serde(default)] pub features: Vec, @@ -153,9 +193,7 @@ impl ManagedVersion for NodeConfig { id: Uuid::new_v4(), name, identity: Identity::default(), - p2p_ipv4_port: Port::Random, - p2p_ipv6_port: Port::Random, - p2p_discovery: P2PDiscoveryState::Everyone, + p2p: NodeConfigP2P::default(), version: Self::LATEST_VERSION, features: vec![], notifications: vec![], diff --git a/core/src/object/media/old_thumbnail/process.rs b/core/src/object/media/old_thumbnail/process.rs index 277f64700..000f368ef 100644 --- a/core/src/object/media/old_thumbnail/process.rs +++ b/core/src/object/media/old_thumbnail/process.rs @@ -373,7 +373,12 @@ pub(super) async fn generate_thumbnail( } } } - + // This if is REALLY needed, due to the sheer performance of the thumbnailer, + // I restricted to only send events notifying for thumbnails in the current + // opened directory, sending events for the entire location turns into a + // humongous bottleneck in the frontend lol, since it doesn't even knows + // what to do with thumbnails for inner directories lol + // - fogodev if !in_background { trace!("Emitting new thumbnail event"); if reporter diff --git a/core/src/object/old_file_identifier/old_file_identifier_job.rs b/core/src/object/old_file_identifier/old_file_identifier_job.rs index 3a1afa3f6..69494b3fd 100644 --- a/core/src/object/old_file_identifier/old_file_identifier_job.rs +++ b/core/src/object/old_file_identifier/old_file_identifier_job.rs @@ -1,4 +1,5 @@ use crate::{ + api::CoreEvent, library::Library, location::ScanState, old_job::{ @@ -226,6 +227,11 @@ impl StatefulJob for OldFileIdentifierJobInit { new_metadata.total_objects_linked = total_objects_linked; new_metadata.cursor = new_cursor; + // send an array of ids to let clients know new objects were identified + ctx.node.emit(CoreEvent::NewIdentifiedObjects { + file_path_ids: file_paths.iter().map(|fp| fp.id).collect(), + }); + ctx.progress(vec![ JobReportUpdate::CompletedTaskCount(step_number * CHUNK_SIZE + file_paths.len()), JobReportUpdate::Message(format!( diff --git a/core/src/p2p/manager.rs b/core/src/p2p/manager.rs index 36f2c0163..8bffca74c 100644 --- a/core/src/p2p/manager.rs +++ b/core/src/p2p/manager.rs @@ -1,6 +1,6 @@ use crate::{ node::{ - config::{self, P2PDiscoveryState, Port}, + config::{self, P2PDiscoveryState}, get_hardware_model_name, HardwareModel, }, p2p::{ @@ -14,17 +14,14 @@ use axum::routing::IntoMakeService; use sd_p2p::{ flume::{bounded, Receiver}, - HookId, Libp2pPeerId, Listener, Mdns, Peer, QuicTransport, RelayServerEntry, RemoteIdentity, + HookId, Libp2pPeerId, Mdns, Peer, QuicTransport, RelayServerEntry, RemoteIdentity, UnicastStream, P2P, }; use sd_p2p_tunnel::Tunnel; -use serde::Serialize; use serde_json::json; -use specta::Type; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, convert::Infallible, - net::SocketAddr, sync::{atomic::AtomicBool, Arc, Mutex, PoisonError}, time::Duration, }; @@ -37,6 +34,12 @@ use uuid::Uuid; use super::{P2PEvents, PeerMetadata}; +#[derive(Default)] +pub struct ListenerErrors { + pub ipv4: Option, + pub ipv6: Option, +} + pub struct P2PManager { pub(crate) p2p: Arc, mdns: Mutex>, @@ -48,6 +51,7 @@ pub struct P2PManager { pub(super) spacedrop_cancellations: Arc>>>, pub(crate) node_config: Arc, pub libraries_hook_id: HookId, + pub listener_errors: Mutex, } impl P2PManager { @@ -75,6 +79,7 @@ impl P2PManager { spacedrop_cancellations: Default::default(), node_config, libraries_hook_id, + listener_errors: Default::default(), }); this.on_node_config_change().await; @@ -141,35 +146,45 @@ impl P2PManager { } .update(&mut self.p2p.metadata_mut()); - let port = match config.p2p_ipv4_port { - Port::Disabled => None, - Port::Random => Some(0), - Port::Discrete(port) => Some(port), - }; - info!("Setting quic ipv4 listener to: {port:?}"); - if let Err(err) = self.quic.set_ipv4_enabled(port).await { + let port = config.p2p.port.get(); + + info!( + "Setting quic ipv4 listener to: {:?}", + config.p2p.ipv4.then_some(port) + ); + if let Err(err) = self + .quic + .set_ipv4_enabled(config.p2p.ipv4.then_some(port)) + .await + { error!("Failed to enabled quic ipv4 listener: {err}"); - self.node_config - .write(|c| c.p2p_ipv4_port = Port::Disabled) - .await - .ok(); + self.node_config.write(|c| c.p2p.ipv4 = false).await.ok(); + + self.listener_errors + .lock() + .unwrap_or_else(PoisonError::into_inner) + .ipv4 = Some(err.to_string()); } - let port = match config.p2p_ipv6_port { - Port::Disabled => None, - Port::Random => Some(0), - Port::Discrete(port) => Some(port), - }; - info!("Setting quic ipv4 listener to: {port:?}"); - if let Err(err) = self.quic.set_ipv6_enabled(port).await { + info!( + "Setting quic ipv6 listener to: {:?}", + config.p2p.ipv6.then_some(port) + ); + if let Err(err) = self + .quic + .set_ipv6_enabled(config.p2p.ipv6.then_some(port)) + .await + { error!("Failed to enabled quic ipv6 listener: {err}"); - self.node_config - .write(|c| c.p2p_ipv6_port = Port::Disabled) - .await - .ok(); + self.node_config.write(|c| c.p2p.ipv6 = false).await.ok(); + + self.listener_errors + .lock() + .unwrap_or_else(PoisonError::into_inner) + .ipv6 = Some(err.to_string()); } - let should_revert = match config.p2p_discovery { + let should_revert = match config.p2p.discovery { P2PDiscoveryState::Everyone // TODO: Make `ContactsOnly` work | P2PDiscoveryState::ContactsOnly => { @@ -210,7 +225,7 @@ impl P2PManager { if should_revert { let _ = self .node_config - .write(|c| c.p2p_discovery = P2PDiscoveryState::Disabled) + .write(|c| c.p2p.discovery = P2PDiscoveryState::Disabled) .await; } } @@ -255,11 +270,7 @@ impl P2PManager { "name": name, "listener_addrs": listeners.iter().find(|l| l.is_hook_id(*id)).map(|l| l.addrs.clone()), })).collect::>(), - "config": json!({ - "p2p_ipv4_port": node_config.p2p_ipv4_port, - "p2p_ipv6_port": node_config.p2p_ipv6_port, - "p2p_discovery": node_config.p2p_discovery, - }), + "config": node_config.p2p, "relay_config": self.quic.get_relay_config(), }) } @@ -282,8 +293,6 @@ async fn start( let mut service = unwrap_infallible(service.call(()).await); tokio::spawn(async move { - println!("APPLICATION GOT STREAM: {:?}", stream); // TODO - let Ok(header) = Header::from_stream(&mut stream).await.map_err(|err| { error!("Failed to read header from stream: {}", err); }) else { @@ -337,7 +346,8 @@ async fn start( } Header::Http => { let remote = stream.remote_identity(); - let Err(err) = operations::rspc::receiver(stream, &mut service).await else { + let Err(err) = operations::rspc::receiver(stream, &mut service, &node).await + else { return; }; @@ -350,23 +360,6 @@ async fn start( Ok::<_, ()>(()) } -#[derive(Debug, Serialize, Type)] -pub struct Listener2 { - pub id: String, - pub name: &'static str, - pub addrs: HashSet, -} - -pub fn into_listener2(l: &[Listener]) -> Vec { - l.iter() - .map(|l| Listener2 { - id: format!("{:?}", l.id), - name: l.name, - addrs: l.addrs.clone(), - }) - .collect() -} - fn unwrap_infallible(result: Result) -> T { match result { Ok(value) => value, diff --git a/core/src/p2p/operations/rspc.rs b/core/src/p2p/operations/rspc.rs index d6823eed1..25396c0bd 100644 --- a/core/src/p2p/operations/rspc.rs +++ b/core/src/p2p/operations/rspc.rs @@ -6,7 +6,7 @@ use sd_p2p::{RemoteIdentity, UnicastStream, P2P}; use tokio::io::AsyncWriteExt; use tracing::debug; -use crate::p2p::Header; +use crate::{p2p::Header, Node}; /// Transfer an rspc query to a remote node. #[allow(unused)] @@ -37,6 +37,7 @@ pub async fn remote_rspc( pub(crate) async fn receiver( stream: UnicastStream, service: &mut Router, + node: &Node, ) -> Result<(), Box> { debug!( "Received http request from peer '{}'", @@ -45,8 +46,8 @@ pub(crate) async fn receiver( // TODO: Authentication #[allow(clippy::todo)] - if true { - todo!("You wouldn't download a car!"); + if node.config.get().await.p2p.remote_access { + todo!("No way buddy!"); } Http::new() diff --git a/core/src/p2p/operations/spacedrop.rs b/core/src/p2p/operations/spacedrop.rs index c4df1dd32..8c2d24a3a 100644 --- a/core/src/p2p/operations/spacedrop.rs +++ b/core/src/p2p/operations/spacedrop.rs @@ -85,7 +85,7 @@ pub async fn spacedrop( debug!("({id}): connected, sending header"); let header = Header::Spacedrop(SpaceblockRequests { id, - block_size: BlockSize::from_size(total_length), + block_size: BlockSize::from_file_size(total_length), requests, }); if let Err(err) = stream.write_all(&header.to_bytes()).await { diff --git a/core/src/p2p/sync/mod.rs b/core/src/p2p/sync/mod.rs index 0f2b0d9e6..e4cc31f07 100644 --- a/core/src/p2p/sync/mod.rs +++ b/core/src/p2p/sync/mod.rs @@ -6,7 +6,7 @@ use crate::{ }; use sd_p2p_proto::{decode, encode}; -use sd_sync::CRDTOperation; +use sd_sync::CompressedCRDTOperations; use std::sync::Arc; @@ -28,10 +28,11 @@ mod originator { use sd_p2p_tunnel::Tunnel; pub mod tx { + use super::*; #[derive(Debug, PartialEq)] - pub struct Operations(pub Vec); + pub struct Operations(pub CompressedCRDTOperations); impl Operations { // TODO: Per field errors for better error handling @@ -56,8 +57,10 @@ mod originator { #[cfg(test)] #[tokio::test] async fn test() { + use sd_sync::CRDTOperation; + { - let original = Operations(vec![]); + let original = Operations(CompressedCRDTOperations::new(vec![])); let mut cursor = std::io::Cursor::new(original.to_bytes()); let result = Operations::from_stream(&mut cursor).await.unwrap(); @@ -65,13 +68,13 @@ mod originator { } { - let original = Operations(vec![CRDTOperation { + let original = Operations(CompressedCRDTOperations::new(vec![CRDTOperation { instance: Uuid::new_v4(), timestamp: sync::NTP64(0), record_id: rmpv::Value::Nil, model: 0, data: sd_sync::CRDTOperationData::create(), - }]); + }])); let mut cursor = std::io::Cursor::new(original.to_bytes()); let result = Operations::from_stream(&mut cursor).await.unwrap(); @@ -115,7 +118,7 @@ mod originator { let ops = sync.get_ops(args).await.unwrap(); tunnel - .write_all(&tx::Operations(ops).to_bytes()) + .write_all(&tx::Operations(CompressedCRDTOperations::new(ops)).to_bytes()) .await .unwrap(); tunnel.flush().await.unwrap(); diff --git a/core/src/util/batched_stream.rs b/core/src/util/batched_stream.rs index d39cc7152..1cd350840 100644 --- a/core/src/util/batched_stream.rs +++ b/core/src/util/batched_stream.rs @@ -31,7 +31,7 @@ impl BatchedStream { } } -impl Stream for BatchedStream { +impl Stream for BatchedStream { type Item = Vec; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { diff --git a/core/src/util/unsafe_streamed_query.rs b/core/src/util/unsafe_streamed_query.rs index 8d7f2dbd5..8957097d8 100644 --- a/core/src/util/unsafe_streamed_query.rs +++ b/core/src/util/unsafe_streamed_query.rs @@ -1,8 +1,9 @@ +use std::pin::pin; + use async_stream::stream; use futures::{Stream, StreamExt}; use serde::Serialize; use specta::{reference::Reference, DataType, Type, TypeMap}; -use sync_wrapper::SyncStream; #[derive(Serialize)] #[serde(untagged)] @@ -26,18 +27,13 @@ impl Type for Output { } // Marked as unsafe as the types are a lie and this should always be used with `useUnsafeStreamedQuery` -pub fn unsafe_streamed_query( - stream: S, -) -> impl Stream> + Send + Sync -where - S::Item: Send, -{ - SyncStream::new(stream! { - let mut stream = std::pin::pin!(stream); +pub fn unsafe_streamed_query(stream: S) -> impl Stream> { + stream! { + let mut stream = pin!(stream); while let Some(v) = stream.next().await { yield Output::Data(v); } yield Output::Complete { __stream_complete: () }; - }) + } } diff --git a/crates/crypto/src/keyring/linux/secret_service.rs b/crates/crypto/src/keyring/linux/secret_service.rs index 4236174d9..334a52b88 100644 --- a/crates/crypto/src/keyring/linux/secret_service.rs +++ b/crates/crypto/src/keyring/linux/secret_service.rs @@ -33,14 +33,11 @@ impl KeyringInterface for SecretServiceKeyring { } fn contains_key(&self, id: &Identifier) -> bool { - self.get_collection() - .ok() - .map(|k| { - k.search_items(id.as_sec_ser_identifier()) - .ok() - .map_or(false, |x| !x.is_empty()) - }) - .unwrap_or_default() + self.get_collection().ok().is_some_and(|k| { + k.search_items(id.as_sec_ser_identifier()) + .ok() + .map_or(false, |x| !x.is_empty()) + }) } fn get(&self, id: &Identifier) -> Result>> { diff --git a/crates/file-ext/src/kind.rs b/crates/file-ext/src/kind.rs index 2c21a3052..058e206a4 100644 --- a/crates/file-ext/src/kind.rs +++ b/crates/file-ext/src/kind.rs @@ -60,38 +60,3 @@ pub enum ObjectKind { /// Label Label = 26, } - -impl ObjectKind { - pub fn from_i32(value: i32) -> Self { - match value { - 0 => ObjectKind::Unknown, - 1 => ObjectKind::Document, - 2 => ObjectKind::Folder, - 3 => ObjectKind::Text, - 4 => ObjectKind::Package, - 5 => ObjectKind::Image, - 6 => ObjectKind::Audio, - 7 => ObjectKind::Video, - 8 => ObjectKind::Archive, - 9 => ObjectKind::Executable, - 10 => ObjectKind::Alias, - 11 => ObjectKind::Encrypted, - 12 => ObjectKind::Key, - 13 => ObjectKind::Link, - 14 => ObjectKind::WebPageArchive, - 15 => ObjectKind::Widget, - 16 => ObjectKind::Album, - 17 => ObjectKind::Collection, - 18 => ObjectKind::Font, - 19 => ObjectKind::Mesh, - 20 => ObjectKind::Code, - 21 => ObjectKind::Database, - 22 => ObjectKind::Book, - 23 => ObjectKind::Config, - 24 => ObjectKind::Dotfile, - 25 => ObjectKind::Screenshot, - 26 => ObjectKind::Label, - _ => ObjectKind::Unknown, - } - } -} diff --git a/crates/p2p-block/src/block.rs b/crates/p2p-block/src/block.rs index be64f4e3c..12268c822 100644 --- a/crates/p2p-block/src/block.rs +++ b/crates/p2p-block/src/block.rs @@ -58,8 +58,6 @@ impl<'a> Block<'a> { mod tests { use std::io::Cursor; - use crate::BlockSize; - use super::*; #[tokio::test] diff --git a/crates/p2p-block/src/block_size.rs b/crates/p2p-block/src/block_size.rs index 8e92c93e9..0aac54de2 100644 --- a/crates/p2p-block/src/block_size.rs +++ b/crates/p2p-block/src/block_size.rs @@ -1,39 +1,97 @@ +#![allow(non_upper_case_globals)] + use std::io; use tokio::io::{AsyncRead, AsyncReadExt}; -/// TODO +const KiB: u32 = 1024; +const MiB: u32 = 1024 * KiB; +const GiB: u32 = 1024 * MiB; + +/// defines the size of each chunk of data that is sent +/// +/// We store this in an enum so it's super efficient. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BlockSize(u32); // Max block size is gonna be 3.9GB which is stupidly overkill +pub enum BlockSize { + _128KiB, + _256KiB, + _512KiB, + _1MiB, + _2MiB, + _4MiB, + _8MiB, + _16MiB, +} impl BlockSize { - // TODO: Validating `BlockSize` are multiple of 2, i think. Idk why but BEP does it. - - pub async fn from_stream(stream: &mut (impl AsyncRead + Unpin)) -> io::Result { - stream.read_u32_le().await.map(Self) - } - + /// Determine the optimal block size for a given file size #[must_use] - pub fn to_bytes(&self) -> [u8; 4] { - self.0.to_le_bytes() - } - - #[must_use] - pub fn from_size(size: u64) -> Self { - // TODO: Something like: https://docs.syncthing.net/specs/bep-v1.html#selection-of-block-size - Self(131_072) // 128 KiB - } - - /// This is super dangerous as it doesn't enforce any assumptions of the protocol and is designed just for tests. - #[cfg(test)] - #[must_use] - pub fn dangerously_new(size: u32) -> Self { - Self(size) + pub fn from_file_size(size: u64) -> Self { + // Values directly copied from https://docs.syncthing.net/specs/bep-v1.html#selection-of-block-size + if size < 250 * u64::from(MiB) { + return Self::_128KiB; + } else if size < 500 * u64::from(MiB) { + return Self::_256KiB; + } else if size < u64::from(GiB) { + return Self::_512KiB; + } else if size < 2 * u64::from(GiB) { + return Self::_1MiB; + } else if size < 4 * u64::from(GiB) { + return Self::_2MiB; + } else if size < 8 * u64::from(GiB) { + return Self::_4MiB; + } else if size < 16 * u64::from(GiB) { + return Self::_8MiB; + } + Self::_16MiB } + /// Get the size of the block in bytes #[must_use] pub fn size(&self) -> u32 { - self.0 + match self { + Self::_128KiB => 128 * KiB, + Self::_256KiB => 256 * KiB, + Self::_512KiB => 512 * KiB, + Self::_1MiB => MiB, + Self::_2MiB => 2 * MiB, + Self::_4MiB => 4 * MiB, + Self::_8MiB => 8 * MiB, + Self::_16MiB => 16 * MiB, + } + } + + pub async fn from_stream(stream: &mut (impl AsyncRead + Unpin)) -> io::Result { + // WARNING: Be careful modifying this cause it may break backwards/forwards-compatibility + match stream.read_u8().await? { + 0 => Ok(Self::_128KiB), + 1 => Ok(Self::_256KiB), + 2 => Ok(Self::_512KiB), + 3 => Ok(Self::_1MiB), + 4 => Ok(Self::_2MiB), + 5 => Ok(Self::_4MiB), + 6 => Ok(Self::_8MiB), + 7 => Ok(Self::_16MiB), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid block size", + )), + } + } + + #[must_use] + pub fn to_bytes(&self) -> [u8; 1] { + // WARNING: Be careful modifying this cause it may break backwards/forwards-compatibility + [match self { + Self::_128KiB => 0, + Self::_256KiB => 1, + Self::_512KiB => 2, + Self::_1MiB => 3, + Self::_2MiB => 4, + Self::_4MiB => 5, + Self::_8MiB => 6, + Self::_16MiB => 7, + }] } } @@ -45,7 +103,14 @@ mod tests { #[tokio::test] async fn test_block_size() { - let req = BlockSize::dangerously_new(5); + let req = BlockSize::_128KiB; + let bytes = req.to_bytes(); + let req2 = BlockSize::from_stream(&mut Cursor::new(bytes)) + .await + .unwrap(); + assert_eq!(req, req2); + + let req = BlockSize::_16MiB; let bytes = req.to_bytes(); let req2 = BlockSize::from_stream(&mut Cursor::new(bytes)) .await diff --git a/crates/p2p-block/src/lib.rs b/crates/p2p-block/src/lib.rs index 4582769ea..35a1af8ca 100644 --- a/crates/p2p-block/src/lib.rs +++ b/crates/p2p-block/src/lib.rs @@ -1,32 +1,22 @@ -//! TODO -// TODO: Clippy lints here - -//! Spaceblock is a file transfer protocol that uses a block based system to transfer files. -//! This protocol is modelled after `SyncThing`'s BEP protocol. A huge thanks to it's original authors! +//! A protocol for efficiently and securely transferring files between peers. +//! +//! Goals: +//! - Fast - Transfer files as quickly as possible +//! - Safe - Verify the files integrity on both ends +//! +//! This protocol was heavily inspired by SyncThing's Block Exchange Protocol protocol although it's not compatible. //! You can read more about it here: -#![allow(unused)] // TODO: This module is still in heavy development! +//! +#![warn(clippy::unwrap_used, clippy::panic)] use std::{ io, - marker::PhantomData, - path::{Path, PathBuf}, - string::FromUtf8Error, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, + sync::atomic::{AtomicBool, Ordering}, }; -use thiserror::Error; -use tokio::{ - fs::File, - io::{AsyncBufRead, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}, -}; +use tokio::io::{AsyncBufRead, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; -use sd_p2p::UnicastStream; -use sd_p2p_proto::{decode, encode}; - mod block; mod block_size; mod sb_request; @@ -123,9 +113,8 @@ where ); // SAFETY: Percent must be between 0 and 100 if read == 0 { - #[allow(clippy::panic)] // TODO: Remove panic - // The file may have been modified during sender on the sender and we don't account for that. - // TODO: Error handling + send error to remote + // The file may have been modified during sender on the sender and we don't account for that. + // TODO: Error handling + send error to remote assert!( (offset + read as u64) == self.reqs.requests[self.i].size, "File sending has stopped but it doesn't match the expected length!" @@ -236,9 +225,9 @@ where #[cfg(test)] mod tests { - use std::{io::Cursor, mem}; + use std::{io::Cursor, mem, sync::Arc}; - use tokio::sync::oneshot; + use tokio::{io::BufReader, sync::oneshot}; use uuid::Uuid; use super::*; @@ -251,7 +240,7 @@ mod tests { let data = b"Spacedrive".to_vec(); let req = SpaceblockRequests { id: Uuid::new_v4(), - block_size: BlockSize::from_size(data.len() as u64), + block_size: BlockSize::from_file_size(data.len() as u64), requests: vec![SpaceblockRequest { name: "Demo".to_string(), size: data.len() as u64, @@ -287,9 +276,8 @@ mod tests { let (mut client, mut server) = tokio::io::duplex(64); // This is sent out of band of Spaceblock - let block_size = 131_072_u32; - let data = vec![0u8; block_size as usize * 4]; // Let's pacman some RAM - let block_size = BlockSize::dangerously_new(block_size); + let block_size = BlockSize::_128KiB; + let data = vec![0u8; block_size.size() as usize * 4]; // Let's pacman some RAM let req = SpaceblockRequests { id: Uuid::new_v4(), @@ -328,9 +316,8 @@ mod tests { let (mut client, mut server) = tokio::io::duplex(64); // This is sent out of band of Spaceblock - let block_size = 25u32; - let data = vec![0u8; block_size as usize]; - let block_size = BlockSize::dangerously_new(block_size); // TODO: Determine it using proper algo instead of hardcoding it + let block_size = BlockSize::_128KiB; + let data = vec![0u8; block_size.size() as usize]; let req = SpaceblockRequests { id: Uuid::new_v4(), @@ -370,9 +357,8 @@ mod tests { let (mut client, mut server) = tokio::io::duplex(64); // This is sent out of band of Spaceblock - let block_size = 25u32; - let data = vec![0u8; block_size as usize]; - let block_size = BlockSize::dangerously_new(block_size); // TODO: Determine it using proper algo instead of hardcoding it + let block_size = BlockSize::_128KiB; + let data = vec![0u8; block_size.size() as usize]; let req = SpaceblockRequests { id: Uuid::new_v4(), @@ -413,9 +399,8 @@ mod tests { let (mut client, mut server) = tokio::io::duplex(64); // This is sent out of band of Spaceblock - let block_size = 25u32; + let block_size = BlockSize::_128KiB; let data = vec![0u8; 0]; // Zero sized file - let block_size = BlockSize::dangerously_new(block_size); // TODO: Determine it using proper algo instead of hardcoding it let req = SpaceblockRequests { id: Uuid::new_v4(), diff --git a/crates/p2p-block/src/sb_request.rs b/crates/p2p-block/src/sb_request.rs index 81af2d0cc..1384ca5ed 100644 --- a/crates/p2p-block/src/sb_request.rs +++ b/crates/p2p-block/src/sb_request.rs @@ -88,7 +88,7 @@ impl SpaceblockRequests { .map_err(SpaceblockRequestsError::InvalidLen)?; let mut requests = Vec::new(); - for i in 0..size { + for _i in 0..size { requests.push(SpaceblockRequest::from_stream(stream).await?); } @@ -106,7 +106,6 @@ impl SpaceblockRequests { block_size, requests, } = self; - #[allow(clippy::panic)] // TODO: Remove this panic assert!( requests.len() <= 255, "Can't Spacedrop more than 255 files at once!" @@ -167,10 +166,9 @@ impl SpaceblockRequest { #[must_use] pub fn to_bytes(&self) -> Vec { - let Self { name, size, range } = self; let mut buf = Vec::new(); - encode::string(&mut buf, name); + encode::string(&mut buf, &self.name); buf.extend_from_slice(&self.size.to_le_bytes()); buf.extend_from_slice(&self.range.to_bytes()); buf @@ -200,7 +198,7 @@ mod tests { async fn test_spaceblock_requests_empty() { let req = SpaceblockRequests { id: Uuid::new_v4(), - block_size: BlockSize::from_size(42069), + block_size: BlockSize::from_file_size(42069), requests: vec![], }; @@ -215,7 +213,7 @@ mod tests { async fn test_spaceblock_requests_one() { let req = SpaceblockRequests { id: Uuid::new_v4(), - block_size: BlockSize::from_size(42069), + block_size: BlockSize::from_file_size(42069), requests: vec![SpaceblockRequest { name: "Demo".to_string(), size: 42069, @@ -246,7 +244,7 @@ mod tests { async fn test_spaceblock_requests_many() { let req = SpaceblockRequests { id: Uuid::new_v4(), - block_size: BlockSize::from_size(42069), + block_size: BlockSize::from_file_size(42069), requests: vec![ SpaceblockRequest { name: "Demo".to_string(), diff --git a/crates/sd-indexer/Cargo.toml b/crates/sd-indexer/Cargo.toml deleted file mode 100644 index 65e18084e..000000000 --- a/crates/sd-indexer/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "sd-indexer" -version = "0.0.1" -license.workspace = true -edition.workspace = true -repository.workspace = true -publish = false - -[dependencies] -sd-utils = { path = "../utils" } -sd-file-ext = { path = "../file-ext" } -sd-core-file-path-helper = { path = "../../core/crates/file-path-helper" } -sd-core-indexer-rules = { path = "../../core/crates/indexer-rules" } - -chrono.workspace = true -futures-util = "0.3.30" -globset = { version = "0.4.14", features = ["serde1"] } -opendal = "0.45.1" -serde = { workspace = true, features = ["derive"] } -specta.workspace = true -thiserror.workspace = true -tracing.workspace = true -rmp-serde = "1.1.2" - -# TODO: Remove these -rspc.workspace = true -tokio = { workspace = true, features = ["fs"] } -sd-prisma = { path = "../prisma" } -tempfile.workspace = true -normpath = { workspace = true, features = ["localization"] } diff --git a/crates/sd-indexer/src/ephemeral.rs b/crates/sd-indexer/src/ephemeral.rs deleted file mode 100644 index 3b38d3b9d..000000000 --- a/crates/sd-indexer/src/ephemeral.rs +++ /dev/null @@ -1,212 +0,0 @@ -use std::{ - future::ready, - io::{self, ErrorKind}, - path::PathBuf, -}; - -use chrono::{DateTime, Utc}; -use futures_util::{Stream, StreamExt, TryFutureExt}; -use opendal::{Operator, Scheme}; -use sd_core_file_path_helper::path_is_hidden; -use sd_core_indexer_rules::{IndexerRule, RuleKind}; -use sd_file_ext::{extensions::Extension, kind::ObjectKind}; -use serde::Serialize; -use specta::Type; - -use crate::stream::TaskStream; - -#[derive(Serialize, Type, Debug)] -pub struct NonIndexedPathItem { - pub path: String, - pub name: String, - pub extension: String, - pub kind: i32, // TODO: Use `ObjectKind` instead - // TODO: Use `kind` instead and drop this - pub is_dir: bool, - pub date_created: DateTime, - pub date_modified: DateTime, - pub size_in_bytes_bytes: Vec, - pub hidden: bool, -} - -pub async fn ephemeral( - opendal: Operator, - rules: Vec, - path: &str, -) -> opendal::Result>> { - let is_fs = opendal.info().scheme() == Scheme::Fs; - let base_path = PathBuf::from(opendal.info().root()); - let mut lister = opendal.lister(path).await?; - - Ok(TaskStream::new(move |tx| async move { - let rules = &*rules; - while let Some(entry) = lister.next().await { - let base_path = base_path.clone(); - let result = ready(entry) - .map_err(|err| io::Error::new(ErrorKind::Other, format!("OpenDAL: {err:?}"))) - .and_then(|entry| async move { - let path = base_path.join(entry.path()); - - let extension = (!path.is_dir()) - .then(|| { - path.extension() - .and_then(|s| s.to_str().map(str::to_string)) - .unwrap_or_default() - }) - .unwrap_or_default(); - - // Only Windows supports normalised files without FS access. - // For now we only do normalisation for local files. - let (relative_path, name) = if is_fs { - crate::path::normalize_path(&path).map_err(|err| { - io::Error::new( - ErrorKind::Other, - format!("Error normalising path '{path:?}': {err:?}"), - ) - })? - } else { - unreachable!(); - // ( - // path.file_stem() - // .and_then(|s| s.to_str().map(str::to_string)) - // .ok_or_else(|| { - // io::Error::new( - // ErrorKind::Other, - // "error on file '{path:?}: non UTF-8", - // ) - // })? - // .to_string(), - // path.to_str() - // .expect("non UTF-8 path - is unreachable") - // .to_string(), - // ) - }; - - let kind = if entry.metadata().is_dir() { - ObjectKind::Folder - } else if is_fs { - Extension::resolve_conflicting(&path, false) - .await - .map(Into::into) - .unwrap_or(ObjectKind::Unknown) - } else { - // TODO: Determine kind of remote files - https://linear.app/spacedriveapp/issue/ENG-1718/fix-objectkind-of-remote-files - ObjectKind::Unknown - }; - - let name = (kind != ObjectKind::Folder) - .then(|| { - path.file_stem() - .and_then(|s| s.to_str().map(str::to_string)) - }) - .flatten() - .unwrap_or(name); - - let mut path = path - .to_str() - .expect("comes from string so this is impossible") - .to_string(); - - // OpenDAL will *always* end in a `/` for directories, we strip it here so we can give the path to Tokio. - if path.ends_with('/') && path.len() > 1 { - path.pop(); - } - - let result = IndexerRule::apply_all(rules, &path).await.map_err(|err| { - io::Error::new( - ErrorKind::Other, - format!("Error running indexer rules on file '{path:?}': {err:?}"), - ) - })?; - - // No OS Protected and No Hidden rules, must always be from this kind, should panic otherwise - if result[&RuleKind::RejectFilesByGlob] - .iter() - .any(|reject| !reject) - { - return Ok(None); // Skip this file - }; - - // TODO: OpenDAL last modified time - https://linear.app/spacedriveapp/issue/ENG-1717/fix-modified-time - // TODO: OpenDAL hidden files - https://linear.app/spacedriveapp/issue/ENG-1720/fix-hidden-files - let (hidden, date_created, date_modified, size) = if is_fs { - let metadata = tokio::fs::metadata(&path).await.map_err(|err| { - io::Error::new( - ErrorKind::Other, - format!("Error getting metadata for '{path:?}': {err:?}"), - ) - })?; - - ( - path_is_hidden(&path, &metadata), - metadata - .created() - .map_err(|err| { - io::Error::new( - ErrorKind::Other, - format!("Error determining created time for '{path:?}': {err:?}"), - ) - })? - .into(), - metadata - .modified() - .map_err(|err| { - io::Error::new( - ErrorKind::Other, - format!("Error determining modified time for '{path:?}': {err:?}"), - ) - })? - .into(), - metadata.len(), - ) - } else { - (false, Default::default(), Default::default(), 0) - }; - - // TODO: Fix this - https://linear.app/spacedriveapp/issue/ENG-1725/fix-last-modified - #[allow(clippy::redundant_locals)] - let date_modified = date_modified; - // entry.metadata().last_modified().ok_or_else(|| { - // io::Error::new( - // ErrorKind::Other, - // format!("Error getting modified time for '{path:?}'"), - // ) - // })?; - - #[allow(clippy::redundant_locals)] - // TODO: Fix this - https://linear.app/spacedriveapp/issue/ENG-1726/fix-file-size - let size = size; - - Ok(Some(NonIndexedPathItem { - path: relative_path, - name, - extension, - kind: kind as i32, - is_dir: kind == ObjectKind::Folder, - date_created, - date_modified, - // TODO - // entry - // .metadata() - // .content_length() - size_in_bytes_bytes: size.to_be_bytes().to_vec(), - hidden, - })) - }) - .await; - - if tx - .send(match result { - Ok(Some(item)) => Ok(item), - Ok(None) => continue, - Err(err) => Err(err), - }) - .await - .is_err() - { - // Stream has been dropped. - continue; - } - } - })) -} diff --git a/crates/sd-indexer/src/lib.rs b/crates/sd-indexer/src/lib.rs deleted file mode 100644 index c34480996..000000000 --- a/crates/sd-indexer/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod ephemeral; -pub mod path; -mod stream; - -pub use ephemeral::*; diff --git a/crates/sd-indexer/src/path.rs b/crates/sd-indexer/src/path.rs deleted file mode 100644 index 9803c84a2..000000000 --- a/crates/sd-indexer/src/path.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::{ - io, - path::{Component, Path}, -}; - -use normpath::PathExt; - -pub fn normalize_path(path: impl AsRef) -> io::Result<(String, String)> { - let mut path = path.as_ref().to_path_buf(); - let (location_path, normalized_path) = path - // Normalize path and also check if it exists - .normalize() - .and_then(|normalized_path| { - if cfg!(windows) { - // Use normalized path as main path on Windows - // This ensures we always receive a valid windows formatted path - // ex: /Users/JohnDoe/Downloads will become C:\Users\JohnDoe\Downloads - // Internally `normalize` calls `GetFullPathNameW` on Windows - // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfullpathnamew - path = normalized_path.as_path().to_path_buf(); - } - - Ok(( - // TODO: Maybe save the path bytes instead of the string representation to avoid depending on UTF-8 - path.to_str().map(str::to_string).ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Found non-UTF-8 path", - ))?, - normalized_path, - )) - })?; - - // Not needed on Windows because the normalization already handles it - if cfg!(not(windows)) { - // Replace location_path with normalize_path, when the first one ends in `.` or `..` - // This is required so localize_name doesn't panic - if let Some(component) = path.components().next_back() { - if matches!(component, Component::CurDir | Component::ParentDir) { - path = normalized_path.as_path().to_path_buf(); - } - } - } - - // Use `to_string_lossy` because a partially corrupted but identifiable name is better than nothing - let mut name = path.localize_name().to_string_lossy().to_string(); - - // Windows doesn't have a root directory - if cfg!(not(windows)) && name == "/" { - name = "Root".to_string() - } - - if name.replace(char::REPLACEMENT_CHARACTER, "") == "" { - name = "Unknown".to_string() - } - - Ok((location_path, name)) -} diff --git a/crates/sd-indexer/src/stream.rs b/crates/sd-indexer/src/stream.rs deleted file mode 100644 index 0922c3399..000000000 --- a/crates/sd-indexer/src/stream.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{ - pin::Pin, - task::{Context, Poll}, -}; - -use futures_util::Future; -use tokio::sync::mpsc; - -/// Construct a stream from a Tokio task. -/// Similar to `tokio_stream::stream!` but not a macro for better DX. -pub struct TaskStream { - task: tokio::task::JoinHandle<()>, - receiver: mpsc::Receiver, -} - -impl TaskStream { - pub fn new(task: impl FnOnce(mpsc::Sender) -> F + Send + 'static) -> Self { - let (tx, rx) = mpsc::channel(256); - Self { - task: tokio::spawn(async move { - task(tx).await; - }), - receiver: rx, - } - } -} - -impl futures_util::Stream for TaskStream { - type Item = T; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { - self.receiver.poll_recv(cx) - } -} - -impl Drop for TaskStream { - fn drop(&mut self) { - self.task.abort(); - } -} diff --git a/crates/sync/src/compressed.rs b/crates/sync/src/compressed.rs index a3f8deb39..bdcd523da 100644 --- a/crates/sync/src/compressed.rs +++ b/crates/sync/src/compressed.rs @@ -7,10 +7,8 @@ use crate::{CRDTOperation, CRDTOperationData}; pub type CompressedCRDTOperationsForModel = Vec<(rmpv::Value, Vec)>; /// Stores a bunch of CRDTOperations in a more memory-efficient form for sending to the cloud. -#[derive(Serialize, Deserialize)] -pub struct CompressedCRDTOperations( - pub(self) Vec<(Uuid, Vec<(u16, CompressedCRDTOperationsForModel)>)>, -); +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct CompressedCRDTOperations(pub Vec<(Uuid, Vec<(u16, CompressedCRDTOperationsForModel)>)>); impl CompressedCRDTOperations { pub fn new(ops: Vec) -> Self { @@ -25,7 +23,7 @@ impl CompressedCRDTOperations { let mut instance_id = first.instance; let mut instance = vec![]; - let mut model_str = first.model.clone(); + let mut model_str = first.model; let mut model = vec![]; let mut record_id = first.record_id.clone(); @@ -38,7 +36,7 @@ impl CompressedCRDTOperations { std::mem::take(&mut record), )); instance.push(( - std::mem::replace(&mut model_str, op.model.clone()), + std::mem::replace(&mut model_str, op.model), std::mem::take(&mut model), )); compressed.push(( @@ -51,7 +49,7 @@ impl CompressedCRDTOperations { std::mem::take(&mut record), )); instance.push(( - std::mem::replace(&mut model_str, op.model.clone()), + std::mem::replace(&mut model_str, op.model), std::mem::take(&mut model), )); } else if record_id != op.record_id { @@ -71,6 +69,35 @@ impl CompressedCRDTOperations { Self(compressed) } + pub fn first(&self) -> Option<(Uuid, u16, &rmpv::Value, &CompressedCRDTOperation)> { + self.0.first().and_then(|(instance, data)| { + data.first().and_then(|(model, data)| { + data.first() + .and_then(|(record, ops)| ops.first().map(|op| (*instance, *model, record, op))) + }) + }) + } + + pub fn last(&self) -> Option<(Uuid, u16, &rmpv::Value, &CompressedCRDTOperation)> { + self.0.last().and_then(|(instance, data)| { + data.last().and_then(|(model, data)| { + data.last() + .and_then(|(record, ops)| ops.last().map(|op| (*instance, *model, record, op))) + }) + }) + } + + pub fn len(&self) -> usize { + self.0 + .iter() + .map(|(_, data)| { + data.iter() + .map(|(_, data)| data.iter().map(|(_, ops)| ops.len()).sum::()) + .sum::() + }) + .sum::() + } + pub fn into_ops(self) -> Vec { let mut ops = vec![]; @@ -80,7 +107,7 @@ impl CompressedCRDTOperations { for op in record { ops.push(CRDTOperation { instance: instance_id, - model: model_str.clone(), + model: model_str, record_id: record_id.clone(), timestamp: op.timestamp, data: op.data, @@ -94,7 +121,7 @@ impl CompressedCRDTOperations { } } -#[derive(PartialEq, Serialize, Deserialize, Clone)] +#[derive(PartialEq, Serialize, Deserialize, Clone, Debug)] pub struct CompressedCRDTOperation { pub timestamp: NTP64, pub data: CRDTOperationData, diff --git a/crates/task-system/src/task.rs b/crates/task-system/src/task.rs index bf7c18b49..9fd05d5b5 100644 --- a/crates/task-system/src/task.rs +++ b/crates/task-system/src/task.rs @@ -13,8 +13,7 @@ use async_channel as chan; use async_trait::async_trait; use chan::{Recv, RecvError}; use downcast_rs::{impl_downcast, Downcast}; -use futures::executor::block_on; -use tokio::sync::oneshot; +use tokio::{runtime::Handle, sync::oneshot}; use tracing::{trace, warn}; use uuid::Uuid; @@ -58,6 +57,12 @@ pub enum TaskOutput { Empty, } +impl From<()> for TaskOutput { + fn from((): ()) -> Self { + Self::Empty + } +} + /// An enum representing all possible outcomes for a task. #[derive(Debug)] pub enum TaskStatus { @@ -125,14 +130,8 @@ impl + 'static, E: RunError> IntoTask for T { /// due to a limitation in the Rust language. #[async_trait] pub trait Task: fmt::Debug + Downcast + Send + Sync + 'static { - /// This method represent the work that should be done by the worker, it will be called by the - /// worker when there is a slot available in its internal queue. - /// We receive a `&mut self` so any internal data can be mutated on each `run` invocation. - /// - /// The [`interrupter`](Interrupter) is a helper object that can be used to check if the user requested a pause or a cancel, - /// so the user can decide the appropriated moment to pause or cancel the task. Avoiding corrupted data or - /// inconsistent states. - async fn run(&mut self, interrupter: &Interrupter) -> Result; + /// An unique identifier for the task, it will be used to identify the task on the system and also to the user. + fn id(&self) -> TaskId; /// This method defines whether a task should run with priority or not. The task system has a mechanism /// to suspend non-priority tasks on any worker and run priority tasks ASAP. This is useful for tasks that @@ -142,8 +141,14 @@ pub trait Task: fmt::Debug + Downcast + Send + Sync + 'static { false } - /// An unique identifier for the task, it will be used to identify the task on the system and also to the user. - fn id(&self) -> TaskId; + /// This method represent the work that should be done by the worker, it will be called by the + /// worker when there is a slot available in its internal queue. + /// We receive a `&mut self` so any internal data can be mutated on each `run` invocation. + /// + /// The [`interrupter`](Interrupter) is a helper object that can be used to check if the user requested a pause or a cancel, + /// so the user can decide the appropriated moment to pause or cancel the task. Avoiding corrupted data or + /// inconsistent states. + async fn run(&mut self, interrupter: &Interrupter) -> Result; } impl_downcast!(Task where E: RunError); @@ -508,7 +513,7 @@ impl Future for CancelTaskOnDrop { impl Drop for CancelTaskOnDrop { fn drop(&mut self) { // FIXME: We should use async drop when it becomes stable - block_on(self.0.cancel()); + Handle::current().block_on(self.0.cancel()); } } diff --git a/docs/developers/technology/normalised-cache.mdx b/docs/developers/technology/normalised-cache.mdx index 47fdc5e69..cdf90ecf4 100644 --- a/docs/developers/technology/normalised-cache.mdx +++ b/docs/developers/technology/normalised-cache.mdx @@ -17,8 +17,8 @@ This means the queries will always render the newest version of the model. ## Terminology - - `CacheNode`: A node in the cache - this contains the data and can be identified by the model's name and unique ID within the data (eg. database primary key). - - `Reference`: A reference to a node in the cache - This contains the model's name and unique ID. +- `CacheNode`: A node in the cache - this contains the data and can be identified by the model's name and unique ID within the data (eg. database primary key). +- `Reference`: A reference to a node in the cache - This contains the model's name and unique ID. ## High level overview @@ -26,8 +26,7 @@ We turn the data on the backend into a list of `CacheNode`'s and a list of `Refe We insert the `CacheNode`'s into a global cache on the frontend and then use the `Reference`'s to reconstruct the data by looking up the `CacheNode`'s. -When the cache changes (from another query, invalidation, etc), we can reconstruct *all* queries using their `Reference`'s to reflect the updated data. - +When the cache changes (from another query, invalidation, etc), we can reconstruct _all_ queries using their `Reference`'s to reflect the updated data. ## Rust usage @@ -129,7 +128,6 @@ const filePaths = useCache(query.data?.file_paths); This is only possible because `useNodes` and `useCache` take in a specific key, instead of the whole `data` object, so you can tell it where to look. - ## Known issues ### Specta support diff --git a/docs/developers/technology/rspc.mdx b/docs/developers/technology/rspc.mdx index 1885135f9..23056d19f 100644 --- a/docs/developers/technology/rspc.mdx +++ b/docs/developers/technology/rspc.mdx @@ -7,16 +7,17 @@ We use a fork based on [rspc 0.1.4](https://docs.rs/rspc) which contains heavy m ## What's different? - - A super pre-release version of rspc v1's procedure syntax. - - Upgrade to Specta v2 prelease - - Add `Router::sd_patch_types_dangerously` - - Expose internal type maps for the invalidation system. - - All procedures must return a result - - `Procedure::with2` which is a hack to properly support the middleware mapper API - - Legacy executor system - Will require major changes to the React Native link. +- A super pre-release version of rspc v1's procedure syntax. +- Upgrade to Specta v2 prelease +- Add `Router::sd_patch_types_dangerously` +- Expose internal type maps for the invalidation system. +- All procedures must return a result +- `Procedure::with2` which is a hack to properly support the middleware mapper API +- Legacy executor system - Will require major changes to the React Native link. Removed features relied on by Spacedrive: - - Argument middleware mapper API has been removed upstream + +- Argument middleware mapper API has been removed upstream ## Basic usage @@ -83,9 +84,8 @@ Minus batching HTTP requests are run in parallel. ### Websocket reconnect -If the websocket connection is dropped (due to network disruption) all subscriptions *will not* restart upon reconnecting. +If the websocket connection is dropped (due to network disruption) all subscriptions _will not_ restart upon reconnecting. This will cause the invalidation system to break and potentially other parts of the app that rely on subscriptions. Queries and mutations done during the network disruption will hang indefinitely. - diff --git a/docs/product/getting-started/setup.mdx b/docs/product/getting-started/setup.mdx index 0aba9699d..0e7a596e5 100644 --- a/docs/product/getting-started/setup.mdx +++ b/docs/product/getting-started/setup.mdx @@ -36,7 +36,7 @@ You can run Spacedrive in a Docker container using the following command. /> ```bash -docker run -d --name spacedrive -p 8080:8080 -e SD_AUTH=admin,spacedrive -v /var/spacedrive:/var/spacedrive ghcr.io/spacedriveapp/spacedrive/server +docker run -d --name spacedrive -p 8080:8080 -e SD_AUTH=admin:spacedrive -v /var/spacedrive:/var/spacedrive ghcr.io/spacedriveapp/spacedrive/server ``` #### Authentication @@ -44,9 +44,10 @@ docker run -d --name spacedrive -p 8080:8080 -e SD_AUTH=admin,spacedrive -v /var When using the Spacedrive server you can use the `SD_AUTH` environment variable to configure authentication. Valid values: - - `SD_AUTH=disabled` - Disables authentication. - - `SD_AUTH=username:password` - Enables authentication for a single user. - - `SD_AUTH=username:password,username1:password1` - Enables authentication with multiple users (you can add as many users as you want). + +- `SD_AUTH=disabled` - Disables authentication. +- `SD_AUTH=username:password` - Enables authentication for a single user. +- `SD_AUTH=username:password,username1:password1` - Enables authentication with multiple users (you can add as many users as you want). ### Mobile (Preview) diff --git a/interface/app/$libraryId/Explorer/FilePath/Original.tsx b/interface/app/$libraryId/Explorer/FilePath/Original.tsx index 6b16fb6c8..90c6f0c67 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Original.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Original.tsx @@ -17,7 +17,7 @@ import { usePlatform } from '~/util/Platform'; import { useExplorerContext } from '../Context'; import { explorerStore } from '../store'; -import { ExplorerItemData } from '../util'; +import { ExplorerItemData } from '../useExplorerItemData'; import { Image } from './Image'; import { useBlackBars, useSize } from './utils'; diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index b708a9ad6..f533fe7ea 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -15,7 +15,7 @@ import { useIsDark } from '~/hooks'; import { pdfViewerEnabled } from '~/util/pdfViewer'; import { usePlatform } from '~/util/Platform'; -import { useExplorerItemData } from '../util'; +import { useExplorerItemData } from '../useExplorerItemData'; import { Image, ImageProps } from './Image'; import LayeredFileIcon from './LayeredFileIcon'; import { Original } from './Original'; diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 68b5cdc52..2c86367dc 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -55,7 +55,8 @@ import AssignTagMenuItems from '../ContextMenu/AssignTagMenuItems'; import { FileThumb } from '../FilePath/Thumb'; import { useQuickPreviewStore } from '../QuickPreview/store'; import { explorerStore } from '../store'; -import { uniqueId, useExplorerItemData } from '../util'; +import { useExplorerItemData } from '../useExplorerItemData'; +import { uniqueId } from '../util'; import { RenamableItemText } from '../View/RenamableItemText'; import FavoriteButton from './FavoriteButton'; import MediaData from './MediaData'; diff --git a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/IconSize.tsx b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/IconSize.tsx index 829e1ebe1..551ee2ede 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/IconSize.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/IconSize.tsx @@ -15,8 +15,8 @@ export const IconSize = () => { const explorer = useExplorerContext(); const settings = explorer.useSettingsSnapshot(); - const defaultValue = useMemo( - () => sizes.findIndex((size) => size[0] === settings.listViewIconSize), + const value = useMemo( + () => sizes.indexMap.get(settings.listViewIconSize), [settings.listViewIconSize] ); @@ -25,11 +25,11 @@ export const IconSize = () => { {t('icon_size')} { - const size = value !== undefined && sizes[value]; - if (size) explorer.settingsStore.listViewIconSize = size[0]; + const size = value !== undefined && sizes.sizeMap.get(value); + if (size) explorer.settingsStore.listViewIconSize = size; }} /> diff --git a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/TextSize.tsx b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/TextSize.tsx index 4268bf6fa..d7cb992cd 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/TextSize.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/TextSize.tsx @@ -16,7 +16,7 @@ export const TextSize = () => { const settings = explorer.useSettingsSnapshot(); const defaultValue = useMemo( - () => sizes.findIndex((size) => size[0] === settings.listViewTextSize), + () => sizes.indexMap.get(settings.listViewTextSize), [settings.listViewTextSize] ); @@ -25,11 +25,11 @@ export const TextSize = () => { {t('text_size')} { - const size = value !== undefined && sizes[value]; - if (size) explorer.settingsStore.listViewTextSize = size[0]; + const size = value !== undefined && sizes.sizeMap.get(value); + if (size) explorer.settingsStore.listViewTextSize = size; }} /> diff --git a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/util.ts b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/util.ts index 2dd8dd5b2..8ee118a32 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel/ListView/util.ts +++ b/interface/app/$libraryId/Explorer/OptionsPanel/ListView/util.ts @@ -1,3 +1,18 @@ export function getSizes(sizes: T) { - return (Object.entries(sizes) as [keyof T, T[keyof T]][]).sort((a, b) => a[1] - b[1]); + const sizesArr = (Object.entries(sizes) as [keyof T, T[keyof T]][]).sort((a, b) => a[1] - b[1]); + + // Map fo size to index + const indexMap = new Map(); + + // Map of index to size + const sizeMap = new Map(); + + for (let i = 0; i < sizesArr.length; i++) { + const size = sizesArr[i]; + if (!size) continue; + indexMap.set(size[0], i); + sizeMap.set(i, size[0]); + } + + return { indexMap, sizeMap }; } diff --git a/interface/app/$libraryId/Explorer/OptionsPanel/index.tsx b/interface/app/$libraryId/Explorer/OptionsPanel/index.tsx index c697384c4..a764c016f 100644 --- a/interface/app/$libraryId/Explorer/OptionsPanel/index.tsx +++ b/interface/app/$libraryId/Explorer/OptionsPanel/index.tsx @@ -108,7 +108,7 @@ export default () => { onValueChange={(value) => { explorer.settingsStore.gridItemSize = value[0] || 100; }} - defaultValue={[settings.gridItemSize]} + value={[settings.gridItemSize]} max={200} step={10} min={60} diff --git a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx index 76f87f0a8..e1288210d 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx @@ -50,6 +50,7 @@ import { Conditional } from '../ContextMenu/ConditionalItem'; import { FileThumb } from '../FilePath/Thumb'; import { SingleItemMetadata } from '../Inspector'; import { explorerStore } from '../store'; +import { useExplorerViewContext } from '../View/Context'; import { ImageSlider } from './ImageSlider'; import { getQuickPreviewStore, useQuickPreviewStore } from './store'; @@ -76,6 +77,7 @@ export const QuickPreview = () => { const { openFilePaths, openEphemeralFiles } = usePlatform(); const explorerLayoutStore = useExplorerLayoutStore(); const explorer = useExplorerContext(); + const explorerView = useExplorerViewContext(); const { open, itemIndex } = useQuickPreviewStore(); const thumb = createRef(); @@ -159,6 +161,14 @@ export const QuickPreview = () => { setShowMetadata(false); }, [item, open]); + useEffect(() => { + if (open) explorerView.updateActiveItem(null, { updateFirstItem: true }); + + // "open" is excluded, as we only want this to trigger when hashes change, + // that way we don't have to manually update the active item. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [explorer.selectedItemHashes, explorerView.updateActiveItem]); + const handleMoveBetweenItems = (step: number) => { const nextPreviewItem = items[itemIndex + step]; if (nextPreviewItem) { diff --git a/interface/app/$libraryId/Explorer/View/Context.ts b/interface/app/$libraryId/Explorer/View/Context.tsx similarity index 63% rename from interface/app/$libraryId/Explorer/View/Context.ts rename to interface/app/$libraryId/Explorer/View/Context.tsx index 1c67dd3d8..38ce6c2ba 100644 --- a/interface/app/$libraryId/Explorer/View/Context.ts +++ b/interface/app/$libraryId/Explorer/View/Context.tsx @@ -1,6 +1,8 @@ import { createContext, useContext, type ReactNode, type RefObject } from 'react'; -export interface ExplorerViewContext { +import { useActiveItem } from './useActiveItem'; + +export interface ExplorerViewContextProps extends ReturnType { ref: RefObject; /** * Padding to apply when scrolling to an item. @@ -13,10 +15,10 @@ export interface ExplorerViewContext { }; } -export const ViewContext = createContext(null); +export const ExplorerViewContext = createContext(null); export const useExplorerViewContext = () => { - const ctx = useContext(ViewContext); + const ctx = useContext(ExplorerViewContext); if (ctx === null) throw new Error('ViewContext.Provider not found!'); diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx index c98d9c6cb..e58a56787 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx @@ -7,7 +7,6 @@ import { useExplorerContext } from '../../../Context'; import { explorerStore } from '../../../store'; import { useExplorerOperatingSystem } from '../../../useExplorerOperatingSystem'; import { useExplorerViewContext } from '../../Context'; -import { useKeySelection } from '../useKeySelection'; import { DragSelectContext } from './context'; import { useSelectedTargets } from './useSelectedTargets'; import { getElementIndex, SELECTABLE_DATA_ATTRIBUTE } from './util'; @@ -16,7 +15,6 @@ const CHROME_REGEX = /Chrome/; interface Props extends PropsWithChildren { grid: ReturnType>; - onActiveItemChange: ReturnType['updateActiveItem']; } export interface Drag { @@ -26,11 +24,13 @@ export interface Drag { endRow: number; } -export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { +export const DragSelect = ({ grid, children }: Props) => { const isChrome = CHROME_REGEX.test(navigator.userAgent); const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem(); + const isWindows = explorerOperatingSystem === 'windows' && matchingOperatingSystem; + const explorer = useExplorerContext(); const explorerView = useExplorerViewContext(); @@ -99,20 +99,20 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { // Update active item to the first selected target(first grid item in DOM). const target = selecto.current?.getSelectedTargets()?.[0]; - const item = target && getGridItem(target)?.data; - if (item) onActiveItemChange(item, { updateFirstItem: true, setFirstItemAsChanged: true }); + + const item = target && getGridItem(target); + if (!item) return; + + explorerView.updateActiveItem(item.id as string, { + updateFirstItem: true + }); } function handleSelect(e: SelectoEvents['select']) { const inputEvent = e.inputEvent as MouseEvent; - let continueSelection = false; - - if (explorerOperatingSystem === 'windows') { - continueSelection = matchingOperatingSystem ? inputEvent.ctrlKey : inputEvent.metaKey; - } else { - continueSelection = inputEvent.shiftKey || inputEvent.metaKey; - } + const continueSelection = + inputEvent.shiftKey || (isWindows ? inputEvent.ctrlKey : inputEvent.metaKey); // Handle select on mouse down if (inputEvent.type === 'mousedown') { @@ -130,7 +130,10 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { }; if (!continueSelection) { - if (explorer.selectedItems.has(item.data)) { + if ( + explorerOperatingSystem !== 'windows' && + explorer.selectedItems.has(item.data) + ) { // Keep previous selection as selecto will reset it otherwise selecto.current?.setSelectedTargets(e.beforeSelected); } else { @@ -140,14 +143,31 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { ]); } + explorerView.updateActiveItem(item.id as string, { updateFirstItem: true }); return; } - if (e.added[0]) explorer.addSelectedItem(item.data); - else explorer.removeSelectedItem(item.data); + if (explorerOperatingSystem === 'windows' && inputEvent.shiftKey) { + explorerView.handleWindowsGridShiftSelection(item.index); + return; + } - // Update active item for further keyboard selection. - onActiveItemChange(item.data, { updateFirstItem: true, setFirstItemAsChanged: true }); + if (e.added[0]) { + explorer.addSelectedItem(item.data); + explorerView.updateActiveItem(item.id as string, { updateFirstItem: true }); + return; + } + + explorer.removeSelectedItem(item.data); + + explorerView.updateActiveItem( + explorerOperatingSystem === 'windows' ? (item.id as string) : null, + { + updateFirstItem: true + } + ); + + return; } // Handle select by drag @@ -557,13 +577,7 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => { throttleTime: isChrome ? 30 : 10000 }} selectableTargets={[`[${SELECTABLE_DATA_ATTRIBUTE}]`]} - toggleContinueSelect={ - explorerOperatingSystem === 'windows' - ? matchingOperatingSystem - ? 'ctrl' - : 'meta' - : [['shift'], ['meta']] - } + toggleContinueSelect={[['shift'], [isWindows ? 'ctrl' : 'meta']]} hitRate={0} onDrag={handleDrag} onDragStart={handleDragStart} diff --git a/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx index cac96fb2d..f89e95825 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/useKeySelection.tsx @@ -1,5 +1,4 @@ import { useGrid } from '@virtual-grid/react'; -import { useCallback, useEffect, useRef } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { ExplorerItem } from '@sd/client'; import { useShortcut } from '~/hooks'; @@ -18,92 +17,18 @@ interface Options { scrollToEnd?: boolean; } -interface UpdateActiveItemOptions { - /** - * The index of the item to update. If not provided, the index will be reset. - * @default null - */ - itemIndex?: number | null; - /** - * Whether to update the first active item. - * @default false - */ - updateFirstItem?: boolean; - /** - * Whether to set the first item as changed. This is used to reset the selection. - * @default false - */ - setFirstItemAsChanged?: boolean; -} - export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: false }) => { - const { explorerOperatingSystem } = useExplorerOperatingSystem(); - const explorer = useExplorerContext(); const explorerView = useExplorerViewContext(); - // The item that further selection will move from (shift + arrow for example). - const activeItem = useRef(null); + const { explorerOperatingSystem } = useExplorerOperatingSystem(); - // The index of the active item. This is stored so we don't have to look - // for the index every time we want to move to the next item. - const activeItemIndex = useRef(null); - - // The first active item that acts as a head. - // Only used for windows OS to keep track of the first selected item. - const firstActiveItem = useRef(null); - - // The index of the first active item. - // Only used for windows OS to keep track of the first selected item index. - const firstActiveItemIndex = useRef(null); - - // Whether the first active item has been changed. - // Only used for windows OS to keep track whether selection should be reset. - const hasFirstActiveItemChanged = useRef(true); - - // Reset active item when selection changes, as the active item - // might not be in the selection anymore (further lookups are handled in handleNavigation). - useEffect(() => { - activeItem.current = null; - }, [explorer.selectedItems]); - - // Reset active item index when items change, - // as we can't guarantee the item is still in the same position - useEffect(() => { - activeItemIndex.current = null; - firstActiveItemIndex.current = null; - }, [explorer.items]); - - const updateFirstActiveItem = useCallback( - ( - item: ExplorerItem | null, - options: Omit = {} - ) => { - if (explorerOperatingSystem !== 'windows') return; - - firstActiveItem.current = item; - firstActiveItemIndex.current = options.itemIndex ?? null; - if (options.setFirstItemAsChanged) hasFirstActiveItemChanged.current = true; - }, - [explorerOperatingSystem] - ); - - const updateActiveItem = useCallback( - (item: ExplorerItem | null, options: UpdateActiveItemOptions = {}) => { - // Timeout so the useEffect doesn't override it - setTimeout(() => { - activeItem.current = item; - activeItemIndex.current = options.itemIndex ?? null; - }); - - if (options.updateFirstItem) updateFirstActiveItem(item, options); - }, - [updateFirstActiveItem] - ); - - const scrollToItem = (item: NonNullable>) => { + const scrollToItem = (index: number) => { if (!explorer.scrollRef.current || !explorerView.ref.current) return; + const item = grid.getItem(index); + if (!item) return; + const { top: viewTop } = explorerView.ref.current.getBoundingClientRect(); const { height: scrollHeight } = explorer.scrollRef.current.getBoundingClientRect(); @@ -143,56 +68,25 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa // Select first item in grid if no items are selected, on down/right keybind // TODO: Handle when no items are selected and up/left keybind is executed (should select last item in grid) - if ((direction === 'down' || direction === 'right') && explorer.selectedItems.size === 0) { - const item = grid.getItem(0); - if (!item?.data) return; + if (explorer.selectedItems.size === 0) { + if (direction !== 'down' && direction !== 'right') return; - explorer.resetSelectedItems([item.data]); - scrollToItem(item); + const item = explorer.items[0]; + if (!item) return; - updateActiveItem(item.data, { itemIndex: 0, updateFirstItem: true }); + scrollToItem(0); + + explorer.resetSelectedItems([item]); + + explorerView.updateActiveItem(explorer.getItemUniqueId(item), { + updateFirstItem: true + }); return; } - let currentItemIndex = activeItemIndex.current; - - // Check for any mismatches between the stored index and the current item - if (currentItemIndex !== null) { - if (activeItem.current) { - const itemAtActiveIndex = explorer.items[currentItemIndex]; - const uniqueId = itemAtActiveIndex && explorer.getItemUniqueId(itemAtActiveIndex); - if (uniqueId !== explorer.getItemUniqueId(activeItem.current)) { - currentItemIndex = null; - } - } else { - currentItemIndex = null; - } - } - - // Find index of current active item - if (currentItemIndex === null) { - let currentItem = activeItem.current; - - if (!currentItem) { - const [item] = explorer.selectedItems; - if (!item) return; - - currentItem = item; - } - - const currentItemId = explorer.getItemUniqueId(currentItem); - - const index = explorer.items.findIndex((item) => { - return explorer.getItemUniqueId(item) === currentItemId; - }); - - if (index === -1) return; - - currentItemIndex = index; - } - - if (currentItemIndex === null) return; + const currentItemIndex = explorerView.getActiveItemIndex(); + if (currentItemIndex === undefined) return; let newIndex = currentItemIndex; @@ -225,118 +119,26 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa } } - const newSelectedItem = grid.getItem(newIndex); - if (!newSelectedItem?.data) return; + const newSelectedItem = explorer.items[newIndex]; + if (!newSelectedItem) return; + + scrollToItem(newIndex); if (!e.shiftKey) { - explorer.resetSelectedItems([newSelectedItem.data]); + explorer.resetSelectedItems([newSelectedItem]); } else if ( explorerOperatingSystem !== 'windows' && - !explorer.isItemSelected(newSelectedItem.data) + !explorer.isItemSelected(newSelectedItem) ) { - explorer.addSelectedItem(newSelectedItem.data); + explorer.addSelectedItem(newSelectedItem); } else if (explorerOperatingSystem === 'windows') { - let firstItemId = firstActiveItem.current - ? explorer.getItemUniqueId(firstActiveItem.current) - : undefined; - - let firstItemIndex = firstActiveItemIndex.current; - - // Check if the firstActiveItem is still in the selection. If not, - // update the firstActiveItem to the current active item. - if (firstActiveItem.current && explorer.selectedItems.has(firstActiveItem.current)) { - let searchIndex = firstItemIndex === null; - - if (firstItemIndex !== null) { - const itemAtIndex = explorer.items[firstItemIndex]; - const uniqueId = itemAtIndex && explorer.getItemUniqueId(itemAtIndex); - if (uniqueId !== firstItemId) searchIndex = true; - } - - // Search for the firstActiveItem index if we're missing the index or the ExplorerItem - // at the stored index position doesn't match with the firstActiveItem - if (searchIndex) { - const item = explorer.items[currentItemIndex]; - if (!item) return; - - if (explorer.getItemUniqueId(item) === firstItemId) { - firstItemIndex = currentItemIndex; - } else { - const index = explorer.items.findIndex((item) => { - return explorer.getItemUniqueId(item) === firstItemId; - }); - - if (index === -1) return; - - firstItemIndex = index; - } - - updateFirstActiveItem(firstActiveItem.current, { itemIndex: firstItemIndex }); - } - } else { - const item = explorer.items[currentItemIndex]; - if (!item) return; - - firstItemId = explorer.getItemUniqueId(item); - firstItemIndex = currentItemIndex; - - updateFirstActiveItem(item, { itemIndex: firstItemIndex }); - } - - if (firstItemIndex === null) return; - - const addItems: ExplorerItem[] = []; - const removeItems: ExplorerItem[] = []; - - // Determine if we moved further away from the first selected item. - // This is used to determine if we should add or remove items from the selection. - let movedAwayFromFirstItem = false; - - if (firstItemIndex === currentItemIndex) { - movedAwayFromFirstItem = newIndex !== currentItemIndex; - } else if (firstItemIndex < currentItemIndex) { - movedAwayFromFirstItem = newIndex > currentItemIndex; - } else { - movedAwayFromFirstItem = newIndex < currentItemIndex; - } - - // Determine if the new index is on the other side - // of the firstActiveItem(head) based on the current index. - const isIndexOverHead = (index: number) => - (currentItemIndex < firstItemIndex && index > firstItemIndex) || - (currentItemIndex > firstItemIndex && index < firstItemIndex); - - const itemsCount = - Math.abs(currentItemIndex - newIndex) + (isIndexOverHead(newIndex) ? 1 : 0); - - for (let i = 0; i < itemsCount; i++) { - const _i = i + (movedAwayFromFirstItem ? 1 : 0); - const index = currentItemIndex + (currentItemIndex < newIndex ? _i : -_i); - - const item = explorer.items[index]; - if (!item || explorer.getItemUniqueId(item) === firstItemId) continue; - - const addItem = isIndexOverHead(index) || movedAwayFromFirstItem; - (addItem ? addItems : removeItems).push(item); - } - - if (hasFirstActiveItemChanged.current) { - if (firstActiveItem.current) addItems.push(firstActiveItem.current); - explorer.resetSelectedItems(addItems); - hasFirstActiveItemChanged.current = false; - } else { - if (addItems.length > 0) explorer.addSelectedItem(addItems); - if (removeItems.length > 0) explorer.removeSelectedItem(removeItems); - } + explorerView.handleWindowsGridShiftSelection(newIndex); + return; } - updateActiveItem(newSelectedItem.data, { itemIndex: newIndex }); - updateFirstActiveItem( - e.shiftKey ? firstActiveItem.current ?? newSelectedItem.data : newSelectedItem.data, - { itemIndex: e.shiftKey ? firstActiveItemIndex.current ?? currentItemIndex : newIndex } - ); - - scrollToItem(newSelectedItem); + explorerView.updateActiveItem(explorer.getItemUniqueId(newSelectedItem), { + updateFirstItem: true + }); }; // Debounce keybinds to prevent weird execution order @@ -346,6 +148,4 @@ export const useKeySelection = (grid: Grid, options: Options = { scrollToEnd: fa useShortcut('explorerDown', (e) => debounce(() => handleNavigation(e, 'down'))); useShortcut('explorerLeft', (e) => debounce(() => handleNavigation(e, 'left'))); useShortcut('explorerRight', (e) => debounce(() => handleNavigation(e, 'right'))); - - return { updateActiveItem, updateFirstActiveItem }; }; diff --git a/interface/app/$libraryId/Explorer/View/GridView/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/index.tsx index ea1bd6974..daf89cae9 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/index.tsx @@ -43,10 +43,10 @@ export const GridView = () => { ) }); - const { updateActiveItem } = useKeySelection(grid, { scrollToEnd: true }); + useKeySelection(grid, { scrollToEnd: true }); return ( - + {(index) => { const item = explorer.items?.[index]; diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx index b8021277a..0249cfd88 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -15,7 +15,6 @@ import { isNonEmptyObject } from '~/util'; import { useLayoutContext } from '../../../Layout/Context'; import { useExplorerContext } from '../../Context'; import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store'; -import { explorerStore } from '../../store'; import { uniqueId } from '../../util'; import { useExplorerViewContext } from '../Context'; import { useDragScrollable } from '../useDragScrollable'; @@ -515,6 +514,10 @@ export const ListView = memo(() => { useEffect(() => setRanges([]), [explorerSettings.order]); + useEffect(() => { + if (explorer.selectedItems.size === 0) setRanges([]); + }, [explorer.selectedItems]); + useEffect(() => { // Reset icon size if it's not a valid size if (!LIST_VIEW_ICON_SIZES[explorerSettings.listViewIconSize]) { @@ -653,13 +656,6 @@ export const ListView = memo(() => { }; }, [sized, isLeftMouseDown, quickPreview.open]); - useShortcut('explorerEscape', () => { - if (!explorerView.selectable || explorer.selectedItems.size === 0) return; - if (explorerStore.isCMDPOpen) return; - explorer.resetSelectedItems([]); - setRanges([]); - }); - useShortcut('explorerUp', (e) => { keyboardHandler(e, 'ArrowUp'); }); @@ -736,6 +732,31 @@ export const ListView = memo(() => { // Set list offset useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []); + // Handle active item selection + // TODO: This is a temporary solution + useEffect(() => { + return () => { + const firstRange = getRangeByIndex(0); + if (!firstRange) return; + + const lastRange = getRangeByIndex(ranges.length - 1); + if (!lastRange) return; + + const firstItem = firstRange.start.original; + const lastItem = lastRange.end.original; + + explorerView.updateFirstActiveItem(explorer.getItemUniqueId(firstItem)); + explorerView.updateActiveItem(explorer.getItemUniqueId(lastItem)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + ranges, + getRangeByIndex, + explorerView.updateFirstActiveItem, + explorerView.updateActiveItem, + explorer.getItemUniqueId + ]); + return (
{ orderDirection ]); - const { updateActiveItem } = useKeySelection(grid); + useKeySelection(grid); return (
{ > {isSortingByDate && } - + {virtualRows.map((virtualRow) => ( {columnVirtualizer.getVirtualItems().map((virtualColumn) => { diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 1a423093d..6a7807909 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -10,7 +10,7 @@ import { } from '@sd/client'; import { dialogManager } from '@sd/ui'; import { Loader } from '~/components'; -import { useKeyMatcher, useShortcut } from '~/hooks'; +import { useKeyMatcher, useMouseItemResize, useShortcut } from '~/hooks'; import { useRoutingContext } from '~/RoutingContext'; import { isNonEmpty } from '~/util'; @@ -24,15 +24,16 @@ import { explorerStore } from '../store'; import { useExplorerDroppable } from '../useExplorerDroppable'; import { useExplorerOperatingSystem } from '../useExplorerOperatingSystem'; import { useExplorerSearchParams } from '../util'; -import { ViewContext, type ExplorerViewContext } from './Context'; +import { ExplorerViewContext, ExplorerViewContextProps } from './Context'; import { DragScrollable } from './DragScrollable'; import { GridView } from './GridView'; import { ListView } from './ListView'; import { MediaView } from './MediaView'; +import { useActiveItem } from './useActiveItem'; import { useViewItemDoubleClick } from './ViewItem'; export interface ExplorerViewProps - extends Omit { + extends Pick { emptyNotice?: JSX.Element; } @@ -40,10 +41,11 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem(); const explorer = useExplorerContext(); - const [isContextMenuOpen, isRenaming, drag] = useSelector(explorerStore, (s) => [ + const [isContextMenuOpen, isRenaming, drag, isCMDPOpen] = useSelector(explorerStore, (s) => [ s.isContextMenuOpen, s.isRenaming, - s.drag + s.drag, + s.isCMDPOpen ]); const { layoutMode } = explorer.useSettingsSnapshot(); @@ -59,7 +61,11 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { const [showLoading, setShowLoading] = useState(false); const selectable = - explorer.selectable && !isContextMenuOpen && !isRenaming && !quickPreviewStore.open; + explorer.selectable && + !isContextMenuOpen && + !isRenaming && + !quickPreviewStore.open && + !isCMDPOpen; // Can stay here until we add columns view // Once added, the provided parent related logic should move to useExplorerDroppable @@ -86,12 +92,12 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { }) }); + const activeItem = useActiveItem(); + useShortcuts(); - useShortcut('explorerEscape', () => { - if (!selectable || explorer.selectedItems.size === 0) return; - if (explorerStore.isCMDPOpen) return; - explorer.resetSelectedItems([]); + useShortcut('explorerEscape', () => explorer.resetSelectedItems([]), { + disabled: !selectable || explorer.selectedItems.size === 0 }); useEffect(() => { @@ -139,10 +145,13 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { return () => element.removeEventListener('wheel', handleWheel); }, [explorer.scrollRef, drag?.type]); + // Handle resizing of items in the Explorer grid and list view using the mouse wheel + useMouseItemResize(); + if (!explorer.layouts[layoutMode]) return null; return ( - +
{ {quickPreview.ref && createPortal(, quickPreview.ref)} - + ); }; @@ -201,6 +210,7 @@ const useShortcuts = () => { useShortcut('toggleQuickPreview', (e) => { if (isRenaming || dialogManager.isAnyDialogOpen()) return; if (explorerStore.isCMDPOpen) return; + if (explorer.selectedItems.size === 0) return; e.preventDefault(); getQuickPreviewStore().open = !quickPreviewStore.open; }); diff --git a/interface/app/$libraryId/Explorer/View/useActiveItem.tsx b/interface/app/$libraryId/Explorer/View/useActiveItem.tsx new file mode 100644 index 000000000..5a1915fa3 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/useActiveItem.tsx @@ -0,0 +1,124 @@ +import { MutableRefObject, useCallback, useRef } from 'react'; + +import { useExplorerContext } from '../Context'; +import { useExplorerOperatingSystem } from '../useExplorerOperatingSystem'; + +type ActiveItem = string | null; + +type UpdateActiveItem = ActiveItem | ((current: ActiveItem) => ActiveItem); + +interface UpdateActiveItemOptions { + /** + * Whether to update the first active item. + * @default false + */ + updateFirstItem?: boolean; +} + +export function useActiveItem() { + const explorer = useExplorerContext(); + + const { explorerOperatingSystem } = useExplorerOperatingSystem(); + + // The item that further selection will move from (shift + arrow for example). + const activeItem = useRef(null); + + // The first active item that acts as a head. + // Only used for windows OS to keep track of the first selected item. + const firstActiveItem = useRef(null); + + const updateItem = useCallback((item: MutableRefObject, data: UpdateActiveItem) => { + item.current = typeof data === 'function' ? data(firstActiveItem.current) : data; + }, []); + + const updateFirstActiveItem = useCallback( + (item: UpdateActiveItem) => { + if (explorerOperatingSystem !== 'windows') return; + updateItem(firstActiveItem, item); + }, + [explorerOperatingSystem, updateItem] + ); + + const updateActiveItem = useCallback( + (item: UpdateActiveItem, options: UpdateActiveItemOptions = {}) => { + updateItem(activeItem, item); + if (options.updateFirstItem) updateFirstActiveItem(item); + }, + [updateFirstActiveItem, updateItem] + ); + + const getNewActiveItemIndex = useCallback(() => { + const [item] = explorer.selectedItems; + + const uniqueId = item && explorer.getItemUniqueId(item); + if (!uniqueId) return; + + return explorer.itemsMap.get(uniqueId)?.index; + + // No need to include the whole explorer object here + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [explorer.selectedItems, explorer.itemsMap, explorer.getItemUniqueId]); + + const getItemIndex = useCallback( + (activeItem: MutableRefObject) => { + if (!activeItem.current) return; + return explorer.itemsMap.get(activeItem.current)?.index; + }, + [explorer.itemsMap] + ); + + const getActiveItemIndex = useCallback( + () => getItemIndex(activeItem) ?? getNewActiveItemIndex(), + [getItemIndex, getNewActiveItemIndex] + ); + + const getFirstActiveItemIndex = useCallback( + () => getItemIndex(firstActiveItem), + [getItemIndex] + ); + + const handleWindowsGridShiftSelection = useCallback( + (newIndex: number) => { + if (!explorer.items) return; + + const newItem = explorer.items[newIndex]; + if (!newItem) return; + + const activeItemIndex = getActiveItemIndex() ?? 0; + const firstActiveItemIndex = getFirstActiveItemIndex() ?? activeItemIndex; + + const item = explorer.items[firstActiveItemIndex]; + if (!item) return; + + const items = explorer.items.slice( + Math.min(firstActiveItemIndex, newIndex), + Math.max(firstActiveItemIndex, newIndex) + 1 + ); + + explorer.resetSelectedItems(items); + + updateActiveItem(explorer.getItemUniqueId(newItem)); + updateFirstActiveItem(explorer.getItemUniqueId(item)); + }, + + // No need to include the whole explorer object here + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + explorer.items, + explorer.getItemUniqueId, + explorer.resetSelectedItems, + getActiveItemIndex, + getFirstActiveItemIndex, + updateActiveItem, + updateFirstActiveItem + ] + ); + + return { + getActiveItemIndex, + getFirstActiveItemIndex, + updateActiveItem, + updateFirstActiveItem, + handleWindowsGridShiftSelection + }; +} diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 9048d5614..f11151f69 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -4,6 +4,7 @@ import { explorerLayout, useExplorerLayoutStore, useLibrarySubscription, + useRspcLibraryContext, useSelector } from '@sd/client'; import { useShortcut } from '~/hooks'; @@ -40,19 +41,23 @@ export default function Explorer(props: PropsWithChildren) { const showInspector = useSelector(explorerStore, (s) => s.showInspector); const showPathBar = explorer.showPathBar && layoutStore.showPathBar; - + const rspc = useRspcLibraryContext(); // Can we put this somewhere else -_- useLibrarySubscription(['jobs.newThumbnail'], { - onStarted: () => { - console.log('Started RSPC subscription new thumbnail'); - }, - onError: (err) => { - console.error('Error in RSPC subscription new thumbnail', err); - }, onData: (thumbKey) => { explorerStore.addNewThumbnail(thumbKey); } }); + useLibrarySubscription(['jobs.newFilePathIdentified'], { + onData: (ids) => { + if (ids?.length > 0) { + // I had planned to somehow fetch the Object, but its a lot more work than its worth given + // id have to fetch the file_path explicitly and patch the query + // for now, it seems to work a treat just invalidating the whole query + rspc.queryClient.invalidateQueries(['search.paths']); + } + } + }); useShortcut('showPathBar', (e) => { e.stopPropagation(); diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index b4e114dbe..a50865499 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -122,10 +122,9 @@ export const explorerStore = proxy({ addNewThumbnail: (thumbKey: string[]) => { explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey)); }, - // this should be done when the explorer query is refreshed - // prevents memory leak - resetNewThumbnails: () => { + resetCache: () => { explorerStore.newThumbnails.clear(); + // explorerStore.newFilePathsIdentified.clear(); } }); diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 99986a894..36c798e62 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -158,12 +158,12 @@ function useSelectedItems(items: ExplorerItem[] | null) { const itemsMap = useMemo( () => - (items ?? []).reduce((items, item) => { + (items ?? []).reduce((items, item, i) => { const hash = itemHashesWeakMap.current.get(item) ?? uniqueId(item); itemHashesWeakMap.current.set(item, hash); - items.set(hash, item); + items.set(hash, { index: i, data: item }); return items; - }, new Map()), + }, new Map()), [items] ); @@ -171,7 +171,7 @@ function useSelectedItems(items: ExplorerItem[] | null) { () => [...selectedItemHashes.value].reduce((items, hash) => { const item = itemsMap.get(hash); - if (item) items.add(item); + if (item) items.add(item.data); return items; }, new Set()), [itemsMap, selectedItemHashes] @@ -183,6 +183,7 @@ function useSelectedItems(items: ExplorerItem[] | null) { ); return { + itemsMap, selectedItems, selectedItemHashes, getItemUniqueId, diff --git a/interface/app/$libraryId/Explorer/useExplorerItemData.tsx b/interface/app/$libraryId/Explorer/useExplorerItemData.tsx new file mode 100644 index 000000000..06d2f2af9 --- /dev/null +++ b/interface/app/$libraryId/Explorer/useExplorerItemData.tsx @@ -0,0 +1,34 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; +import { getExplorerItemData, useSelector, type ExplorerItem } from '@sd/client'; + +import { explorerStore, flattenThumbnailKey } from './store'; + +// This is where we intercept the state of the explorer item to determine if we should rerender +// This hook is used inside every thumbnail in the explorer +export function useExplorerItemData(explorerItem: ExplorerItem) { + const newThumbnail = useSelector(explorerStore, (s) => { + const thumbnailKey = + explorerItem.type === 'Label' + ? // labels have .thumbnails, plural + explorerItem.thumbnails?.[0] + : // all other explorer items have .thumbnail singular + 'thumbnail' in explorerItem && explorerItem.thumbnail; + + return !!(thumbnailKey && s.newThumbnails.has(flattenThumbnailKey(thumbnailKey))); + }); + + return useMemo(() => { + const itemData = getExplorerItemData(explorerItem); + + if (!itemData.hasLocalThumbnail) { + itemData.hasLocalThumbnail = newThumbnail; + } + + return itemData; + // whatever goes here, is what can cause an atomic re-render of an explorer item + // this is used for when new thumbnails are generated, and files identified + }, [explorerItem, newThumbnail]); +} + +export type ExplorerItemData = ReturnType; diff --git a/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx b/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx index b7d5a9735..05080a611 100644 --- a/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx +++ b/interface/app/$libraryId/Explorer/useExplorerOperatingSystem.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; -import { proxy, useSnapshot } from 'valtio'; +import { useSnapshot } from 'valtio'; +import { valtioPersist } from '@sd/client'; import { useOperatingSystem } from '~/hooks'; import { OperatingSystem } from '~/util/Platform'; -export const explorerOperatingSystemStore = proxy({ +export const explorerOperatingSystemStore = valtioPersist('sd-explorer-behavior', { os: undefined as Extract | undefined }); diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index eeef7d99d..7aee76bcf 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -1,37 +1,11 @@ -import { useMemo } from 'react'; -import { getExplorerItemData, useSelector, type ExplorerItem } from '@sd/client'; +import { type ExplorerItem } from '@sd/client'; import { ExplorerParamsSchema } from '~/app/route-schemas'; import { useZodSearchParams } from '~/hooks'; -import { explorerStore, flattenThumbnailKey } from './store'; - export function useExplorerSearchParams() { return useZodSearchParams(ExplorerParamsSchema); } -export function useExplorerItemData(explorerItem: ExplorerItem) { - const newThumbnail = useSelector(explorerStore, (s) => { - const firstThumbnail = - explorerItem.type === 'Label' - ? explorerItem.thumbnails?.[0] - : 'thumbnail' in explorerItem && explorerItem.thumbnail; - - return !!(firstThumbnail && s.newThumbnails.has(flattenThumbnailKey(firstThumbnail))); - }); - - return useMemo(() => { - const itemData = getExplorerItemData(explorerItem); - - if (!itemData.hasLocalThumbnail) { - itemData.hasLocalThumbnail = newThumbnail; - } - - return itemData; - }, [explorerItem, newThumbnail]); -} - -export type ExplorerItemData = ReturnType; - export const pubIdToString = (pub_id: number[]) => pub_id.map((b) => b.toString(16).padStart(2, '0')).join(''); diff --git a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx index a4eb3cb3b..2a9b9f403 100644 --- a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx @@ -58,13 +58,13 @@ export default () => { to="settings/library/general" className="font-medium" /> - alert('TODO: Not implemented yet!')} className="font-medium" - /> + /> */} ); }; diff --git a/interface/app/$libraryId/Layout/Sidebar/index.tsx b/interface/app/$libraryId/Layout/Sidebar/index.tsx index 9a8b1068a..3cfbfb1a9 100644 --- a/interface/app/$libraryId/Layout/Sidebar/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/index.tsx @@ -8,6 +8,7 @@ import Local from './sections/Local'; import Locations from './sections/Locations'; import SavedSearches from './sections/SavedSearches'; import Tags from './sections/Tags'; +import Tools from './sections/Tools'; import SidebarLayout from './SidebarLayout'; export default function Sidebar() { @@ -29,7 +30,7 @@ export default function Sidebar() { )} - {/* */} + ); } diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index 3bb747444..1bdd51cb2 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -1,4 +1,4 @@ -import { EjectSimple } from '@phosphor-icons/react'; +import { ArrowRight, EjectSimple } from '@phosphor-icons/react'; import clsx from 'clsx'; import { PropsWithChildren, useMemo } from 'react'; import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client'; @@ -6,6 +6,7 @@ import { Button, toast, tw } from '@sd/ui'; import { Icon, IconName } from '~/components'; import { useLocale } from '~/hooks'; import { useHomeDir } from '~/hooks/useHomeDir'; +import { usePlatform } from '~/util/Platform'; import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable'; import { useExplorerSearchParams } from '../../../../Explorer/util'; @@ -31,6 +32,7 @@ const SidebarIcon = ({ name }: { name: IconName }) => { }; export default function LocalSection() { + const platform = usePlatform(); const locationsQuery = useLibraryQuery(['locations.list']); useNodes(locationsQuery.data?.nodes); const locations = useCache(locationsQuery.data?.items); diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Tools/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Tools/index.tsx new file mode 100644 index 000000000..b8d89ec36 --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Tools/index.tsx @@ -0,0 +1,79 @@ +import { ArrowSquareOut, Trash } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { PropsWithChildren } from 'react'; +import { Button, toast, tw } from '@sd/ui'; +import { Icon, IconName } from '~/components'; +import { useLocale, useOperatingSystem } from '~/hooks'; +import { usePlatform } from '~/util/Platform'; + +import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable'; +import { useExplorerSearchParams } from '../../../../Explorer/util'; +import SidebarLink from '../../SidebarLayout/Link'; +import Section from '../../SidebarLayout/Section'; +import { SeeMore } from '../../SidebarLayout/SeeMore'; + +const Name = tw.span`truncate`; + +const OpenToButton = ({ className }: { className?: string; what_is_opening?: string }) => ( + +); + +export default function ToolsSection() { + const platform = usePlatform(); + + const { t } = useLocale(); + + const os = useOperatingSystem(); + return ( +
+ + {platform.openTrashInOsExplorer && ( + + )} + +
+ ); +} + +const EphemeralLocation = ({ + children, + path, + navigateTo +}: PropsWithChildren<{ path: string; navigateTo: string }>) => { + const [{ path: ephemeralPath }] = useExplorerSearchParams(); + + const { isDroppable, className, setDroppableRef } = useExplorerDroppable({ + id: `sidebar-ephemeral-location-${path}`, + allow: ['Path', 'NonIndexedPath', 'Object'], + data: { type: 'location', path }, + disabled: navigateTo.startsWith('location/') || ephemeralPath === path, + navigateTo: navigateTo + }); + + return ( + + {children} + + ); +}; diff --git a/interface/app/$libraryId/Spacedrop/index.tsx b/interface/app/$libraryId/Spacedrop/index.tsx index a11d68061..9b43d925a 100644 --- a/interface/app/$libraryId/Spacedrop/index.tsx +++ b/interface/app/$libraryId/Spacedrop/index.tsx @@ -112,12 +112,12 @@ export function Spacedrop({ triggerClose }: { triggerClose: () => void }) { Spacedrop -
+

{t('spacedrop_description')}

{discoveredPeers.size === 0 && (

{t('no_nodes_found')}

diff --git a/interface/app/$libraryId/ephemeral.tsx b/interface/app/$libraryId/ephemeral.tsx index f5eb7fa95..d59f6d528 100644 --- a/interface/app/$libraryId/ephemeral.tsx +++ b/interface/app/$libraryId/ephemeral.tsx @@ -1,17 +1,16 @@ +import { type AlphaClient } from '@oscartbeaumont-sd/rspc-client/v2'; import { ArrowLeft, ArrowRight, Info } from '@phosphor-icons/react'; import * as Dialog from '@radix-ui/react-dialog'; import { iconNames } from '@sd/assets/util'; import clsx from 'clsx'; import { memo, Suspense, useDeferredValue, useMemo } from 'react'; -import { match } from 'ts-pattern'; import { ExplorerItem, getExplorerItemData, - ItemData, - SortOrder, useLibraryContext, useNormalisedCache, - useUnsafeStreamedQuery + useUnsafeStreamedQuery, + type EphemeralPathOrder } from '@sd/client'; import { Button, Tooltip } from '@sd/ui'; import { PathParamsSchema, type PathParams } from '~/app/route-schemas'; @@ -42,12 +41,6 @@ import { useTopBarContext } from './TopBar/Context'; import { TopBarPortal } from './TopBar/Portal'; import TopBarButton from './TopBar/TopBarButton'; -export type EphemeralPathOrder = - | { field: 'name'; value: SortOrder } - | { field: 'sizeInBytes'; value: SortOrder } - | { field: 'dateCreated'; value: SortOrder } - | { field: 'dateModified'; value: SortOrder }; - const NOTICE_ITEMS: { icon: keyof typeof iconNames; name: string }[] = [ { icon: 'Folder', @@ -195,16 +188,16 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => { { library_id: libraryCtx.library.uuid, arg: { - from: 'path', path: path ?? (os === 'windows' ? 'C:\\' : '/'), - withHiddenFiles: settingsSnapshot.showHiddenFiles + withHiddenFiles: settingsSnapshot.showHiddenFiles, + order: settingsSnapshot.order } } ], { enabled: path != null, suspense: true, - onSuccess: () => explorerStore.resetNewThumbnails(), + onSuccess: () => explorerStore.resetCache(), onBatch: (item) => { cache.withNodes(item.nodes); } @@ -232,52 +225,8 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => { } } - // We sort on the frontend, as the backend streams in entries from cloud locations out of order - const order = settingsSnapshot.order; - if (order !== null) { - const getValue = match(order.field) - .with('name', () => (a: ItemData) => a.name) - .with('sizeInBytes', () => (a: ItemData) => a.size.original) - .with( - 'dateCreated', - () => (a: ItemData) => (a.dateCreated !== null ? new Date(a.dateCreated) : null) - ) - .with( - 'dateModified', - () => (a: ItemData) => - a.dateModified !== null ? new Date(a.dateModified) : null - ) - .exhaustive(); - - return ret.sort((a, b) => { - const aData = getExplorerItemData(a); - const bData = getExplorerItemData(b); - - let result = 0; - - // Put hidden files first (if the files have a hidden property) - if ( - 'hidden' in a.item && - 'hidden' in b.item && - a.item.hidden !== null && - b.item.hidden !== null - ) - result = +b.item.hidden - +a.item.hidden; - - // Group files before folders (within the hidden groups) - result = result || +(aData.kind === 'Folder') - +(bData.kind === 'Folder'); - - // Finally sort by the user defined property & flip the result for descending order if needed - const valueA = getValue(aData); - const valueB = getValue(bData); - result = result || compare(valueA, valueB) * (order.value === 'Asc' ? 1 : -1); - - return result; - }); - } - return ret; - }, [entries, settingsSnapshot.layoutMode, settingsSnapshot.order]); + }, [entries, settingsSnapshot.layoutMode]); const explorer = useExplorer({ items, @@ -327,20 +276,3 @@ export const Component = () => { ); }; - -// Compare two values and return a number based on their relative order -function compare(a: T, b: T) { - if (a !== null && b !== null) { - if (typeof a === 'string') { - return a.localeCompare(b as string); - } else { - // We must avoid equality as Date doesn't support them but if a > b & b > a then a === b - return a < b ? -1 : a > b ? 1 : 0; - } - } - - if (a === null && b !== null) return -1; - if (a !== null && b === null) return 1; - - return 0; -} diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 68b84e780..7f1e2eac5 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -104,7 +104,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) = ], take, paths: { order: explorerSettings.useSettingsSnapshot().order }, - onSuccess: () => explorerStore.resetNewThumbnails() + onSuccess: () => explorerStore.resetCache() }); const explorer = useExplorer({ diff --git a/interface/app/$libraryId/saved-search/$id.tsx b/interface/app/$libraryId/saved-search/$id.tsx index dc0a53e5b..ef3ae382e 100644 --- a/interface/app/$libraryId/saved-search/$id.tsx +++ b/interface/app/$libraryId/saved-search/$id.tsx @@ -77,7 +77,7 @@ function Inner({ id }: { id: number }) { filters: search.allFilters, take: 50, paths: { order: explorerSettings.useSettingsSnapshot().order }, - onSuccess: () => explorerStore.resetNewThumbnails() + onSuccess: () => explorerStore.resetCache() }); const explorer = useExplorer({ diff --git a/interface/app/$libraryId/settings/client/general.tsx b/interface/app/$libraryId/settings/client/general.tsx index 9ba51256b..4a850f276 100644 --- a/interface/app/$libraryId/settings/client/general.tsx +++ b/interface/app/$libraryId/settings/client/general.tsx @@ -1,9 +1,11 @@ -import { FormProvider } from 'react-hook-form'; +import clsx from 'clsx'; +import { Controller, FormProvider } from 'react-hook-form'; import { useBridgeMutation, useBridgeQuery, useConnectedPeers, useDebugState, + useFeatureFlag, useZodForm } from '@sd/client'; import { Button, Card, Input, Select, SelectOption, Slider, Switch, toast, tw, z } from '@sd/ui'; @@ -37,6 +39,8 @@ const LANGUAGE_OPTIONS = [ // Sort the languages by their label LANGUAGE_OPTIONS.sort((a, b) => a.label.localeCompare(b.label)); +const u16 = () => z.number().min(0).max(65535); + export const Component = () => { const node = useBridgeQuery(['nodeState']); const platform = usePlatform(); @@ -50,9 +54,20 @@ export const Component = () => { schema: z .object({ name: z.string().min(1).max(250).optional(), - // p2p_enabled: z.boolean().optional(), - // p2p_port: u16, - // customOrDefault: z.enum(['Custom', 'Default']), + p2p_port: z.discriminatedUnion('type', [ + z.object({ type: z.literal('random') }), + z.object({ type: z.literal('discrete'), value: u16() }) + ]), + p2p_ipv4_enabled: z.boolean().optional(), + p2p_ipv6_enabled: z.boolean().optional(), + p2p_discovery: z + .union([ + z.literal('Everyone'), + z.literal('ContactsOnly'), + z.literal('Disabled') + ]) + .optional(), + p2p_remote_access: z.boolean().optional(), image_labeler_version: z.string().optional(), background_processing_percentage: z.coerce .number({ @@ -66,28 +81,30 @@ export const Component = () => { reValidateMode: 'onChange', defaultValues: { name: node.data?.name, - // p2p_port: node.data?.p2p_port || 0, - // p2p_enabled: node.data?.p2p_enabled, - // customOrDefault: node.data?.p2p_port ? 'Custom' : 'Default', + p2p_port: node.data?.p2p.port || { type: 'random' }, + p2p_ipv4_enabled: node.data?.p2p.ipv4 || true, + p2p_ipv6_enabled: node.data?.p2p.ipv6 || true, + p2p_discovery: node.data?.p2p.discovery || 'Everyone', + p2p_remote_access: node.data?.p2p.remote_access || false, image_labeler_version: node.data?.image_labeler_version ?? undefined, background_processing_percentage: node.data?.preferences.thumbnailer.background_processing_percentage || 50 } }); + const p2p_port = form.watch('p2p_port'); - // const watchCustomOrDefault = form.watch('customOrDefault'); - // const watchP2pEnabled = form.watch('p2p_enabled'); const watchBackgroundProcessingPercentage = form.watch('background_processing_percentage'); useDebouncedFormWatch(form, async (value) => { if (await form.trigger()) { await editNode.mutateAsync({ name: value.name || null, - p2p_ipv4_port: null, - p2p_ipv6_port: null, - p2p_discovery: null, - // p2p_port: value.customOrDefault === 'Default' ? 0 : Number(value.p2p_port), - // p2p_enabled: value.p2p_enabled ?? null, + + p2p_port: (value.p2p_port as any) ?? null, + p2p_ipv4_enabled: value.p2p_ipv4_enabled ?? null, + p2p_ipv6_enabled: value.p2p_ipv6_enabled ?? null, + p2p_discovery: value.p2p_discovery ?? null, + p2p_remote_access: value.p2p_remote_access ?? null, image_labeler_version: value.image_labeler_version ?? null }); @@ -101,14 +118,16 @@ export const Component = () => { node.refetch(); }); - // form.watch((data) => { - // if (Number(data.p2p_port) > 65535) { - // form.setValue('p2p_port', 65535); - // } - // }); + form.watch((data) => { + if (data.p2p_port?.type == 'discrete' && Number(data.p2p_port.value) > 65535) { + form.setValue('p2p_port', { type: 'discrete', value: 65535 }); + } + }); const { t } = useLocale(); + const isP2PWipFeatureEnabled = useFeatureFlag('wipP2P'); + return ( { />
*/} - {/*
-

{t('networking')}

*/} +
+

{t('networking')}

- {/* TODO: Add some UI for this stuff */} - {/* {node.data?.p2p.ipv4.status === 'Listening' || - node.data?.p2p.ipv4.status === 'Enabling' - ? `0.0.0.0:${node.data?.p2p.ipv4?.port || 0}` - : ''} - {node.data?.p2p.ipv6.status === 'Listening' || - node.data?.p2p.ipv6.status === 'Enabling' - ? `[::1]:${node.data?.p2p.ipv6?.port || 0}` - : ''} */} - - {/* { > form.setValue('p2p_enabled', !form.getValues('p2p_enabled'))} - // disabled - onClick={() => toast.info(t('coming_soon'))} + checked={form.watch('p2p_ipv4_enabled') && form.watch('p2p_ipv6_enabled')} + onCheckedChange={(checked) => { + form.setValue('p2p_ipv4_enabled', checked); + form.setValue('p2p_ipv6_enabled', checked); + }} /> - */} - {/* -
- ( + + + {form.watch('p2p_ipv4_enabled') && form.watch('p2p_ipv6_enabled') ? ( + <> + +
- )} - /> - { - form.setValue( - 'p2p_port', - Number(e.target.value.replace(/[^0-9]/g, '')) - ); - }} - /> -
-
*/} - {/*
*/} + { + form.setValue('p2p_port', { + type: 'discrete', + value: Number(e.target.value.replace(/[^0-9]/g, '')) + }); + }} + /> +
+ + {t('ipv6_description')}

+ } + > + + form.setValue('p2p_ipv6_enabled', checked) + } + /> +
+ + {isP2PWipFeatureEnabled && ( + <> + + {t('spacedrop_description')} +

+ } + > + +
+ + +

+ {t('remote_access_description')} +

+

+ WARNING: This protocol has no security at the moment + and effectively gives root access! +

+ + } + > + + form.setValue('p2p_remote_access', checked) + } + /> +
+ + )} + + ) : null} +
); }; diff --git a/interface/app/$libraryId/settings/library/tags/EditForm.tsx b/interface/app/$libraryId/settings/library/tags/EditForm.tsx index dc6cbc0b5..b8a213104 100644 --- a/interface/app/$libraryId/settings/library/tags/EditForm.tsx +++ b/interface/app/$libraryId/settings/library/tags/EditForm.tsx @@ -71,22 +71,22 @@ export default ({ tag, onDelete }: Props) => {
-
+ {/*
- + - + -
+
*/} ); }; diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 7980ce298..498fa0550 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -46,12 +46,20 @@ import './style.scss'; import { useZodRouteParams } from '~/hooks'; +import { useP2PErrorToast } from './p2p'; + // NOTE: all route `Layout`s below should contain // the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself). // the hook should only be included if there's a valid `ClientContext` (so not onboarding) const LibraryIdParamsSchema = z.object({ libraryId: z.string() }); +// Broken out so this always runs after the `Toaster` is merged. +function P2PErrorToast() { + useP2PErrorToast(); + return null; +} + export const createRoutes = (platform: Platform, cache: NormalisedCache) => [ { @@ -68,6 +76,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) => + ); }, diff --git a/interface/app/p2p/index.tsx b/interface/app/p2p/index.tsx index f59a492cf..8ed642292 100644 --- a/interface/app/p2p/index.tsx +++ b/interface/app/p2p/index.tsx @@ -1,49 +1,56 @@ -import { useEffect, useState } from 'react'; -import { useBridgeQuery, useFeatureFlag, useP2PEvents, withFeatureFlag } from '@sd/client'; +import { useEffect, useRef, useState } from 'react'; +import { useBridgeQuery } from '@sd/client'; import { toast } from '@sd/ui'; export function useP2PErrorToast() { - // const nodeState = useBridgeQuery(['nodeState']); - // const [didShowError, setDidShowError] = useState({ - // ipv4: false, - // ipv6: false - // }); + const listeners = useBridgeQuery(['p2p.listeners']); + const didShowError = useRef(false); - // // TODO: This can probally be improved in the future. Theorically if you enable -> disable -> then enable and it fails both enables the error won't be shown. - // useEffect(() => { - // const ipv4Error = - // (nodeState.data?.p2p_enabled && nodeState.data?.p2p.ipv4.status === 'Error') || false; - // const ipv6Error = - // (nodeState.data?.p2p_enabled && nodeState.data?.p2p.ipv6.status === 'Error') || false; + useEffect(() => { + if (!listeners.data) return; + if (didShowError.current) return; - // if (!didShowError.ipv4 && ipv4Error) - // toast.error( - // { - // title: 'Error starting up P2P!', - // body: 'Error creating the IPv4 listener. Please check your firewall settings!' - // }, - // { - // id: 'ipv4-listener-error' - // } - // ); + let body: JSX.Element | undefined; + if (listeners.data.ipv4.type === 'Error' && listeners.data.ipv6.type === 'Error') { + body = ( +
+

+ Error creating the IPv4 and IPv6 listeners. Please check your firewall + settings! +

+

{listeners.data.ipv4.error}

+
+ ); + } else if (listeners.data.ipv4.type === 'Error') { + body = ( +
+

Error creating the IPv4 listeners. Please check your firewall settings!

+

{listeners.data.ipv4.error}

+
+ ); + } else if (listeners.data.ipv6.type === 'Error') { + body = ( +
+

Error creating the IPv6 listeners. Please check your firewall settings!

+

{listeners.data.ipv6.error}

+
+ ); + } - // if (!didShowError.ipv6 && ipv6Error) - // toast.error( - // { - // title: 'Error starting up P2P!', - // body: 'Error creating the IPv6 listener. Please check your firewall settings!' - // }, - // { - // id: 'ipv6-listener-error' - // } - // ); - - // setDidShowError({ - // ipv4: ipv4Error, - // ipv6: ipv6Error - // }); - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [nodeState.data]); + if (body) { + toast.error( + { + title: 'Error starting up networking!', + body + }, + { + id: 'p2p-listener-error' + } + ); + didShowError.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listeners.data]); return null; } diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index 91834b80b..6f2fbdf72 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -18,6 +18,9 @@ export * from './useIsLocationIndexing'; export * from './useIsTextTruncated'; export * from './useKeyMatcher'; export * from './useLocale'; +export * from './useMouseItemResize'; +export * from './usePrefersReducedMotion'; +export * from './useRandomInterval'; export * from './useRedirectToNewLocation'; export * from './useRouteTitle'; export * from './useShortcut'; @@ -25,8 +28,6 @@ export * from './useShowControls'; export * from './useTheme'; export * from './useWindowSize'; export * from './useWindowState'; +export * from './useZodParams'; export * from './useZodRouteParams'; export * from './useZodSearchParams'; -export * from './usePrefersReducedMotion'; -export * from './useRandomInterval'; -export * from './useZodParams'; diff --git a/interface/hooks/useMouseItemResize.ts b/interface/hooks/useMouseItemResize.ts new file mode 100644 index 000000000..842e533b2 --- /dev/null +++ b/interface/hooks/useMouseItemResize.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect } from 'react'; +import { useExplorerContext } from '~/app/$libraryId/Explorer/Context'; +import { getSizes } from '~/app/$libraryId/Explorer/OptionsPanel/ListView/util'; +import { LIST_VIEW_ICON_SIZES } from '~/app/$libraryId/Explorer/View/ListView/useTable'; + +import { useOperatingSystem } from './useOperatingSystem'; + +/** + * Hook that allows resizing of items in the Explorer views for GRID and LIST only - using the mouse wheel. + */ + +export const useMouseItemResize = () => { + const os = useOperatingSystem(); + const explorer = useExplorerContext(); + const { layoutMode } = explorer.useSettingsSnapshot(); + + const handleWheel = useCallback( + (e: WheelEvent) => { + const isList = layoutMode === 'list'; + const deltaYModifier = isList ? -Math.sign(e.deltaY) : -e.deltaY / 10; // Sensitivity adjustment + const newSize = + Number( + isList + ? explorer.settingsStore.listViewIconSize + : explorer.settingsStore.gridItemSize + ) + deltaYModifier; + + const minSize = isList ? 0 : 60; + const maxSize = isList ? 2 : 200; + const clampedSize = Math.max(minSize, Math.min(maxSize, newSize)); + + if (isList) { + const listSizes = getSizes(LIST_VIEW_ICON_SIZES); + explorer.settingsStore.listViewIconSize = listSizes.sizeMap.get(clampedSize) ?? '0'; + } else if (layoutMode === 'grid') { + explorer.settingsStore.gridItemSize = Number(clampedSize.toFixed(0)); + } + }, + [explorer.settingsStore, layoutMode] + ); + + useEffect(() => { + if (os !== 'windows') return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Control') { + document.addEventListener('wheel', handleWheel); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Control') { + document.removeEventListener('wheel', handleWheel); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + }; + }, [os, handleWheel]); +}; diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts index 0ddd99032..73256c2d3 100644 --- a/interface/hooks/useShortcut.ts +++ b/interface/hooks/useShortcut.ts @@ -157,7 +157,11 @@ export const shortcutsStore = valtioPersist( shortcuts as Record ); -export const useShortcut = (shortcut: Shortcuts, func: (e: KeyboardEvent) => void) => { +export const useShortcut = ( + shortcut: Shortcuts, + func: (e: KeyboardEvent) => void, + options: Omit[2], 'when'> & { disabled?: boolean } = {} +) => { const os = useOperatingSystem(true); const shortcuts = useSnapshot(shortcutsStore); const { visible } = useRoutingContext(); @@ -175,7 +179,8 @@ export const useShortcut = (shortcut: Shortcuts, func: (e: KeyboardEvent) => voi return func(e); }, { - when: visible + ...options, + when: visible && !options.disabled } ); }; diff --git a/interface/index.tsx b/interface/index.tsx index 57f8cd37c..1d8799098 100644 --- a/interface/index.tsx +++ b/interface/index.tsx @@ -17,7 +17,6 @@ import { toast, TooltipProvider } from '@sd/ui'; import { createRoutes } from './app'; import { SpacedropProvider } from './app/$libraryId/Spacedrop'; import i18n from './app/I18n'; -import { useP2PErrorToast } from './app/p2p'; import { Devtools } from './components/Devtools'; import { WithPrismTheme } from './components/TextViewer/prism'; import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback'; @@ -79,7 +78,6 @@ export function SpacedriveRouterProvider(props: { export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) { useLoadBackendFeatureFlags(); - useP2PErrorToast(); useInvalidateQuery(); useTheme(); diff --git a/interface/locales/by/common.json b/interface/locales/by/common.json index 8c513fc68..7c8a446c9 100644 --- a/interface/locales/by/common.json +++ b/interface/locales/by/common.json @@ -3,7 +3,7 @@ "about_vision_text": "У многіх з нас ёсць некалькі уліковых запісаў у воблаку, дыскі без рэзервовай копіі і дадзеныя, якія могуць быць згубленыя. Мы залежым ад хмарных сэрвісаў, такіх як Google Photos і iCloud, але іх магчымасці абмежаваныя, а сумяшчальнасць паміж сэрвісамі і аперацыйнымі сістэмамі практычна адсутнічае. Фотагалерэя не павінна быць прывязаная да экасістэме прылады або выкарыстоўвацца для збору рэкламных дадзеных. Яна павінна быць незалежная ад аперацыйнай сістэмы, сталая і належаць асабіста вам. Дадзеныя, якія мы ствараем, - гэта наша спадчына, якое надоўга перажыве нас. Тэхналогія з адкрытым зыходным кодам-адзіны спосаб забяспечыць абсалютны кантроль над дадзенымі, якія вызначаюць наша жыццё, у неабмежаванай маштабе.", "about_vision_title": "Бачанне", "accept": "Прыняць", - "accessed": "Даступна", + "accessed": "Выкарыстаны", "account": "Уліковы запіс", "actions": "Дзеянні", "add": "Дабавіць", @@ -11,7 +11,7 @@ "add_library": "Дабавіць бібліятэку", "add_location": "Дабавіць лакацыю", "add_location_description": "Пашырце магчымасці Spacedrive, дабавіў любімыя лакацыі ў сваю асабістую бібліятэку, для зручнага і эфектыўнага кіравання файламі", - "add_location_tooltip": "Дадайце дадзены шлях у якасці індэксаваная лакацыі", + "add_location_tooltip": "Дадайце шлях у якасці індэксаваная лакацыі", "add_locations": "Дабавіць лакацыі", "add_tag": "Дабавіць тэг", "advanced_settings": "Дадатковыя налады", @@ -24,19 +24,19 @@ "archive_coming_soon": "Архіваванне лакацый хутка з'явіцца...", "archive_info": "Выманне дадзеных з бібліятэкі ў выглядзе архіва, карысна для захавання структуры лакацый.", "are_you_sure": "Вы ўпэўнены?", - "ask_spacedrive": "Pergunte ao Spacedrive", + "ask_spacedrive": "Спытайце Spacedrive", "assign_tag": "Прысвоіць тэг", "audio_preview_not_supported": "Папярэдні прагляд аўдыя не падтрымваецца.", "back": "Назад", - "backups": "Рэзервовыя копіі", - "backups_description": "Кіруйце Вашымі копіямі базы дадзеных Spacedrive", + "backups": "Рэз. копіі", + "backups_description": "Кіруйце вашымі копіямі базы дадзеных Spacedrive", "blur_effects": "Эфекты размыцця", "blur_effects_description": "Да некаторых кампанентаў будзе ўжыты эфект размыцця.", "cancel": "Скасаваць", "cancel_selection": "Скасаваць выбар", "celcius": "Цэльсій", "change": "Змяніць", - "change_view_setting_description": "Change the default explorer view", + "change_view_setting_description": "Змяніць выгляд правадніка па змаўчанні", "changelog": "Што новага", "changelog_page_description": "Даведайцеся, якія новыя магчымасці мы дадалі", "changelog_page_title": "Спіс змен", @@ -44,7 +44,7 @@ "clear_finished_jobs": "Ачысціць скончаныя заданні", "client": "Кліент", "close": "Зачыніць", - "close_command_palette": "Fechar paleta de comandos", + "close_command_palette": "Закрыць панэль каманд", "close_current_tab": "Зачыніць бягучую ўкладку", "clouds": "Воблачныя схов.", "color": "Колер", @@ -65,10 +65,10 @@ "copy_path_to_clipboard": "Капіяваць шлях у буфер памену", "copy_success": "Элементы скапіраваны", "create": "Стварыць", - "create_file_error": "Error creating file", - "create_file_success": "Created new file: {{name}}", - "create_folder_error": "Error creating folder", - "create_folder_success": "Created new folder: {{name}}", + "create_file_error": "Памылка стварэння файла", + "create_file_success": "Створаны новы файл: {{name}}", + "create_folder_error": "Памылка стварэння папкі", + "create_folder_success": "Створана новая папка: {{name}}", "create_library": "Стварыць бібліятэку", "create_library_description": "Бібліятэка - гэта абароненая база дадзеных на прыладзе. Вашы файлы застаюцца на сваіх месцах, бібліятэка парадкуе іх і захоўвае ўсе дадзеныя, злучаныя з Spacedrive.", "create_new_library": "Стварыце новую бібліятэку", @@ -76,9 +76,9 @@ "create_new_tag": "Стварыць новы тэг", "create_new_tag_description": "Выберыце назву і колер.", "create_tag": "Стварыць тэг", - "created": "Створана", + "created": "Створаны", "creating_library": "Стварэнне бібліятэкі...", - "creating_your_library": "Стварэнне Вашай бібліятэкі", + "creating_your_library": "Стварэнне вашай бібліятэкі", "current": "Бягучая", "current_directory": "Бягучая дырэкторыя", "current_directory_with_descendants": "Бягучая дырэкторыя са спадчыннікамі", @@ -87,17 +87,17 @@ "cut_object": "Выразаць аб'ект", "cut_success": "Элементы выразаны", "data_folder": "Папка з дадзенымі", - "date_accessed": "Date Accessed", - "date_created": "Date Created", - "date_indexed": "Date Indexed", - "date_modified": "Date Modified", + "date_accessed": "Дата доступу", + "date_created": "Дата стварэння", + "date_indexed": "Дата індэксавання", + "date_modified": "Дата змены", "debug_mode": "Рэжым адладкі", "debug_mode_description": "Уключыце дадатковыя функцыі адладкі ў дадатку.", "default": "Стандартны", - "default_settings": "Default Settings", + "default_settings": "Налады па змаўчанні", "delete": "Выдаліць", "delete_dialog_title": "Выдаліць {{prefix}} {{type}}", - "delete_forever": "Delete Forever", + "delete_forever": "Выдаліць", "delete_info": "Гэта не выдаліць самой тэчкі на дыску. Будзе выдалена медыя-прэўю.", "delete_library": "Выдаліць бібліятэку", "delete_library_description": "Гэта незваротнае дзеянне, вашы файлы не будуць выдалены, толькі бібліятэка Spacedrive.", @@ -107,7 +107,7 @@ "delete_rule": "Выдаліць правіла", "delete_tag": "Выдаліць тэг", "delete_tag_description": "Вы ўпэўнены, што хочаце выдаліць гэты тэг? Гэта дзеянне няможна скасаваць, і тэгнутые файлы будуць адлучаны.", - "delete_warning": "гэта выдаліць ваш {{type}} назаўжды, у нас пакуль няма сметніцы.", + "delete_warning": "Гэта дзеянне прывядзе да выдалення вашага {{type}}. У дадзены момант гэта дзеянне немагчыма адмяніць. Калі перамясціць файл у корзіну, вы зможаце аднавіць яго пазней.", "description": "Апісанне", "deselect": "Скасаваць выбар", "details": "Падрабязней", @@ -132,10 +132,10 @@ "edit": "Рэдагаваць", "edit_library": "Рэдагаваць бібліятэку", "edit_location": "Рэдагаваць лакацыю", - "empty_file": "Empty file", + "empty_file": "Пусты файл", "enable_networking": "Уключыць сеткавае ўзаемадзеянне", - "enable_networking_description": "Allow your node to communicate with other Spacedrive nodes around you.", - "enable_networking_description_required": "Required for library sync or Spacedrop!", + "enable_networking_description": "Дазвольце вашаму вузлу звязвацца з іншымі вузламі Spacedrive вакол вас.", + "enable_networking_description_required": "Патрабуецца для сінхранізацыі бібліятэкі або Spacedrop!", "encrypt": "Зашыфраваць", "encrypt_library": "Зашыфраваць бібліятэку", "encrypt_library_coming_soon": "Шыфраванне бібліятэкі хутка з'явіцца", @@ -149,9 +149,9 @@ "error_loading_original_file": "Памылка пры загрузцы выточнага файла", "expand": "Разгарнуць", "explorer": "Праваднік", - "explorer_settings": "Explorer settings", + "explorer_settings": "Налады правадніка", "explorer_shortcut_description": "Навігацыя і ўзаемадзеянне з файлавай сістэмай", - "explorer_view": "Explorer view", + "explorer_view": "Выгляд правадніка", "export": "Экспарт", "export_library": "Экспарт бібліятэкі", "export_library_coming_soon": "Экспарт бібліятэкі хутка стане магчымым", @@ -181,7 +181,7 @@ "favorites": "Абранае", "feedback": "Фідбэк", "feedback_is_required": "Патрэбен фідбэк", - "feedback_login_description": "Уваход у сістэму дазваляе нам адказваць на Ваш фідбэк", + "feedback_login_description": "Уваход у сістэму дазваляе нам адказваць на ваш фідбэк", "feedback_placeholder": "Ваш фідбэк...", "feedback_toast_error_message": "Пры адпраўленні вашага фідбэку адбылася абмыла. Калі ласка, паспрабуйце яшчэ раз.", "file_already_exist_in_this_location": "Файл ужо існуе ў гэтай лакацыі", @@ -199,16 +199,16 @@ "generatePreviewMedia_label": "Стварыце папярэдні прагляд медыяфайлаў для гэтай лакацыі", "generate_checksums": "Генерацыя кантрольных сум", "go_back": "Назад", - "go_to_labels": "Ir para etiquetas", - "go_to_location": "Ir para localização", - "go_to_overview": "Ir para visão geral", - "go_to_recents": "Ir para mensagens recentes", - "go_to_settings": "Ir para configurações", - "go_to_tag": "Ir para marcação", + "go_to_labels": "Перайсці да ярлыкоў", + "go_to_location": "Перайсці да лакацыі", + "go_to_overview": "Перайсці да агляду", + "go_to_recents": "Перайсці да нядаўніх", + "go_to_settings": "Перайсці ў налады", + "go_to_tag": "Перайсці да тэгу", "got_it": "Атрымалася", "grid_gap": "Прабел", - "grid_view": "Выгляд сеткай", - "grid_view_notice_description": "Атрымайце візуальны агляд файлаў з дапамогай прагляду сеткай. У гэтым уяўленні файлы і тэчкі адлюстроўваюцца ў выглядзе зменшаных выяў, што дазваляе хутка знайсці патрэбны файл.", + "grid_view": "Значкі", + "grid_view_notice_description": "Атрымайце візуальны агляд файлаў з дапамогай прагляду значкамі. У гэтым уяўленні файлы і тэчкі адлюстроўваюцца ў выглядзе зменшаных выяў, што дазваляе хутка знайсці патрэбны файл.", "hidden_label": "Не дазваляе лакацыі і яе змесціву адлюстроўвацца ў выніковых катэгорыях, пошуку і тэгах, калі не ўлучана функцыя \"Паказваць утоеныя элементы\".", "hide_in_library_search": "Схаваць у пошуку па бібліятэцы", "hide_in_library_search_description": "Схаваць файлы з гэтым тэгам з вынікаў пры пошуку па ўсёй бібліятэцы.", @@ -227,6 +227,8 @@ "install": "Устанавіць", "install_update": "Устанавіць абнаўленне", "installed": "Устаноўлена", + "ipv6": "Сетка IPv6", + "ipv6_description": "Дазволіць аднарангавыя сувязі з выкарыстаннем сеткі IPv6.", "item_size": "Памер элемента", "item_with_count_one": "{{count}} элемент", "item_with_count_other": "{{count}} элементаў", @@ -256,7 +258,7 @@ "library_overview": "Агляд бібліятэкі", "library_settings": "Налады бібліятэкі", "library_settings_description": "Галоўныя налады, што адносяцца да бягучай актыўнай бібліятэкі.", - "list_view": "Выгляд спісам", + "list_view": "Спісам", "list_view_notice_description": "Зручная навігацыя па файлах і тэчках з дапамогай функцыі прагляду спісам. Гэты выгляд адлюстроўвае файлы ў выглядзе простага, спарадкаванага спіса, дазваляючы хутка знаходзіць і атрымваць доступ да патрэбных файлаў.", "loading": "Загрузка", "local": "Лакальна", @@ -273,32 +275,32 @@ "location_type_normal": "Змесціва будзе індэксавацца як есці, новыя файлы не будуць аўтаматычна сартавацца.", "location_type_replica": "Гэта лакацыя з'яўляецца копіяй іншай, яе змесціва будзе аўтаматычна сінхранізавана.", "locations": "Лакацыі", - "locations_description": "Кіруйце Вашымі лакацыямі.", + "locations_description": "Кіруйце вашымі лакацыямі.", "lock": "Заблакаваць", - "log_in": "Log in", + "log_in": "Увайсці", "log_in_with_browser": "Увайдзіце ў сістэму з дапамогай браўзара", "log_out": "Выйсці з сістэмы", "logged_in_as": "Увайшлі ў сістэму як {{email}}", - "logging_in": "Logging in...", + "logging_in": "Уваход у сістэму...", "logout": "Выхад з сістэмы", "manage_library": "Кіраванне бібліятэкай", "managed": "Кіраваны", "media": "Медыя", - "media_view": "Media View", - "media_view_context": "Кантэкст медыя-выгляду", - "media_view_notice_description": "Лёгка знаходзьце фатаграфіі і відэа, прагляд медыя паказвае вынікі, пачынальна з бягучай лакацыі, улучаючы ўкладзеныя каталогі.", + "media_view": "Галерэя", + "media_view_context": "Кантэкст галерэі", + "media_view_notice_description": "Лёгка знаходзіце фатаграфіі і відэа, галерэя паказвае вынікі, пачынаючы з бягучай лакацыі, уключаючы укладзеныя папкі.", "meet_contributors_behind_spacedrive": "Пазнаёмцеся з удзельнікамі праекта Spacedrive", - "meet_title": "Пазнаёмцеся з {{title}}", + "meet_title": "Сустракайце: {{title}}", "miles": "Мілі", "mode": "Рэжым", "modified": "Зменены", "more": "Падрабязней", "more_actions": "Дадатковыя дзеянні...", "more_info": "Падрабязней", - "move_back_within_quick_preview": "Перамяшчэнне назад у рамках хуткага перадпрагляду", + "move_back_within_quick_preview": "Перамяшчэнне назад у рамках хуткага прагляду", "move_files": "Перамясціць файлы", - "move_forward_within_quick_preview": "Перамяшчэнне наперад у рамках хуткага перадпрагляду", - "move_to_trash": "Move to Trash", + "move_forward_within_quick_preview": "Перамяшчэнне наперад у рамках хуткага прагляду", + "move_to_trash": "Перамясціць у сметніцу", "name": "Імя", "navigate_back": "Пераход назад", "navigate_backwards": "Пераход назад", @@ -314,7 +316,7 @@ "networking": "Праца ў сеткі", "networking_port": "Сеткавы порт", "networking_port_description": "Порт для аднарангавай сеткі Spacedrive. Калі ў вас не ўсталявана абмежавальная сетказаслона, гэты параметр ідзе пакінуць адключаным. Не адкрывайце доступу ў Інтэрнэт!", - "new": "New", + "new": "Новае", "new_folder": "Новая папка", "new_library": "Новая бібліятэка", "new_location": "Новая лакацыя", @@ -322,11 +324,11 @@ "new_tab": "Новая ўкладка", "new_tag": "Новы тэг", "new_update_available": "Новая абнаўленне даступна!", - "no_favorite_items": "No favorite items", - "no_items_found": "No items found", + "no_favorite_items": "Няма абраных файлаў", + "no_items_found": "Ничего не найдено", "no_jobs": "Няма заданняў.", - "no_labels": "Sem etiquetas", - "no_nodes_found": "Не знойдзены вузлоў Spacedrive.", + "no_labels": "Няма ярлыкоў", + "no_nodes_found": "Вузлы Spacedrive не знойдзены.", "no_tag_selected": "Тэг не абраны", "no_tags": "Няма тэгаў", "node_name": "Імя вузла", @@ -345,7 +347,7 @@ "open_new_location_once_added": "Адкрыць новую лакацыю пасля дадання", "open_new_tab": "Адкрыць новую ўкладку", "open_object": "Адкрыць аб'ект", - "open_object_from_quick_preview_in_native_file_manager": "Адкрыццё аб'екта з перадпрагляду ў родным файлавым менеджары", + "open_object_from_quick_preview_in_native_file_manager": "Адкрыццё аб'екта з хуткага прагляду ў родным файлавым менеджары", "open_settings": "Адкрыць налады", "open_with": "Адкрыць пры дапамозе", "or": "Ці", @@ -363,26 +365,29 @@ "pause": "Паўза", "peers": "Удзельнікі", "people": "Людзі", - "pin": "шпілька", + "pin": "Замацаваць", "privacy": "Прыватнасць", "privacy_description": "Spacedrive створаны для забеспячэння прыватнасці, таму ў нас адкрыты выточны код і лакальны падыход. Таму мы выразна паказваем, якія дадзеныя перадаюцца нам.", - "quick_preview": "Хуткі перадпрагляд", + "quick_preview": "Хуткі прагляд", "quick_view": "Хуткі прагляд", + "random": "Выпадковы", "recent_jobs": "Нядаўнія заданні", "recents": "Нядаўняе", - "recents_notice_message": "Recents are created when you open a file.", + "recents_notice_message": "Нядаўнія ствараюцца пры адкрыцці файла.", "regen_labels": "Рэгенераваць цэтлікі", "regen_thumbnails": "Рэгенераваць мініяцюры", "regenerate_thumbs": "Рэгенераваць мініяцюры", "reindex": "Пераіндэксаваць", "reject": "Адхіліць", "reload": "Перазагрузіць", + "remote_access": "Уключыць выдалены доступ", + "remote_access_description": "Разрешите другим узлам напрамую падключыць да гэтага узлу.", "remove": "Выдаліць", "remove_from_recents": "Выдаліць з нядаўніх", "rename": "Перайменаваць", "rename_object": "Перайменаваць аб'ект", "replica": "Рэпліка", - "rescan": "паўторнае сканаванне", + "rescan": "Паўторнае сканаванне", "rescan_directory": "Паўторнае сканаванне дырэкторыі", "rescan_location": "Паўторнае сканаванне лакацыі", "reset": "Скінуць", @@ -396,9 +401,9 @@ "save": "Захаваць", "save_changes": "Захаваць змены", "saved_searches": "Захаваныя пошукавыя запыты", - "search": "Search", + "search": "Пошук", "search_extensions": "Пашырэнні для пошуку", - "search_for_files_and_actions": "Pesquisar por arquivos e ações...", + "search_for_files_and_actions": "Пошук файлаў і дзеянняў...", "secure_delete": "Бяспечнае выдаленне", "security": "Бяспека", "security_description": "Гарантуйце бяспеку вашага кліента.", @@ -412,9 +417,9 @@ "share_bare_minimum_description": "Падзяляцца толькі тым, што з'яўляецеся актыўным карыстачом Spacedrive і некалькімі тэхнічнымі момантамі.", "sharing": "Супольнае выкарыстанне", "sharing_description": "Кіруйце тым, хто мае доступ да вашых бібліятэк.", - "show_details": "Паказаць падрабязна", + "show_details": "Паказаць падрабязнасці", "show_hidden_files": "Паказаць утоеныя файлы", - "show_inspector": "Show Inspector", + "show_inspector": "Паказаць інспектар", "show_object_size": "Паказаць памер аб'екта", "show_path_bar": "Паказаць адрасны радок", "show_slider": "Паказаць паўзунок", @@ -429,9 +434,13 @@ "spacedrive_account": "Уліковы запіс Spacedrive", "spacedrive_cloud": "Spacedrive Cloud", "spacedrive_cloud_description": "У першую чаргу Spacedrive прызначаны для лакальнага выкарыстання, але ў будучыні мы прапануем уласныя дадатковыя хмарныя сэрвісы. На дадзены момант аўтэнтыфікацыя выкарыстоўваецца толькі для функцыі 'Фідбэк', у іншым яна не патрабуецца.", + "spacedrop": "Бачнасць Spacedrop", "spacedrop_a_file": "Адправіць файл з дапамогай Spacedrop", "spacedrop_already_progress": "Spacedrop ужо ў працэсе", - "spacedrop_description": "Дзеляся миттева з прыладамі, якія працуюць з Spacedrive у вашай сетцы.", + "spacedrop_contacts_only": "Толькі для кантактаў", + "spacedrop_description": "Імгненна дзяліцеся з прыладамі, якія працуюць з Spacedrive ў вашай сеткі.", + "spacedrop_disabled": "Адключаны", + "spacedrop_everyone": "Усе", "spacedrop_rejected": "Spacedrop адхілены", "square_thumbnails": "Квадратныя эскізы", "star_on_github": "Паставіць зорку на GitHub", @@ -440,7 +449,7 @@ "support": "Падтрымка", "switch_to_grid_view": "Пераключэнне ў рэжым прагляду сеткай", "switch_to_list_view": "Пераключэнне ў рэжым прагляду спісам", - "switch_to_media_view": "Пераключэнне ў рэжым прагляду мультымедыя", + "switch_to_media_view": "Пераключэнне ў рэжым прагляду галерэяй", "switch_to_next_tab": "Пераключэнне на наступную ўкладку", "switch_to_previous_tab": "Пераключэнне на папярэднюю ўкладку", "sync": "Сінхранізаваць", @@ -450,24 +459,26 @@ "sync_with_library_description": "Калі гэта опцыя ўлучана, вашы прывязаныя клавішы будуць сінхранізаваны з бібліятэкай, у адваротным выпадку яны будуць ужывацца толькі да гэтага кліента.", "tags": "Тэгі", "tags_description": "Кіруйце сваімі тэгамі.", - "tags_notice_message": "No items assigned to this tag.", + "tags_notice_message": "Гэтаму тэгу не прысвоена ні аднаго элемента.", "telemetry_description": "Уключыце, каб падаць распрацоўнікам дэталёвыя дадзеныя пра выкарыстанне і тэлеметрыю для паляпшэння дадатку. Выключыце, каб адпраўляць толькі асноўныя дадзеныя: статус актыўнасці, версію дадатку, версію ядра і платформу (прыкладам, мабільную, ўэб- ці настольную).", "telemetry_title": "Падаванне дадатковай телеметриии і дадзеных пра выкарыстанне", "temperature": "Тэмпература", - "text_file": "Text File", + "text_file": "Тэкставы файл", "text_size": "Памер тэксту", - "thank_you_for_your_feedback": "Дзякуй за Ваш фідбэк!", + "thank_you_for_your_feedback": "Дзякуй за ваш фідбэк!", "thumbnailer_cpu_usage": "Выкарыстанне працэсара пры стварэнні мініяцюр", "thumbnailer_cpu_usage_description": "Абмяжуйце нагрузку на працэсар, якую можа скарыстаць праграма для стварэння мініяцюр у фонавым рэжыме.", "toggle_all": "Уключыць усё", - "toggle_command_palette": "Alternar paleta de comandos", + "toggle_command_palette": "Адкрыць панэль каманд", "toggle_hidden_files": "Уключыць бачнасць утоеных файлаў", - "toggle_image_slider_within_quick_preview": "Адкрыць слайдар выяў у рэжыме перадпрагляду", + "toggle_image_slider_within_quick_preview": "Адкрыць слайдар выяў у рэжыме хуткага прагляду", "toggle_inspector": "Адкрыць інспектар", "toggle_job_manager": "Адкрыць менеджар заданняў", "toggle_metadata": "Паказаць метададзеныя", "toggle_path_bar": "Адкрыць адрасны радок", "toggle_quick_preview": "Адкрыць перадпрагляд", + "tools": "Інструменты", + "trash": "Сметніца", "type": "Тып", "ui_animations": "UI Анімацыі", "ui_animations_description": "Дыялогавыя вокны і іншыя элементы карыстацкага інтэрфейсу будуць анімавацца пры адкрыцці і зачыненні.", @@ -477,9 +488,9 @@ "updated_successfully": "Паспяхова абнавіліся, вы на версіі {{version}}", "usage": "Выкарыстанне", "usage_description": "Інфармацыя пра выкарыстанне бібліятэкі і інфармацыя пра ваша апаратнае забеспячэнне", - "vaccum": "Vaccum", - "vaccum_library": "Vaccum Library", - "vaccum_library_description": "Repack your database to free up unnecessary space.", + "vaccum": "Vacuum", + "vaccum_library": "Vacuum бібліятэкі", + "vaccum_library_description": "Перапакуйце базу дадзеных, каб вызваліць непатрэбную прастору.", "value": "Значэнне", "version": "Версія {{version}}", "video_preview_not_supported": "Папярэдні прагляд відэа не падтрымваецца.", diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json index a587d5f30..040da5d15 100644 --- a/interface/locales/de/common.json +++ b/interface/locales/de/common.json @@ -227,6 +227,8 @@ "install": "Installieren", "install_update": "Update installieren", "installed": "Installiert", + "ipv6": "IPv6-Netzwerk", + "ipv6_description": "Ermöglichen Sie Peer-to-Peer-Kommunikation über IPv6-Netzwerke", "item_size": "Elementgröße", "item_with_count_one": "{{count}} artikel", "item_with_count_other": "{{count}} artikel", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive ist für Datenschutz entwickelt, deshalb sind wir Open Source und \"local first\". Deshalb werden wir sehr deutlich machen, welche Daten mit uns geteilt werden.", "quick_preview": "Schnellvorschau", "quick_view": "Schnellansicht", + "random": "Zufällig", "recent_jobs": "Aktuelle Aufgaben", "recents": "Zuletzt verwendet", "recents_notice_message": "Aktuelle Dateien werden erstellt, wenn Sie eine Datei öffnen.", @@ -377,6 +380,8 @@ "reindex": "Neu indizieren", "reject": "Ablehnen", "reload": "Neu laden", + "remote_access": "Aktivieren Sie den Fernzugriff", + "remote_access_description": "Ermöglichen Sie anderen Knoten, eine direkte Verbindung zu diesem Knoten herzustellen.", "remove": "Entfernen", "remove_from_recents": "Aus den aktuellen Dokumenten entfernen", "rename": "Umbenennen", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedrive-Konto", "spacedrive_cloud": "Spacedrive-Cloud", "spacedrive_cloud_description": "Spacedrive ist immer lokal zuerst, aber wir werden in Zukunft unsere eigenen optionalen Cloud-Dienste anbieten. Derzeit wird die Authentifizierung nur für die Feedback-Funktion verwendet, ansonsten ist sie nicht erforderlich.", + "spacedrop": "Sichtbarkeit von Spacedrops", "spacedrop_a_file": "Eine Datei Spacedropen", "spacedrop_already_progress": "Spacedrop bereits im Gange", + "spacedrop_contacts_only": "Nur Kontakte", "spacedrop_description": "Sofortiges Teilen mit Geräten, die Spacedrive in Ihrem Netzwerk ausführen.", + "spacedrop_disabled": "Deaktiviert", + "spacedrop_everyone": "Alle", "spacedrop_rejected": "Spacedrop abgelehnt", "square_thumbnails": "Quadratische Vorschaubilder", "star_on_github": "Auf GitHub als Favorit markieren", @@ -468,6 +477,8 @@ "toggle_metadata": "Metadaten umschalten", "toggle_path_bar": "Pfadleiste umschalten", "toggle_quick_preview": "Schnellvorschau umschalten", + "tools": "Werkzeuge", + "trash": "Müll", "type": "Typ", "ui_animations": "UI-Animationen", "ui_animations_description": "Dialoge und andere UI-Elemente werden animiert, wenn sie geöffnet und geschlossen werden.", diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 9a91ea5cf..3a10bfe62 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -1,495 +1,504 @@ { - "about": "About", - "about_vision_text": "Many of us have multiple cloud accounts, drives that aren’t backed up and data at risk of loss. We depend on cloud services like Google Photos and iCloud, but are locked in with limited capacity and almost zero interoperability between services and operating systems. Photo albums shouldn’t be stuck in a device ecosystem, or harvested for advertising data. They should be OS agnostic, permanent and personally owned. Data we create is our legacy, that will long outlive us—open source technology is the only way to ensure we retain absolute control over the data that defines our lives, at unlimited scale.", - "about_vision_title": "Vision", - "accept": "Accept", - "accessed": "Accessed", - "account": "Account", - "actions": "Actions", - "add": "Add", - "add_device": "Add Device", - "add_library": "Add Library", - "add_location": "Add Location", - "add_location_description": "Enhance your Spacedrive experience by adding your favorite locations to your personal library, for seamless and efficient file management.", - "add_location_tooltip": "Add path as an indexed location", - "add_locations": "Add Locations", - "add_tag": "Add Tag", - "advanced_settings": "Advanced settings", - "all_jobs_have_been_cleared": "All jobs have been cleared.", - "alpha_release_description": "We are delighted for you to try Spacedrive, now in Alpha release, showcasing exciting new features. As with any initial release, this version may contain some bugs. We kindly request your assistance in reporting any issues you encounter on our Discord channel. Your valuable feedback will greatly contribute to enhancing the user experience.", - "alpha_release_title": "Alpha Release", - "appearance": "Appearance", - "appearance_description": "Change the look of your client.", - "archive": "Archive", - "archive_coming_soon": "Archiving locations is coming soon...", - "archive_info": "Extract data from Library as an archive, useful to preserve Location folder structure.", - "are_you_sure": "Are you sure?", - "ask_spacedrive": "Ask Spacedrive", - "assign_tag": "Assign tag", - "audio_preview_not_supported": "Audio preview is not supported.", - "back": "Back", - "backups": "Backups", - "backups_description": "Manage your Spacedrive database backups.", - "blur_effects": "Blur Effects", - "blur_effects_description": "Some components will have a blur effect applied to them.", - "cancel": "Cancel", - "cancel_selection": "Cancel selection", - "celcius": "Celsius", - "change": "Change", - "change_view_setting_description": "Change the default explorer view", - "changelog": "Changelog", - "changelog_page_description": "See what cool new features we're making", - "changelog_page_title": "Changelog", - "checksum": "Checksum", - "clear_finished_jobs": "Clear out finished jobs", - "client": "Client", - "close": "Close", - "close_command_palette": "Close command palette", - "close_current_tab": "Close current tab", - "clouds": "Clouds", - "color": "Color", - "coming_soon": "Coming soon", - "compress": "Compress", - "configure_location": "Configure Location", - "connected": "Connected", - "contacts": "Contacts", - "contacts_description": "Manage your contacts in Spacedrive.", - "content_id": "Content ID", - "continue": "Continue", - "convert_to": "Convert to", - "coordinates": "Coordinates", - "copied": "Copied", - "copy": "Copy", - "copy_as_path": "Copy as path", - "copy_object": "Copy object", - "copy_path_to_clipboard": "Copy path to clipboard", - "copy_success": "Items copied", - "create": "Create", - "create_file_error": "Error creating file", - "create_file_success": "Created new file: {{name}}", - "create_folder_error": "Error creating folder", - "create_folder_success": "Created new folder: {{name}}", - "create_library": "Create a Library", - "create_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", - "create_new_library": "Create new library", - "create_new_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", - "create_new_tag": "Create New Tag", - "create_new_tag_description": "Choose a name and color.", - "create_tag": "Create Tag", - "created": "Created", - "creating_library": "Creating library...", - "creating_your_library": "Creating your library", - "current": "Current", - "current_directory": "Current Directory", - "current_directory_with_descendants": "Current Directory With Descendants", - "custom": "Custom", - "cut": "Cut", - "cut_object": "Cut object", - "cut_success": "Items cut", - "data_folder": "Data Folder", - "date_accessed": "Date Accessed", - "date_created": "Date Created", - "date_indexed": "Date Indexed", - "date_modified": "Date Modified", - "debug_mode": "Debug mode", - "debug_mode_description": "Enable extra debugging features within the app.", - "default": "Default", - "default_settings": "Default Settings", - "delete": "Delete", - "delete_dialog_title": "Delete {{prefix}} {{type}}", - "delete_forever": "Delete Forever", - "delete_info": "This will not delete the actual folder on disk. Preview media will be deleted.", - "delete_library": "Delete Library", - "delete_library_description": "This is permanent, your files will not be deleted, only the Spacedrive library.", - "delete_location": "Delete Location", - "delete_location_description": "Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted.", - "delete_object": "Delete object", - "delete_rule": "Delete rule", - "delete_tag": "Delete Tag", - "delete_tag_description": "Are you sure you want to delete this tag? This cannot be undone and tagged files will be unlinked.", - "delete_warning": "This will delete your {{type}}. This action cannot be undone as of right now. If you Move to Trash, you can restore it later. If you Delete Forever, it will be gone forever.", - "description": "Description", - "deselect": "Deselect", - "details": "Details", - "devices": "Devices", - "devices_coming_soon_tooltip": "Coming soon! This alpha release doesn't include library sync, it will be ready very soon.", - "dialog": "Dialog", - "dialog_shortcut_description": "To perform actions and operations", - "direction": "Direction", - "disabled": "Disabled", - "disconnected": "Disconnected", - "display_formats": "Display Formats", - "display_name": "Display Name", - "distance": "Distance", - "done": "Done", - "dont_show_again": "Don't show again", - "double_click_action": "Double click action", - "download": "Download", - "downloading_update": "Downloading Update", - "duplicate": "Duplicate", - "duplicate_object": "Duplicate object", - "duplicate_success": "Items duplicated", - "edit": "Edit", - "edit_library": "Edit Library", - "edit_location": "Edit Location", - "empty_file": "Empty file", - "enable_networking": "Enable Networking", - "enable_networking_description": "Allow your node to communicate with other Spacedrive nodes around you.", - "enable_networking_description_required": "Required for library sync or Spacedrop!", - "encrypt": "Encrypt", - "encrypt_library": "Encrypt Library", - "encrypt_library_coming_soon": "Library encryption coming soon", - "encrypt_library_description": "Enable encryption for this library, this will only encrypt the Spacedrive database, not the files themselves.", - "ephemeral_notice_browse": "Browse your files and folders directly from your device.", - "ephemeral_notice_consider_indexing": "Consider indexing your local locations for a faster and more efficient exploration.", - "erase": "Erase", - "erase_a_file": "Erase a file", - "erase_a_file_description": "Configure your erasure settings.", - "error": "Error", - "error_loading_original_file": "Error loading original file", - "expand": "Expand", - "explorer": "Explorer", - "explorer_settings": "Explorer settings", - "explorer_shortcut_description": "To navigate and interact with the file system", - "explorer_view": "Explorer view", - "export": "Export", - "export_library": "Export Library", - "export_library_coming_soon": "Export Library coming soon", - "export_library_description": "Export this library to a file.", - "extensions": "Extensions", - "extensions_description": "Install extensions to extend the functionality of this client.", - "fahrenheit": "Fahrenheit", - "failed_to_cancel_job": "Failed to cancel job.", - "failed_to_clear_all_jobs": "Failed to clear all jobs.", - "failed_to_copy_file": "Failed to copy file", - "failed_to_copy_file_path": "Failed to copy file path", - "failed_to_cut_file": "Failed to cut file", - "failed_to_download_update": "Failed to download update", - "failed_to_duplicate_file": "Failed to duplicate file", - "failed_to_generate_checksum": "Failed to generate checksum", - "failed_to_generate_labels": "Failed to generate labels", - "failed_to_generate_thumbnails": "Failed to generate thumbnails", - "failed_to_load_tags": "Failed to load tags", - "failed_to_pause_job": "Failed to pause job.", - "failed_to_reindex_location": "Failed to re-index location", - "failed_to_remove_file_from_recents": "Failed to remove file from recents", - "failed_to_remove_job": "Failed to remove job.", - "failed_to_rescan_location": "Failed to rescan location", - "failed_to_resume_job": "Failed to resume job.", - "failed_to_update_location_settings": "Failed to update location settings", - "favorite": "Favorite", - "favorites": "Favorites", - "feedback": "Feedback", - "feedback_is_required": "Feedback is required", - "feedback_login_description": "Logging in allows us to respond to your feedback", - "feedback_placeholder": "Your feedback...", - "feedback_toast_error_message": "There was an error submitting your feedback. Please try again.", - "file_already_exist_in_this_location": "File already exists in this location", - "file_indexing_rules": "File indexing rules", - "filters": "Filters", - "forward": "Forward", - "full_disk_access": "Full disk access", - "full_disk_access_description": "To provide the best experience, we need access to your disk in order to index your files. Your files are only available to you.", - "full_reindex": "Full Reindex", - "full_reindex_info": "Perform a full rescan of this Location.", - "general": "General", - "general_settings": "General Settings", - "general_settings_description": "General settings related to this client.", - "general_shortcut_description": "General usage shortcuts", - "generatePreviewMedia_label": "Generate preview media for this Location", - "generate_checksums": "Generate Checksums", - "go_back": "Go Back", - "go_to_labels": "Go to labels", - "go_to_location": "Go to location", - "go_to_overview": "Go to overview", - "go_to_recents": "Go to recents", - "go_to_settings": "Go to settings", - "go_to_tag": "Go to tag", - "got_it": "Got it", - "grid_gap": "Gap", - "grid_view": "Grid View", - "grid_view_notice_description": "Get a visual overview of your files with Grid View. This view displays your files and folders as thumbnail images, making it easy to quickly identify the file you're looking for.", - "hidden_label": "Prevents the location and its contents from appearing in summary categories, search and tags unless \"Show hidden items\" is enabled.", - "hide_in_library_search": "Hide in Library search", - "hide_in_library_search_description": "Hide files with this tag from results when searching entire library.", - "hide_in_sidebar": "Hide in sidebar", - "hide_in_sidebar_description": "Prevent this tag from showing in the sidebar of the app.", - "hide_location_from_view": "Hide location and contents from view", - "home": "Home", - "icon_size": "Icon size", - "image_labeler_ai_model": "Image label recognition AI model", - "image_labeler_ai_model_description": "The model used to recognize objects in images. Larger models are more accurate but slower.", - "import": "Import", - "indexed": "Indexed", - "indexer_rule_reject_allow_label": "By default, an indexer rule functions as a Reject list, resulting in the exclusion of any files that match its criteria. Enabling this option will transform it into a Allow list, allowing the location to solely index files that meet its specified rules.", - "indexer_rules": "Indexer rules", - "indexer_rules_info": "Indexer rules allow you to specify paths to ignore using globs.", - "install": "Install", - "install_update": "Install Update", - "installed": "Installed", - "item_size": "Item size", - "item_with_count_one": "{{count}} item", - "item_with_count_other": "{{count}} items", - "job_has_been_canceled": "Job has been canceled.", - "job_has_been_paused": "Job has been paused.", - "job_has_been_removed": "Job has been removed.", - "job_has_been_resumed": "Job has been resumed.", - "join": "Join", - "join_discord": "Join Discord", - "join_library": "Join a Library", - "join_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", - "key": "Key", - "key_manager": "Key Manager", - "key_manager_description": "Create encryption keys, mount and unmount your keys to see files decrypted on the fly.", - "keybinds": "Keybinds", - "keybinds_description": "View and manage client keybinds", - "keys": "Keys", - "kilometers": "Kilometers", - "labels": "Labels", - "language": "Language", - "language_description": "Change the language of the Spacedrive interface", - "learn_more_about_telemetry": "Learn more about telemetry", - "libraries": "Libraries", - "libraries_description": "The database contains all library data and file metadata.", - "library": "Library", - "library_name": "Library name", - "library_overview": "Library Overview", - "library_settings": "Library Settings", - "library_settings_description": "General settings related to the currently active library.", - "list_view": "List View", - "list_view_notice_description": "Easily navigate through your files and folders with List View. This view displays your files in a simple, organized list format, allowing you to quickly locate and access the files you need.", - "loading": "Loading", - "local": "Local", - "local_locations": "Local Locations", - "local_node": "Local Node", - "location_connected_tooltip": "Location is being watched for changes", - "location_disconnected_tooltip": "Location is not being watched for changes", - "location_display_name_info": "The name of this Location, this is what will be displayed in the sidebar. Will not rename the actual folder on disk.", - "location_empty_notice_message": "No files found here", - "location_is_already_linked": "Location is already linked", - "location_path_info": "The path to this Location, this is where the files will be stored on disk.", - "location_type": "Location Type", - "location_type_managed": "Spacedrive will sort files for you. If Location isn't empty a \"spacedrive\" folder will be created.", - "location_type_normal": "Contents will be indexed as-is, new files will not be automatically sorted.", - "location_type_replica": "This Location is a replica of another, its contents will be automatically synchronized.", - "locations": "Locations", - "locations_description": "Manage your storage locations.", - "lock": "Lock", - "log_in": "Log in", - "log_in_with_browser": "Log in with browser", - "log_out": "Log out", - "logged_in_as": "Logged in as {{email}}", - "logging_in": "Logging in...", - "logout": "Logout", - "manage_library": "Manage Library", - "managed": "Managed", - "media": "Media", - "media_view": "Media View", - "media_view_context": "Media View Context", - "media_view_notice_description": "Discover photos and videos easily, Media View will show results starting at the current location including sub directories.", - "meet_contributors_behind_spacedrive": "Meet the contributors behind Spacedrive", - "meet_title": "Meet {{title}}", - "miles": "Miles", - "mode": "Mode", - "modified": "Modified", - "more": "More", - "more_actions": "More actions...", - "more_info": "More info", - "move_back_within_quick_preview": "Move back within quick preview", - "move_files": "Move Files", - "move_forward_within_quick_preview": "Move forward within quick preview", - "move_to_trash": "Move to Trash", - "name": "Name", - "navigate_back": "Navigate back", - "navigate_backwards": "Navigate backwards", - "navigate_files_downwards": "Navigate files downwards", - "navigate_files_leftwards": "Navigate files leftwards", - "navigate_files_rightwards": "Navigate files rightwards", - "navigate_files_upwards": "Navigate files upwards", - "navigate_forward": "Navigate forward", - "navigate_forwards": "Navigate forwards", - "navigate_to_settings_page": "Navigate to Settings page", - "network": "Network", - "network_page_description": "Other Spacedrive nodes on your LAN will appear here, along with your default OS network mounts.", - "networking": "Networking", - "networking_port": "Networking Port", - "networking_port_description": "The port for Spacedrive's Peer-to-peer networking to communicate on. You should leave this disabled unless you have a restrictive firewall. Do not expose to the internet!", - "new": "New", - "new_folder": "Folder", - "new_library": "New library", - "new_location": "New location", - "new_location_web_description": "As you are using the browser version of Spacedrive you will (for now) need to specify an absolute URL of a directory local to the remote node.", - "new_tab": "New Tab", - "new_tag": "New tag", - "new_update_available": "New Update Available!", - "no_favorite_items": "No favorite items", - "no_items_found": "No items found", - "no_jobs": "No jobs.", - "no_labels": "No labels", - "no_nodes_found": "No Spacedrive nodes were found.", - "no_tag_selected": "No Tag Selected", - "no_tags": "No tags", - "node_name": "Node Name", - "nodes": "Nodes", - "nodes_description": "Manage the nodes connected to this library. A node is an instance of Spacedrive's backend, running on a device or server. Each node carries a copy of the database and synchronizes via peer-to-peer connections in realtime.", - "none": "None", - "normal": "Normal", - "not_you": "Not you?", - "number_of_passes": "# of passes", - "object_id": "Object ID", - "offline": "Offline", - "online": "Online", - "open": "Open", - "open_file": "Open File", - "open_in_new_tab": "Open in new tab", - "open_new_location_once_added": "Open new location once added", - "open_new_tab": "Open new tab", - "open_object": "Open object", - "open_object_from_quick_preview_in_native_file_manager": "Open object from quick preview in native file manager", - "open_settings": "Open Settings", - "open_with": "Open with", - "or": "OR", - "overview": "Overview", - "page": "Page", - "page_shortcut_description": "Different pages in the app", - "pair": "Pair", - "pairing_with_node": "Pairing with {{node}}", - "paste": "Paste", - "paste_object": "Paste object", - "paste_success": "Items pasted", - "path": "Path", - "path_copied_to_clipboard_description": "Path for location {{location}} copied to clipboard.", - "path_copied_to_clipboard_title": "Path copied to clipboard", - "pause": "Pause", - "peers": "Peers", - "people": "People", - "pin": "Pin", - "privacy": "Privacy", - "privacy_description": "Spacedrive is built for privacy, that's why we're open source and local first. So we'll make it very clear what data is shared with us.", - "quick_preview": "Quick Preview", - "quick_view": "Quick view", - "recent_jobs": "Recent Jobs", - "recents": "Recents", - "recents_notice_message": "Recents are created when you open a file.", - "regen_labels": "Regen Labels", - "regen_thumbnails": "Regen Thumbnails", - "regenerate_thumbs": "Regenerate Thumbs", - "reindex": "Re-index", - "reject": "Reject", - "reload": "Reload", - "remove": "Remove", - "remove_from_recents": "Remove From Recents", - "rename": "Rename", - "rename_object": "Rename object", - "replica": "Replica", - "rescan": "Rescan", - "rescan_directory": "Rescan Directory", - "rescan_location": "Rescan Location", - "reset": "Reset", - "resources": "Resources", - "restore": "Restore", - "resume": "Resume", - "retry": "Retry", - "reveal_in_native_file_manager": "Reveal in native file manager", - "revel_in_browser": "Reveal in {{browser}}", - "running": "Running", - "save": "Save", - "save_changes": "Save Changes", - "saved_searches": "Saved Searches", - "search": "Search", - "search_extensions": "Search extensions", - "search_for_files_and_actions": "Search for files and actions...", - "secure_delete": "Secure delete", - "security": "Security", - "security_description": "Keep your client safe.", - "send": "Send", - "settings": "Settings", - "setup": "Set up", - "share": "Share", - "share_anonymous_usage": "Share anonymous usage", - "share_anonymous_usage_description": "Share completely anonymous telemetry data to help the developers improve the app", - "share_bare_minimum": "Share the bare minimum", - "share_bare_minimum_description": "Only share that I am an active user of Spacedrive and a few technical bits", - "sharing": "Sharing", - "sharing_description": "Manage who has access to your libraries.", - "show_details": "Show details", - "show_hidden_files": "Show Hidden Files", - "show_inspector": "Show Inspector", - "show_object_size": "Show Object size", - "show_path_bar": "Show Path Bar", - "show_slider": "Show slider", - "size": "Size", - "size_b": "B", - "size_gb": "GB", - "size_kb": "kB", - "size_mb": "MB", - "size_tb": "TB", - "skip_login": "Skip login", - "sort_by": "Sort by", - "spacedrive_account": "Spacedrive Account", - "spacedrive_cloud": "Spacedrive Cloud", - "spacedrive_cloud_description": "Spacedrive is always local first, but we will offer our own optional cloud services in the future. For now, authentication is only used for the Feedback feature, otherwise it is not required.", - "spacedrop_a_file": "Spacedrop a File", - "spacedrop_already_progress": "Spacedrop already in progress", - "spacedrop_description": "Share instantly with devices running Spacedrive on your network.", - "spacedrop_rejected": "Spacedrop rejected", - "square_thumbnails": "Square Thumbnails", - "star_on_github": "Star on GitHub", - "stop": "Stop", - "success": "Success", - "support": "Support", - "switch_to_grid_view": "Switch to grid view", - "switch_to_list_view": "Switch to list view", - "switch_to_media_view": "Switch to media view", - "switch_to_next_tab": "Switch to next tab", - "switch_to_previous_tab": "Switch to previous tab", - "sync": "Sync", - "syncPreviewMedia_label": "Sync preview media for this Location with your devices", - "sync_description": "Manage how Spacedrive syncs.", - "sync_with_library": "Sync with Library", - "sync_with_library_description": "If enabled, your keybinds will be synced with library, otherwise they will apply only to this client.", - "tags": "Tags", - "tags_description": "Manage your tags.", - "tags_notice_message": "No items assigned to this tag.", - "telemetry_description": "Toggle ON to provide developers with detailed usage and telemetry data to enhance the app. Toggle OFF to send only basic data: your activity status, app version, core version, and platform (e.g., mobile, web, or desktop).", - "telemetry_title": "Share Additional Telemetry and Usage Data", - "temperature": "Temperature", - "text_file": "Text File", - "text_size": "Text size", - "thank_you_for_your_feedback": "Thanks for your feedback!", - "thumbnailer_cpu_usage": "Thumbnailer CPU usage", - "thumbnailer_cpu_usage_description": "Limit how much CPU the thumbnailer can use for background processing.", - "toggle_all": "Toggle All", - "toggle_command_palette": "Toggle command palette", - "toggle_hidden_files": "Toggle hidden files", - "toggle_image_slider_within_quick_preview": "Toggle image slider within quick preview", - "toggle_inspector": "Toggle inspector", - "toggle_job_manager": "Toggle job manager", - "toggle_metadata": "Toggle metadata", - "toggle_path_bar": "Toggle path bar", - "toggle_quick_preview": "Toggle quick preview", - "type": "Type", - "ui_animations": "UI Animations", - "ui_animations_description": "Dialogs and other UI elements will animate when opening and closing.", - "unnamed_location": "Unnamed Location", - "update": "Update", - "update_downloaded": "Update Downloaded. Restart Spacedrive to install", - "updated_successfully": "Updated successfully, you're on version {{version}}", - "usage": "Usage", - "usage_description": "Your library usage and hardware information", - "vaccum": "Vaccum", - "vaccum_library": "Vaccum Library", - "vaccum_library_description": "Repack your database to free up unnecessary space.", - "value": "Value", - "version": "Version {{version}}", - "video_preview_not_supported": "Video preview is not supported.", - "view_changes": "View Changes", - "want_to_do_this_later": "Want to do this later?", - "website": "Website", - "your_account": "Your account", - "your_account_description": "Spacedrive account and information.", - "your_local_network": "Your Local Network", - "your_privacy": "Your Privacy", - "zoom_in": "Zoom In", - "zoom_out": "Zoom Out" + "about": "About", + "about_vision_text": "Many of us have multiple cloud accounts, drives that aren’t backed up and data at risk of loss. We depend on cloud services like Google Photos and iCloud, but are locked in with limited capacity and almost zero interoperability between services and operating systems. Photo albums shouldn’t be stuck in a device ecosystem, or harvested for advertising data. They should be OS agnostic, permanent and personally owned. Data we create is our legacy, that will long outlive us—open source technology is the only way to ensure we retain absolute control over the data that defines our lives, at unlimited scale.", + "about_vision_title": "Vision", + "accept": "Accept", + "accessed": "Accessed", + "account": "Account", + "actions": "Actions", + "add": "Add", + "add_device": "Add Device", + "add_library": "Add Library", + "add_location": "Add Location", + "add_location_description": "Enhance your Spacedrive experience by adding your favorite locations to your personal library, for seamless and efficient file management.", + "add_location_tooltip": "Add path as an indexed location", + "add_locations": "Add Locations", + "add_tag": "Add Tag", + "advanced_settings": "Advanced settings", + "all_jobs_have_been_cleared": "All jobs have been cleared.", + "alpha_release_description": "We are delighted for you to try Spacedrive, now in Alpha release, showcasing exciting new features. As with any initial release, this version may contain some bugs. We kindly request your assistance in reporting any issues you encounter on our Discord channel. Your valuable feedback will greatly contribute to enhancing the user experience.", + "alpha_release_title": "Alpha Release", + "appearance": "Appearance", + "appearance_description": "Change the look of your client.", + "archive": "Archive", + "archive_coming_soon": "Archiving locations is coming soon...", + "archive_info": "Extract data from Library as an archive, useful to preserve Location folder structure.", + "are_you_sure": "Are you sure?", + "ask_spacedrive": "Ask Spacedrive", + "assign_tag": "Assign tag", + "audio_preview_not_supported": "Audio preview is not supported.", + "back": "Back", + "backups": "Backups", + "backups_description": "Manage your Spacedrive database backups.", + "blur_effects": "Blur Effects", + "blur_effects_description": "Some components will have a blur effect applied to them.", + "cancel": "Cancel", + "cancel_selection": "Cancel selection", + "celcius": "Celsius", + "change": "Change", + "change_view_setting_description": "Change the default explorer view", + "changelog": "Changelog", + "changelog_page_description": "See what cool new features we're making", + "changelog_page_title": "Changelog", + "checksum": "Checksum", + "clear_finished_jobs": "Clear out finished jobs", + "client": "Client", + "close": "Close", + "close_command_palette": "Close command palette", + "close_current_tab": "Close current tab", + "clouds": "Clouds", + "color": "Color", + "coming_soon": "Coming soon", + "compress": "Compress", + "configure_location": "Configure Location", + "connected": "Connected", + "contacts": "Contacts", + "contacts_description": "Manage your contacts in Spacedrive.", + "content_id": "Content ID", + "continue": "Continue", + "convert_to": "Convert to", + "coordinates": "Coordinates", + "copied": "Copied", + "copy": "Copy", + "copy_as_path": "Copy as path", + "copy_object": "Copy object", + "copy_path_to_clipboard": "Copy path to clipboard", + "copy_success": "Items copied", + "create": "Create", + "create_file_error": "Error creating file", + "create_file_success": "Created new file: {{name}}", + "create_folder_error": "Error creating folder", + "create_folder_success": "Created new folder: {{name}}", + "create_library": "Create a Library", + "create_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", + "create_new_library": "Create new library", + "create_new_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", + "create_new_tag": "Create New Tag", + "create_new_tag_description": "Choose a name and color.", + "create_tag": "Create Tag", + "created": "Created", + "creating_library": "Creating library...", + "creating_your_library": "Creating your library", + "current": "Current", + "current_directory": "Current Directory", + "current_directory_with_descendants": "Current Directory With Descendants", + "custom": "Custom", + "cut": "Cut", + "cut_object": "Cut object", + "cut_success": "Items cut", + "data_folder": "Data Folder", + "date_accessed": "Date Accessed", + "date_created": "Date Created", + "date_indexed": "Date Indexed", + "date_modified": "Date Modified", + "debug_mode": "Debug mode", + "debug_mode_description": "Enable extra debugging features within the app.", + "default": "Default", + "random": "Random", + "ipv6": "IPv6 networking", + "ipv6_description": "Allow peer-to-peer communication using IPv6 networking", + "default_settings": "Default Settings", + "delete": "Delete", + "delete_dialog_title": "Delete {{prefix}} {{type}}", + "delete_forever": "Delete Forever", + "delete_info": "This will not delete the actual folder on disk. Preview media will be deleted.", + "delete_library": "Delete Library", + "delete_library_description": "This is permanent, your files will not be deleted, only the Spacedrive library.", + "delete_location": "Delete Location", + "delete_location_description": "Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted.", + "delete_object": "Delete object", + "delete_rule": "Delete rule", + "delete_tag": "Delete Tag", + "delete_tag_description": "Are you sure you want to delete this tag? This cannot be undone and tagged files will be unlinked.", + "delete_warning": "This will delete your {{type}}. This action cannot be undone as of right now. If you Move to Trash, you can restore it later. If you Delete Forever, it will be gone forever.", + "description": "Description", + "deselect": "Deselect", + "details": "Details", + "devices": "Devices", + "devices_coming_soon_tooltip": "Coming soon! This alpha release doesn't include library sync, it will be ready very soon.", + "dialog": "Dialog", + "dialog_shortcut_description": "To perform actions and operations", + "direction": "Direction", + "disabled": "Disabled", + "disconnected": "Disconnected", + "display_formats": "Display Formats", + "display_name": "Display Name", + "distance": "Distance", + "done": "Done", + "dont_show_again": "Don't show again", + "double_click_action": "Double click action", + "download": "Download", + "downloading_update": "Downloading Update", + "duplicate": "Duplicate", + "duplicate_object": "Duplicate object", + "duplicate_success": "Items duplicated", + "edit": "Edit", + "edit_library": "Edit Library", + "edit_location": "Edit Location", + "empty_file": "Empty file", + "enable_networking": "Enable Networking", + "enable_networking_description": "Allow your node to communicate with other Spacedrive nodes around you.", + "enable_networking_description_required": "Required for library sync or Spacedrop!", + "spacedrop": "Spacedrop visibility", + "spacedrop_everyone": "Everyone", + "spacedrop_contacts_only": "Contacts Only", + "spacedrop_disabled": "Disabled", + "remote_access": "Enable remote access", + "remote_access_description": "Enable other nodes to directly connect to this node.", + "encrypt": "Encrypt", + "encrypt_library": "Encrypt Library", + "encrypt_library_coming_soon": "Library encryption coming soon", + "encrypt_library_description": "Enable encryption for this library, this will only encrypt the Spacedrive database, not the files themselves.", + "ephemeral_notice_browse": "Browse your files and folders directly from your device.", + "ephemeral_notice_consider_indexing": "Consider indexing your local locations for a faster and more efficient exploration.", + "erase": "Erase", + "erase_a_file": "Erase a file", + "erase_a_file_description": "Configure your erasure settings.", + "error": "Error", + "error_loading_original_file": "Error loading original file", + "expand": "Expand", + "explorer": "Explorer", + "explorer_settings": "Explorer settings", + "explorer_shortcut_description": "To navigate and interact with the file system", + "explorer_view": "Explorer view", + "export": "Export", + "export_library": "Export Library", + "export_library_coming_soon": "Export Library coming soon", + "export_library_description": "Export this library to a file.", + "extensions": "Extensions", + "extensions_description": "Install extensions to extend the functionality of this client.", + "fahrenheit": "Fahrenheit", + "failed_to_cancel_job": "Failed to cancel job.", + "failed_to_clear_all_jobs": "Failed to clear all jobs.", + "failed_to_copy_file": "Failed to copy file", + "failed_to_copy_file_path": "Failed to copy file path", + "failed_to_cut_file": "Failed to cut file", + "failed_to_download_update": "Failed to download update", + "failed_to_duplicate_file": "Failed to duplicate file", + "failed_to_generate_checksum": "Failed to generate checksum", + "failed_to_generate_labels": "Failed to generate labels", + "failed_to_generate_thumbnails": "Failed to generate thumbnails", + "failed_to_load_tags": "Failed to load tags", + "failed_to_pause_job": "Failed to pause job.", + "failed_to_reindex_location": "Failed to re-index location", + "failed_to_remove_file_from_recents": "Failed to remove file from recents", + "failed_to_remove_job": "Failed to remove job.", + "failed_to_rescan_location": "Failed to rescan location", + "failed_to_resume_job": "Failed to resume job.", + "failed_to_update_location_settings": "Failed to update location settings", + "favorite": "Favorite", + "favorites": "Favorites", + "feedback": "Feedback", + "feedback_is_required": "Feedback is required", + "feedback_login_description": "Logging in allows us to respond to your feedback", + "feedback_placeholder": "Your feedback...", + "feedback_toast_error_message": "There was an error submitting your feedback. Please try again.", + "file_already_exist_in_this_location": "File already exists in this location", + "file_indexing_rules": "File indexing rules", + "filters": "Filters", + "forward": "Forward", + "full_disk_access": "Full disk access", + "full_disk_access_description": "To provide the best experience, we need access to your disk in order to index your files. Your files are only available to you.", + "full_reindex": "Full Reindex", + "full_reindex_info": "Perform a full rescan of this Location.", + "general": "General", + "general_settings": "General Settings", + "general_settings_description": "General settings related to this client.", + "general_shortcut_description": "General usage shortcuts", + "generatePreviewMedia_label": "Generate preview media for this Location", + "generate_checksums": "Generate Checksums", + "go_back": "Go Back", + "go_to_labels": "Go to labels", + "go_to_location": "Go to location", + "go_to_overview": "Go to overview", + "go_to_recents": "Go to recents", + "go_to_settings": "Go to settings", + "go_to_tag": "Go to tag", + "got_it": "Got it", + "grid_gap": "Gap", + "grid_view": "Grid View", + "grid_view_notice_description": "Get a visual overview of your files with Grid View. This view displays your files and folders as thumbnail images, making it easy to quickly identify the file you're looking for.", + "hidden_label": "Prevents the location and its contents from appearing in summary categories, search and tags unless \"Show hidden items\" is enabled.", + "hide_in_library_search": "Hide in Library search", + "hide_in_library_search_description": "Hide files with this tag from results when searching entire library.", + "hide_in_sidebar": "Hide in sidebar", + "hide_in_sidebar_description": "Prevent this tag from showing in the sidebar of the app.", + "hide_location_from_view": "Hide location and contents from view", + "home": "Home", + "icon_size": "Icon size", + "image_labeler_ai_model": "Image label recognition AI model", + "image_labeler_ai_model_description": "The model used to recognize objects in images. Larger models are more accurate but slower.", + "import": "Import", + "indexed": "Indexed", + "indexer_rule_reject_allow_label": "By default, an indexer rule functions as a Reject list, resulting in the exclusion of any files that match its criteria. Enabling this option will transform it into a Allow list, allowing the location to solely index files that meet its specified rules.", + "indexer_rules": "Indexer rules", + "indexer_rules_info": "Indexer rules allow you to specify paths to ignore using globs.", + "install": "Install", + "install_update": "Install Update", + "installed": "Installed", + "item_size": "Item size", + "item_with_count_one": "{{count}} item", + "item_with_count_other": "{{count}} items", + "job_has_been_canceled": "Job has been canceled.", + "job_has_been_paused": "Job has been paused.", + "job_has_been_removed": "Job has been removed.", + "job_has_been_resumed": "Job has been resumed.", + "join": "Join", + "join_discord": "Join Discord", + "join_library": "Join a Library", + "join_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", + "key": "Key", + "key_manager": "Key Manager", + "key_manager_description": "Create encryption keys, mount and unmount your keys to see files decrypted on the fly.", + "keybinds": "Keybinds", + "keybinds_description": "View and manage client keybinds", + "keys": "Keys", + "kilometers": "Kilometers", + "labels": "Labels", + "language": "Language", + "language_description": "Change the language of the Spacedrive interface", + "learn_more_about_telemetry": "Learn more about telemetry", + "libraries": "Libraries", + "libraries_description": "The database contains all library data and file metadata.", + "library": "Library", + "library_name": "Library name", + "library_overview": "Library Overview", + "library_settings": "Library Settings", + "library_settings_description": "General settings related to the currently active library.", + "list_view": "List View", + "list_view_notice_description": "Easily navigate through your files and folders with List View. This view displays your files in a simple, organized list format, allowing you to quickly locate and access the files you need.", + "loading": "Loading", + "local": "Local", + "local_locations": "Local Locations", + "local_node": "Local Node", + "location_connected_tooltip": "Location is being watched for changes", + "location_disconnected_tooltip": "Location is not being watched for changes", + "location_display_name_info": "The name of this Location, this is what will be displayed in the sidebar. Will not rename the actual folder on disk.", + "location_empty_notice_message": "No files found here", + "location_is_already_linked": "Location is already linked", + "location_path_info": "The path to this Location, this is where the files will be stored on disk.", + "location_type": "Location Type", + "location_type_managed": "Spacedrive will sort files for you. If Location isn't empty a \"spacedrive\" folder will be created.", + "location_type_normal": "Contents will be indexed as-is, new files will not be automatically sorted.", + "location_type_replica": "This Location is a replica of another, its contents will be automatically synchronized.", + "locations": "Locations", + "locations_description": "Manage your storage locations.", + "lock": "Lock", + "log_in": "Log in", + "log_in_with_browser": "Log in with browser", + "log_out": "Log out", + "logged_in_as": "Logged in as {{email}}", + "logging_in": "Logging in...", + "logout": "Logout", + "manage_library": "Manage Library", + "managed": "Managed", + "media": "Media", + "media_view": "Media View", + "media_view_context": "Media View Context", + "media_view_notice_description": "Discover photos and videos easily, Media View will show results starting at the current location including sub directories.", + "meet_contributors_behind_spacedrive": "Meet the contributors behind Spacedrive", + "meet_title": "Meet {{title}}", + "miles": "Miles", + "mode": "Mode", + "modified": "Modified", + "more": "More", + "more_actions": "More actions...", + "more_info": "More info", + "move_back_within_quick_preview": "Move back within quick preview", + "move_files": "Move Files", + "move_forward_within_quick_preview": "Move forward within quick preview", + "move_to_trash": "Move to Trash", + "name": "Name", + "navigate_back": "Navigate back", + "navigate_backwards": "Navigate backwards", + "navigate_files_downwards": "Navigate files downwards", + "navigate_files_leftwards": "Navigate files leftwards", + "navigate_files_rightwards": "Navigate files rightwards", + "navigate_files_upwards": "Navigate files upwards", + "navigate_forward": "Navigate forward", + "navigate_forwards": "Navigate forwards", + "navigate_to_settings_page": "Navigate to Settings page", + "network": "Network", + "network_page_description": "Other Spacedrive nodes on your LAN will appear here, along with your default OS network mounts.", + "networking": "Networking", + "networking_port": "Networking Port", + "networking_port_description": "The port for Spacedrive's Peer-to-peer networking to communicate on. You should leave this disabled unless you have a restrictive firewall. Do not expose to the internet!", + "new": "New", + "new_folder": "Folder", + "new_library": "New library", + "new_location": "New location", + "new_location_web_description": "As you are using the browser version of Spacedrive you will (for now) need to specify an absolute URL of a directory local to the remote node.", + "new_tab": "New Tab", + "new_tag": "New tag", + "new_update_available": "New Update Available!", + "no_favorite_items": "No favorite items", + "no_items_found": "No items found", + "no_jobs": "No jobs.", + "no_labels": "No labels", + "no_nodes_found": "No Spacedrive nodes were found.", + "no_tag_selected": "No Tag Selected", + "no_tags": "No tags", + "node_name": "Node Name", + "nodes": "Nodes", + "nodes_description": "Manage the nodes connected to this library. A node is an instance of Spacedrive's backend, running on a device or server. Each node carries a copy of the database and synchronizes via peer-to-peer connections in realtime.", + "none": "None", + "normal": "Normal", + "not_you": "Not you?", + "number_of_passes": "# of passes", + "object_id": "Object ID", + "offline": "Offline", + "online": "Online", + "open": "Open", + "open_file": "Open File", + "open_in_new_tab": "Open in new tab", + "open_new_location_once_added": "Open new location once added", + "open_new_tab": "Open new tab", + "open_object": "Open object", + "open_object_from_quick_preview_in_native_file_manager": "Open object from quick preview in native file manager", + "open_settings": "Open Settings", + "open_with": "Open with", + "or": "OR", + "overview": "Overview", + "page": "Page", + "page_shortcut_description": "Different pages in the app", + "pair": "Pair", + "pairing_with_node": "Pairing with {{node}}", + "paste": "Paste", + "paste_object": "Paste object", + "paste_success": "Items pasted", + "path": "Path", + "path_copied_to_clipboard_description": "Path for location {{location}} copied to clipboard.", + "path_copied_to_clipboard_title": "Path copied to clipboard", + "pause": "Pause", + "peers": "Peers", + "people": "People", + "pin": "Pin", + "privacy": "Privacy", + "privacy_description": "Spacedrive is built for privacy, that's why we're open source and local first. So we'll make it very clear what data is shared with us.", + "quick_preview": "Quick Preview", + "quick_view": "Quick view", + "recent_jobs": "Recent Jobs", + "recents": "Recents", + "recents_notice_message": "Recents are created when you open a file.", + "regen_labels": "Regen Labels", + "regen_thumbnails": "Regen Thumbnails", + "regenerate_thumbs": "Regenerate Thumbs", + "reindex": "Re-index", + "reject": "Reject", + "reload": "Reload", + "remove": "Remove", + "remove_from_recents": "Remove From Recents", + "rename": "Rename", + "rename_object": "Rename object", + "replica": "Replica", + "rescan": "Rescan", + "rescan_directory": "Rescan Directory", + "rescan_location": "Rescan Location", + "reset": "Reset", + "resources": "Resources", + "restore": "Restore", + "resume": "Resume", + "retry": "Retry", + "reveal_in_native_file_manager": "Reveal in native file manager", + "revel_in_browser": "Reveal in {{browser}}", + "running": "Running", + "save": "Save", + "save_changes": "Save Changes", + "saved_searches": "Saved Searches", + "search": "Search", + "search_extensions": "Search extensions", + "search_for_files_and_actions": "Search for files and actions...", + "secure_delete": "Secure delete", + "security": "Security", + "security_description": "Keep your client safe.", + "send": "Send", + "settings": "Settings", + "setup": "Set up", + "share": "Share", + "share_anonymous_usage": "Share anonymous usage", + "share_anonymous_usage_description": "Share completely anonymous telemetry data to help the developers improve the app", + "share_bare_minimum": "Share the bare minimum", + "share_bare_minimum_description": "Only share that I am an active user of Spacedrive and a few technical bits", + "sharing": "Sharing", + "sharing_description": "Manage who has access to your libraries.", + "show_details": "Show details", + "show_hidden_files": "Show Hidden Files", + "show_inspector": "Show Inspector", + "show_object_size": "Show Object size", + "show_path_bar": "Show Path Bar", + "show_slider": "Show slider", + "size": "Size", + "size_b": "B", + "size_gb": "GB", + "size_kb": "kB", + "size_mb": "MB", + "size_tb": "TB", + "skip_login": "Skip login", + "sort_by": "Sort by", + "spacedrive_account": "Spacedrive Account", + "spacedrive_cloud": "Spacedrive Cloud", + "spacedrive_cloud_description": "Spacedrive is always local first, but we will offer our own optional cloud services in the future. For now, authentication is only used for the Feedback feature, otherwise it is not required.", + "spacedrop_a_file": "Spacedrop a File", + "spacedrop_already_progress": "Spacedrop already in progress", + "spacedrop_description": "Share instantly with devices running Spacedrive on your network.", + "spacedrop_rejected": "Spacedrop rejected", + "square_thumbnails": "Square Thumbnails", + "star_on_github": "Star on GitHub", + "stop": "Stop", + "success": "Success", + "support": "Support", + "switch_to_grid_view": "Switch to grid view", + "switch_to_list_view": "Switch to list view", + "switch_to_media_view": "Switch to media view", + "switch_to_next_tab": "Switch to next tab", + "switch_to_previous_tab": "Switch to previous tab", + "sync": "Sync", + "syncPreviewMedia_label": "Sync preview media for this Location with your devices", + "sync_description": "Manage how Spacedrive syncs.", + "sync_with_library": "Sync with Library", + "sync_with_library_description": "If enabled, your keybinds will be synced with library, otherwise they will apply only to this client.", + "tags": "Tags", + "tags_description": "Manage your tags.", + "tags_notice_message": "No items assigned to this tag.", + "telemetry_description": "Toggle ON to provide developers with detailed usage and telemetry data to enhance the app. Toggle OFF to send only basic data: your activity status, app version, core version, and platform (e.g., mobile, web, or desktop).", + "telemetry_title": "Share Additional Telemetry and Usage Data", + "temperature": "Temperature", + "text_file": "Text File", + "text_size": "Text size", + "thank_you_for_your_feedback": "Thanks for your feedback!", + "thumbnailer_cpu_usage": "Thumbnailer CPU usage", + "thumbnailer_cpu_usage_description": "Limit how much CPU the thumbnailer can use for background processing.", + "toggle_all": "Toggle All", + "toggle_command_palette": "Toggle command palette", + "toggle_hidden_files": "Toggle hidden files", + "toggle_image_slider_within_quick_preview": "Toggle image slider within quick preview", + "toggle_inspector": "Toggle inspector", + "toggle_job_manager": "Toggle job manager", + "toggle_metadata": "Toggle metadata", + "toggle_path_bar": "Toggle path bar", + "toggle_quick_preview": "Toggle quick preview", + "tools": "Tools", + "trash": "Trash", + "type": "Type", + "ui_animations": "UI Animations", + "ui_animations_description": "Dialogs and other UI elements will animate when opening and closing.", + "unnamed_location": "Unnamed Location", + "update": "Update", + "update_downloaded": "Update Downloaded. Restart Spacedrive to install", + "updated_successfully": "Updated successfully, you're on version {{version}}", + "usage": "Usage", + "usage_description": "Your library usage and hardware information", + "vaccum": "Vaccum", + "vaccum_library": "Vaccum Library", + "vaccum_library_description": "Repack your database to free up unnecessary space.", + "value": "Value", + "version": "Version {{version}}", + "video_preview_not_supported": "Video preview is not supported.", + "view_changes": "View Changes", + "want_to_do_this_later": "Want to do this later?", + "website": "Website", + "your_account": "Your account", + "your_account_description": "Spacedrive account and information.", + "your_local_network": "Your Local Network", + "your_privacy": "Your Privacy" } diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json index 674a12e7c..a4280a847 100644 --- a/interface/locales/es/common.json +++ b/interface/locales/es/common.json @@ -227,6 +227,8 @@ "install": "Instalar", "install_update": "Instalar Actualización", "installed": "Instalado", + "ipv6": "redes IPv6", + "ipv6_description": "Permitir la comunicación entre pares mediante redes IPv6", "item_size": "Tamaño de elemento", "item_with_count_one": "{{count}} artículo", "item_with_count_other": "{{count}} artículos", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive está construido para la privacidad, es por eso que somos de código abierto y primero locales. Por eso, haremos muy claro qué datos se comparten con nosotros.", "quick_preview": "Vista rápida", "quick_view": "Vista rápida", + "random": "Aleatorio", "recent_jobs": "Trabajos recientes", "recents": "Recientes", "recents_notice_message": "Los recientes se crean cuando abres un archivo.", @@ -377,6 +380,8 @@ "reindex": "Reindexar", "reject": "Rechazar", "reload": "Recargar", + "remote_access": "Habilitar acceso remoto", + "remote_access_description": "Permita que otros nodos se conecten directamente a este nodo.", "remove": "Eliminar", "remove_from_recents": "Eliminar de Recientes", "rename": "Renombrar", @@ -429,9 +434,13 @@ "spacedrive_account": "Cuenta de Spacedrive", "spacedrive_cloud": "Spacedrive Cloud", "spacedrive_cloud_description": "Spacedrive siempre es primero local, pero ofreceremos nuestros propios servicios en la nube opcionalmente en el futuro. Por ahora, la autenticación solo se utiliza para la función de retroalimentación, de lo contrario no es requerida.", + "spacedrop": "Visibilidad de lanzamiento espacial", "spacedrop_a_file": "Soltar un Archivo", "spacedrop_already_progress": "Spacedrop ya está en progreso", + "spacedrop_contacts_only": "Solo contactos", "spacedrop_description": "Comparta al instante con dispositivos que ejecuten Spacedrive en su red.", + "spacedrop_disabled": "Desactivado", + "spacedrop_everyone": "Todos", "spacedrop_rejected": "Spacedrop rechazado", "square_thumbnails": "Miniaturas Cuadradas", "star_on_github": "Dar estrella en GitHub", @@ -468,6 +477,8 @@ "toggle_metadata": "Alternar metadatos", "toggle_path_bar": "Alternar barra de ruta", "toggle_quick_preview": "Alternar vista rápida", + "tools": "Herramientas", + "trash": "Basura", "type": "Tipo", "ui_animations": "Animaciones de la UI", "ui_animations_description": "Los diálogos y otros elementos de la UI se animarán al abrirse y cerrarse.", diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json index a2ccbe289..559a60cf1 100644 --- a/interface/locales/fr/common.json +++ b/interface/locales/fr/common.json @@ -227,6 +227,8 @@ "install": "Installer", "install_update": "Installer la mise à jour", "installed": "Installé", + "ipv6": "Mise en réseau IPv6", + "ipv6_description": "Autoriser la communication peer-to-peer à l'aide du réseau IPv6", "item_size": "Taille de l'élément", "item_with_count_one": "{{count}} article", "item_with_count_other": "{{count}} articles", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive est conçu pour la confidentialité, c'est pourquoi nous sommes open source et local d'abord. Nous serons donc très clairs sur les données partagées avec nous.", "quick_preview": "Aperçu rapide", "quick_view": "Vue rapide", + "random": "Aléatoire", "recent_jobs": "Travaux récents", "recents": "Récents", "recents_notice_message": "Les fichiers récents sont créés lorsque vous ouvrez un fichier.", @@ -377,6 +380,8 @@ "reindex": "Réindexer", "reject": "Rejeter", "reload": "Recharger", + "remote_access": "Activer l'accès à distance", + "remote_access_description": "Permettez à d’autres nœuds de se connecter directement à ce nœud.", "remove": "Retirer", "remove_from_recents": "Retirer des récents", "rename": "Renommer", @@ -429,9 +434,13 @@ "spacedrive_account": "Compte Spacedrive", "spacedrive_cloud": "Cloud Spacedrive", "spacedrive_cloud_description": "Spacedrive est toujours local en premier lieu, mais nous proposerons nos propres services cloud en option à l'avenir. Pour l'instant, l'authentification est uniquement utilisée pour la fonctionnalité de retour d'information, sinon elle n'est pas requise.", + "spacedrop": "Visibilité du largage spatial", "spacedrop_a_file": "Déposer un fichier dans Spacedrive", "spacedrop_already_progress": "Spacedrop déjà en cours", + "spacedrop_contacts_only": "Contacts uniquement", "spacedrop_description": "Partagez instantanément avec les appareils exécutant Spacedrive sur votre réseau.", + "spacedrop_disabled": "Désactivé", + "spacedrop_everyone": "Tout le monde", "spacedrop_rejected": "Spacedrop rejeté", "square_thumbnails": "Vignettes carrées", "star_on_github": "Mettre une étoile sur GitHub", @@ -468,6 +477,8 @@ "toggle_metadata": "Activer/désactiver les métadonnées", "toggle_path_bar": "Activer/désactiver la barre de chemin", "toggle_quick_preview": "Activer/désactiver l'aperçu rapide", + "tools": "Outils", + "trash": "Poubelle", "type": "Type", "ui_animations": "Animations de l'interface", "ui_animations_description": "Les dialogues et autres éléments d'interface animeront lors de l'ouverture et de la fermeture.", diff --git a/interface/locales/it/common.json b/interface/locales/it/common.json index 8b86c897d..b3e3ba545 100644 --- a/interface/locales/it/common.json +++ b/interface/locales/it/common.json @@ -227,6 +227,8 @@ "install": "Installa", "install_update": "Installa Aggiornamento", "installed": "Installato", + "ipv6": "Rete IPv6", + "ipv6_description": "Consenti la comunicazione peer-to-peer utilizzando la rete IPv6", "item_size": "Dimensione dell'elemento", "item_with_count_one": "{{count}} elemento", "item_with_count_other": "{{count}} elementi", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive è progettato per garantire la privacy, ecco perché siamo innanzitutto open source e manteniamo i file in locale. Quindi renderemo molto chiaro quali dati vengono condivisi con noi.", "quick_preview": "Anteprima rapida", "quick_view": "Visualizzazione rapida", + "random": "Casuale", "recent_jobs": "Jobs recenti", "recents": "Recenti", "recents_notice_message": "I recenti vengono creati quando apri un file.", @@ -377,6 +380,8 @@ "reindex": "Re-indicizza", "reject": "Rifiuta", "reload": "Ricarica", + "remote_access": "Abilita l'accesso remoto", + "remote_access_description": "Abilita altri nodi a connettersi direttamente a questo nodo.", "remove": "Rimuovi", "remove_from_recents": "Rimuovi dai recenti", "rename": "Rinomina", @@ -429,9 +434,13 @@ "spacedrive_account": "Account Spacedrive", "spacedrive_cloud": "Cloud Spacedrive", "spacedrive_cloud_description": "Spacedrive è sempre locale in primo luogo, ma offriremo i nostri servizi cloud opzionali in futuro. Per ora, l'autenticazione viene utilizzata solo per la funzione di Feedback, altrimenti non è richiesta.", + "spacedrop": "Visibilità dallo spazio", "spacedrop_a_file": "Spacedroppa un file", "spacedrop_already_progress": "Spacedrop già in corso", + "spacedrop_contacts_only": "Solo contatti", "spacedrop_description": "Condividi istantaneamente con dispositivi che eseguono Spacedrive sulla tua rete.", + "spacedrop_disabled": "Disabilitato", + "spacedrop_everyone": "Tutti", "spacedrop_rejected": "Spacedrop rifiutato", "square_thumbnails": "Miniature quadrate", "star_on_github": "Aggiungi ai preferiti su GitHub", @@ -468,6 +477,8 @@ "toggle_metadata": "Mostra/nascondi metadati", "toggle_path_bar": "Mostra/nascondi barra del percorso", "toggle_quick_preview": "Mostra/nascondi anteprima rapida", + "tools": "Utensili", + "trash": "Spazzatura", "type": "Tipo", "ui_animations": "Animazioni dell'interfaccia utente", "ui_animations_description": "Le finestre di dialogo e altri elementi dell'interfaccia utente si animeranno durante l'apertura e la chiusura.", diff --git a/interface/locales/ja/common.json b/interface/locales/ja/common.json index cd30c5537..f3058c0cf 100644 --- a/interface/locales/ja/common.json +++ b/interface/locales/ja/common.json @@ -227,6 +227,8 @@ "install": "インストール", "install_update": "アップデートをインストールする", "installed": "インストール完了", + "ipv6": "IPv6ネットワーキング", + "ipv6_description": "IPv6 ネットワークを使用したピアツーピア通信を許可する", "item_size": "アイテムの表示サイズ", "item_with_count_one": "{{count}} item", "item_with_count_other": "{{count}} items", @@ -368,6 +370,7 @@ "privacy_description": "Spacedriveはプライバシーを遵守します。だからこそ、私達はオープンソースであり、ローカルでの利用を優先しています。プライバシーのために、どのようなデータが私達と共有されるのかを明示しています。", "quick_preview": "クイック プレビュー", "quick_view": "クイック プレビュー", + "random": "ランダム", "recent_jobs": "最近のジョブ", "recents": "最近のアクセス", "recents_notice_message": "ファイルを開くと最近のアクセスが表示されます。", @@ -377,6 +380,8 @@ "reindex": "再インデックス化", "reject": "Reject", "reload": "更新", + "remote_access": "リモートアクセスを有効にする", + "remote_access_description": "他のノードがこのノードに直接接続できるようにします。", "remove": "削除", "remove_from_recents": "最近のアクセスから削除", "rename": "名前の変更", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedriveアカウント", "spacedrive_cloud": "Spacedriveクラウド", "spacedrive_cloud_description": "Spacedriveは常にローカルでの利用を優先しますが、将来的には独自オプションのクラウドサービスを提供する予定です。現在、アカウント認証はフィードバック機能のみに使用されており、それ以外では必要ありません。", + "spacedrop": "スペースドロップの可視性", "spacedrop_a_file": "ファイルをSpacedropへ", "spacedrop_already_progress": "Spacedropは既に実行中です", + "spacedrop_contacts_only": "連絡先のみ", "spacedrop_description": "ネットワーク上のSpacedriveを実行しているデバイスと速やかに共有できます。", + "spacedrop_disabled": "無効", + "spacedrop_everyone": "みんな", "spacedrop_rejected": "Spacedrop rejected", "square_thumbnails": "Square Thumbnails", "star_on_github": "Star on GitHub", @@ -468,6 +477,8 @@ "toggle_metadata": "詳細パネルを開閉", "toggle_path_bar": "パスバーの表示切り替え", "toggle_quick_preview": "クイック プレビューを表示", + "tools": "ツール", + "trash": "ごみ", "type": "種類", "ui_animations": "UIアニメーション", "ui_animations_description": "ダイアログやその他のUI要素を開いたり閉じたりするときにアニメーションを有効にします。", diff --git a/interface/locales/nl/common.json b/interface/locales/nl/common.json index 87269d2c0..f31324666 100644 --- a/interface/locales/nl/common.json +++ b/interface/locales/nl/common.json @@ -227,6 +227,8 @@ "install": "Installeer", "install_update": "Installeer Update", "installed": "Geïnstalleerd", + "ipv6": "IPv6-netwerken", + "ipv6_description": "Maak peer-to-peer-communicatie mogelijk via IPv6-netwerken", "item_size": "Item grootte", "item_with_count_one": "{{count}} item", "item_with_count_other": "{{count}} items", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive is gebouwd met het oog op privacy, daarom zijn we open source en \"local first\". Daarom maken we heel duidelijk welke gegevens met ons worden gedeeld.", "quick_preview": "Snelle Voorvertoning", "quick_view": "Geef snel weer", + "random": "Willekeurig", "recent_jobs": "Recente Taken", "recents": "Recent", "recents_notice_message": "Recente bestanden worden gemaakt wanneer u een bestand opent.", @@ -377,6 +380,8 @@ "reindex": "Herindexeren", "reject": "Afwijzen", "reload": "Herlaad", + "remote_access": "Schakel externe toegang in", + "remote_access_description": "Zorg ervoor dat andere knooppunten rechtstreeks verbinding kunnen maken met dit knooppunt.", "remove": "Verwijder", "remove_from_recents": "Verwijder van Recent", "rename": "Naam wijzigen", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedrive Account", "spacedrive_cloud": "Spacedrive Cloud", "spacedrive_cloud_description": "Spacedrive is altijd lokaal eerst, maar we zullen in de toekomst optionele cloudservices aanbieden. Voor nu wordt authenticatie alleen gebruikt voor de Feedback-functie, anders is het niet vereist.", + "spacedrop": "Zichtbaarheid van een ruimtedruppel", "spacedrop_a_file": "Spacedrop een Bestand", "spacedrop_already_progress": "Spacedrop is al bezig", + "spacedrop_contacts_only": "Alleen contacten", "spacedrop_description": "Deel direct met apparaten die Spacedrive uitvoeren op uw netwerk.", + "spacedrop_disabled": "Gehandicapt", + "spacedrop_everyone": "Iedereen", "spacedrop_rejected": "Spacedrop geweigerd", "square_thumbnails": "Vierkante Miniaturen", "star_on_github": "Ster op GitHub", @@ -468,6 +477,8 @@ "toggle_metadata": "Metadata in-/uitschakelen", "toggle_path_bar": "Padbalk in-/uitschakelen", "toggle_quick_preview": "Snelle voorvertoning in-/uitschakelen", + "tools": "Hulpmiddelen", + "trash": "Afval", "type": "Type", "ui_animations": "UI Animaties", "ui_animations_description": "Dialogen en andere UI elementen zullen animeren bij het openen en sluiten.", diff --git a/interface/locales/ru/common.json b/interface/locales/ru/common.json index c2ac6760e..896e5714e 100644 --- a/interface/locales/ru/common.json +++ b/interface/locales/ru/common.json @@ -3,7 +3,7 @@ "about_vision_text": "У многих из нас есть несколько учетных записей в облаке, диски без резервной копии и данные, которые могут быть утеряны. Мы зависим от облачных сервисов, таких как Google Photos и iCloud, но их возможности ограничены, а совместимость между сервисами и операционными системами практически отсутствует. Фотогалерея не должна быть привязана к экосистеме устройства или использоваться для сбора рекламных данных. Она должна быть независима от операционной системы, постоянна и принадлежать лично Вам. Данные, которые мы создаем, - это наше наследие, которое надолго переживет нас. Технология с открытым исходным кодом - единственный способ обеспечить абсолютный контроль над данными, определяющими нашу жизнь, в неограниченном масштабе.", "about_vision_title": "Видение", "accept": "Принять", - "accessed": "Доступно", + "accessed": "Использован", "account": "Аккаунт", "actions": "Действия", "add": "Добавить", @@ -11,7 +11,7 @@ "add_library": "Добавить библиотеку", "add_location": "Добавить локацию", "add_location_description": "Расширьте возможности Spacedrive, добавив любимые локации в свою личную библиотеку, для удобного и эффективного управления файлами.", - "add_location_tooltip": "Добавьте данный путь в качестве индексированной локации", + "add_location_tooltip": "Добавьте путь в качестве индексированной локации", "add_locations": "Добавить локации", "add_tag": "Добавить тег", "advanced_settings": "Дополнительные настройки", @@ -24,19 +24,19 @@ "archive_coming_soon": "Архивация локаций скоро появится...", "archive_info": "Извлечение данных из библиотеки в виде архива, полезно для сохранения структуры локаций.", "are_you_sure": "Вы уверены?", - "ask_spacedrive": "Спросите Спейсдрайв", + "ask_spacedrive": "Спросите Spacedrive", "assign_tag": "Присвоить тег", "audio_preview_not_supported": "Предварительный просмотр аудио не поддерживается.", "back": "Назад", - "backups": "Резервные копии", - "backups_description": "Управляйте Вашими копиями базы данных Spacedrive", + "backups": "Рез. копии", + "backups_description": "Управляйте вашими копиями базы данных Spacedrive", "blur_effects": "Эффекты размытия", "blur_effects_description": "К некоторым компонентам будет применен эффект размытия.", "cancel": "Отменить", "cancel_selection": "Отменить выбор", "celcius": "Цельсий", "change": "Изменить", - "change_view_setting_description": "Изменение представления проводника по умолчанию", + "change_view_setting_description": "Изменить вида проводника по умолчанию", "changelog": "Что нового", "changelog_page_description": "Узнайте, какие новые возможности мы добавили", "changelog_page_title": "Список изменений", @@ -44,7 +44,7 @@ "clear_finished_jobs": "Очистить законченные задачи", "client": "Клиент", "close": "Закрыть", - "close_command_palette": "Закрыть палитру команд", + "close_command_palette": "Закрыть панель команд", "close_current_tab": "Закрыть текущую вкладку", "clouds": "Облачные хр.", "color": "Цвет", @@ -76,9 +76,9 @@ "create_new_tag": "Создать новый тег", "create_new_tag_description": "Выберите название и цвет.", "create_tag": "Создать тег", - "created": "Создано", + "created": "Создан", "creating_library": "Создание библиотеки...", - "creating_your_library": "Создание Вашей библиотеки", + "creating_your_library": "Создание вашей библиотеки", "current": "Текущая", "current_directory": "Текущая директория", "current_directory_with_descendants": "Текущая директория с наследниками", @@ -90,14 +90,14 @@ "date_accessed": "Дата доступа", "date_created": "Дата создания", "date_indexed": "Дата индексации", - "date_modified": "Дата изменена", + "date_modified": "Дата изменения", "debug_mode": "Режим отладки", "debug_mode_description": "Включите дополнительные функции отладки в приложении.", "default": "Стандартный", "default_settings": "Настройки по умолчанию", "delete": "Удалить", "delete_dialog_title": "Удалить {{prefix}} {{type}}", - "delete_forever": "Удалить навсегда", + "delete_forever": "Удалить", "delete_info": "Это не удалит саму папку на диске. Будет удалено медиа-превью.", "delete_library": "Удалить библиотеку", "delete_library_description": "Это необратимое действие, ваши файлы не будут удалены, только библиотека Spacedrive.", @@ -107,7 +107,7 @@ "delete_rule": "Удалить правило", "delete_tag": "Удалить тег", "delete_tag_description": "Вы уверены, что хотите удалить этот тег? Это действие нельзя отменить, и тегнутые файлы будут отсоединены.", - "delete_warning": "это удалит ваш {{type}} навсегда, у нас пока нет мусорной корзины.", + "delete_warning": "Это действие приведет к удалению вашего {{type}}. На данный момент это действие невозможно отменить. Если переместите файл в корзину, вы сможете восстановить его позже.", "description": "Описание", "deselect": "Отменить выбор", "details": "Подробности", @@ -151,7 +151,7 @@ "explorer": "Проводник", "explorer_settings": "Настройки проводника", "explorer_shortcut_description": "Навигация и взаимодействие с файловой системой", - "explorer_view": "Просмотр проводника", + "explorer_view": "Вид проводника", "export": "Экспорт", "export_library": "Экспорт библиотеки", "export_library_coming_soon": "Экспорт библиотеки скоро станет возможным", @@ -181,7 +181,7 @@ "favorites": "Избранное", "feedback": "Фидбек", "feedback_is_required": "Необходим фидбек", - "feedback_login_description": "Вход в систему позволяет нам отвечать на Ваш фидбек", + "feedback_login_description": "Вход в систему позволяет нам отвечать на ваш фидбек", "feedback_placeholder": "Ваш фидбек...", "feedback_toast_error_message": "При отправке вашего фидбека произошла ошибка. Пожалуйста, попробуйте еще раз.", "file_already_exist_in_this_location": "Файл уже существует в этой локации", @@ -203,12 +203,12 @@ "go_to_location": "Перейти к месту", "go_to_overview": "Перейти к обзору", "go_to_recents": "Перейти к недавним", - "go_to_settings": "Перейдите в настройки", + "go_to_settings": "Перейти в настройки", "go_to_tag": "Перейти к тегу", "got_it": "Получилось", "grid_gap": "Пробел", - "grid_view": "Вид сеткой", - "grid_view_notice_description": "Получите визуальный обзор файлов с помощью просмотра сеткой. В этом представлении файлы и папки отображаются в виде уменьшенных изображений, что позволяет быстро найти нужный файл.", + "grid_view": "Значки", + "grid_view_notice_description": "Получите визуальный обзор файлов с помощью просмотра значками. В этом представлении файлы и папки отображаются в виде уменьшенных изображений, что позволяет быстро найти нужный файл.", "hidden_label": "Не позволяет локации и её содержимому отображаться в итоговых категориях, поиске и тегах, если не включена функция \"Показывать скрытые элементы\".", "hide_in_library_search": "Скрыть в поиске по библиотеке", "hide_in_library_search_description": "Скрыть файлы с этим тегом из результатов при поиске по всей библиотеке.", @@ -220,13 +220,15 @@ "image_labeler_ai_model": "Модель ИИ для генерации ярлыков изображений", "image_labeler_ai_model_description": "Модель, используемая для распознавания объектов на изображениях. Большие модели более точны, но работают медленнее.", "import": "Импорт", - "indexed": "Индексированный", + "indexed": "Индексирован", "indexer_rule_reject_allow_label": "По умолчанию правило индексатора работает как список отклоненных, в результате чего исключаются любые файлы, соответствующие его критериям. Включение этого параметра преобразует его в список разрешенных, позволяя локации индексировать только файлы, соответствующие заданным правилам.", "indexer_rules": "Правила индексатора", "indexer_rules_info": "Правила индексатора позволяют указывать пути для игнорирования с помощью шаблонов.", "install": "Установить", "install_update": "Установить обновление", "installed": "Установлено", + "ipv6": "Сеть IPv6", + "ipv6_description": "Разрешить одноранговую связь с использованием сети IPv6.", "item_size": "Размер элемента", "item_with_count_one": "{{count}} элемент", "item_with_count_other": "{{count}} элементов", @@ -256,7 +258,7 @@ "library_overview": "Обзор библиотеки", "library_settings": "Настройки библиотеки", "library_settings_description": "Главные настройки, относящиеся к текущей активной библиотеке.", - "list_view": "Вид списком", + "list_view": "Список", "list_view_notice_description": "Удобная навигация по файлам и папкам с помощью функции просмотра списком. Этот вид отображает файлы в виде простого, упорядоченного списка, позволяя быстро находить и получать доступ к нужным файлам.", "loading": "Загрузка", "local": "Локально", @@ -273,9 +275,9 @@ "location_type_normal": "Содержимое будет индексироваться как есть, новые файлы не будут автоматически сортироваться.", "location_type_replica": "Эта локация является копией другой, ее содержимое будет автоматически синхронизировано.", "locations": "Локации", - "locations_description": "Управляйте Вашими локациями.", + "locations_description": "Управляйте вашими локациями.", "lock": "Заблокировать", - "log_in": "Авторизоваться", + "log_in": "Войти", "log_in_with_browser": "Войдите в систему с помощью браузера", "log_out": "Выйти из системы", "logged_in_as": "Вошли в систему как {{email}}", @@ -284,20 +286,20 @@ "manage_library": "Управление библиотекой", "managed": "Управляемый", "media": "Медиа", - "media_view": "Медиа-представление", - "media_view_context": "Контекст медиа-вида", - "media_view_notice_description": "Легко находите фотографии и видео, просмотр медиа показывает результаты, начиная с текущей локации, включая вложенные каталоги.", + "media_view": "Галерея", + "media_view_context": "Контекст галереи", + "media_view_notice_description": "Легко находите фотографии и видео, галерея показывает результаты, начиная с текущей локации, включая вложенные папки.", "meet_contributors_behind_spacedrive": "Познакомьтесь с участниками проекта Spacedrive", - "meet_title": "Познакомьтесь с {{title}}", + "meet_title": "Встречайте: {{title}}", "miles": "Мили", "mode": "Режим", - "modified": "Измененный", + "modified": "Изменен", "more": "Подробнее", "more_actions": "Дополнительные действия...", "more_info": "Подробнее", - "move_back_within_quick_preview": "Перемещение назад в рамках быстрого предпросмотра", + "move_back_within_quick_preview": "Перемещение назад в рамках быстрого просмотра", "move_files": "Переместить файлы", - "move_forward_within_quick_preview": "Перемещение вперед в рамках быстрого предпросмотра", + "move_forward_within_quick_preview": "Перемещение вперед в рамках быстрого просмотра", "move_to_trash": "Переместить в корзину", "name": "Имя", "navigate_back": "Переход назад", @@ -314,7 +316,7 @@ "networking": "Работа в сети", "networking_port": "Сетевой порт", "networking_port_description": "Порт для одноранговой сети Spacedrive. Если у вас не установлен ограничительный брандмауэр, этот параметр следует оставить отключенным. Не открывайте доступ в Интернет!", - "new": "Новый", + "new": "Новое", "new_folder": "Новая папка", "new_library": "Новая библиотека", "new_location": "Новая локация", @@ -322,11 +324,11 @@ "new_tab": "Новая вкладка", "new_tag": "Новый тег", "new_update_available": "Доступно новое обновление!", - "no_favorite_items": "Нет любимых предметов", - "no_items_found": "ничего не найдено", + "no_favorite_items": "Нет избранных файлов", + "no_items_found": "Ничего не найдено", "no_jobs": "Нет задач.", "no_labels": "Нет ярлыков", - "no_nodes_found": "Не найдено узлов Spacedrive.", + "no_nodes_found": "Узлы Spacedrive не найдены.", "no_tag_selected": "Тег не выбран", "no_tags": "Нет тегов", "node_name": "Имя узла", @@ -345,7 +347,7 @@ "open_new_location_once_added": "Открыть новую локацию после добавления", "open_new_tab": "Открыть новую вкладку", "open_object": "Открыть объект", - "open_object_from_quick_preview_in_native_file_manager": "Открытие объекта из предпросмотра в родном файловом менеджере", + "open_object_from_quick_preview_in_native_file_manager": "Открытие объекта из быстрого просмотра в родном файловом менеджере", "open_settings": "Открыть настройки", "open_with": "Открыть при помощи", "or": "Или", @@ -363,11 +365,12 @@ "pause": "Пауза", "peers": "Участники", "people": "Люди", - "pin": "приколоть", + "pin": "Закрепить", "privacy": "Приватность", "privacy_description": "Spacedrive создан для обеспечения конфиденциальности, поэтому у нас открытый исходный код и локальный подход. Поэтому мы четко указываем, какие данные передаются нам.", - "quick_preview": "Быстрый предпросмотр", + "quick_preview": "Быстрый просмотр", "quick_view": "Быстрый просмотр", + "random": "Случайный", "recent_jobs": "Недавние задачи", "recents": "Недавнее", "recents_notice_message": "Недавние создаются при открытии файла.", @@ -377,6 +380,8 @@ "reindex": "Переиндексировать", "reject": "Отклонить", "reload": "Перезагрузить", + "remote_access": "Включить удаленный доступ", + "remote_access_description": "Разрешите другим узлам напрямую подключаться к этому узлу.", "remove": "Удалить", "remove_from_recents": "Удалить из недавних", "rename": "Переименовать", @@ -412,7 +417,7 @@ "share_bare_minimum_description": "Делиться лишь тем, что являетесь активным пользователем Spacedrive и несколькими техническими моментами.", "sharing": "Совместное использование", "sharing_description": "Управляйте тем, кто имеет доступ к вашим библиотекам.", - "show_details": "Показать подробно", + "show_details": "Показать подробности", "show_hidden_files": "Показать скрытые файлы", "show_inspector": "Показать инспектор", "show_object_size": "Показать размер объекта", @@ -421,7 +426,7 @@ "size": "Размер", "size_b": "Б", "size_gb": "ГБ", - "size_kb": "kБ", + "size_kb": "КБ", "size_mb": "МБ", "size_tb": "ТБ", "skip_login": "Пропустить вход", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedrive аккаунт", "spacedrive_cloud": "Spacedrive Cloud", "spacedrive_cloud_description": "Spacedrive в первую очередь предназначен для локального использования, но в будущем мы предложим собственные дополнительные облачные сервисы. На данный момент аутентификация используется только для функции 'Фидбек', в остальном она не требуется.", + "spacedrop": "Видимость Spacedrop", "spacedrop_a_file": "Отправить файл с помощью Spacedrop", "spacedrop_already_progress": "Spacedrop уже в процессе", + "spacedrop_contacts_only": "Только для контактов", "spacedrop_description": "Мгновенно делитесь с устройствами, работающими с Spacedrive в вашей сети.", + "spacedrop_disabled": "Отключен", + "spacedrop_everyone": "Все", "spacedrop_rejected": "Spacedrop отклонен", "square_thumbnails": "Квадратные эскизы", "star_on_github": "Поставить звезду на GitHub", @@ -456,18 +465,20 @@ "temperature": "Температура", "text_file": "Текстовый файл", "text_size": "Размер текста", - "thank_you_for_your_feedback": "Спасибо за Ваш фидбек!", + "thank_you_for_your_feedback": "Спасибо за ваш фидбек!", "thumbnailer_cpu_usage": "Использование процессора при создании миниатюр", "thumbnailer_cpu_usage_description": "Ограничьте нагрузку на процессор, которую может использовать программа для создания миниатюр в фоновом режиме.", "toggle_all": "Включить все", - "toggle_command_palette": "Переключить палитру команд", + "toggle_command_palette": "Открыть панель команд", "toggle_hidden_files": "Включить видимость скрытых файлов", - "toggle_image_slider_within_quick_preview": "Открыть слайдер изображений в режиме предпросмотра", + "toggle_image_slider_within_quick_preview": "Открыть слайдер изображений в режиме быстрого просмотра", "toggle_inspector": "Открыть инспектор", "toggle_job_manager": "Открыть менеджер задач", "toggle_metadata": "Показать метаданные", "toggle_path_bar": "Открыть адресную строку", "toggle_quick_preview": "Открыть предпросмотр", + "tools": "Инструменты", + "trash": "Корзина", "type": "Тип", "ui_animations": "UI Анимации", "ui_animations_description": "Диалоговые окна и другие элементы пользовательского интерфейса будут анимироваться при открытии и закрытии.", @@ -477,8 +488,8 @@ "updated_successfully": "Успешно обновлено, вы используете версию {{version}}", "usage": "Использование", "usage_description": "Информация об использовании библиотеки и информация об вашем аппаратном обеспечении", - "vaccum": "Вакуум", - "vaccum_library": "Вакуумная библиотека", + "vaccum": "Vacuum", + "vaccum_library": "Vacuum библиотеки", "vaccum_library_description": "Переупакуйте базу данных, чтобы освободить ненужное пространство.", "value": "Значение", "version": "Версия {{version}}", diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json index 9fea6c168..dac19fb33 100644 --- a/interface/locales/tr/common.json +++ b/interface/locales/tr/common.json @@ -227,6 +227,8 @@ "install": "Yükle", "install_update": "Güncellemeyi Yükle", "installed": "Yüklendi", + "ipv6": "IPv6 ağı", + "ipv6_description": "IPv6 ağını kullanarak eşler arası iletişime izin verin", "item_size": "Öğe Boyutu", "item_with_count_one": "{{count}} madde", "item_with_count_other": "{{count}} maddeler", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive gizlilik için tasarlandı, bu yüzden açık kaynaklı ve yerel ilkeliyiz. Bu yüzden hangi verilerin bizimle paylaşıldığı konusunda çok açık olacağız.", "quick_preview": "Hızlı Önizleme", "quick_view": "Hızlı bakış", + "random": "Rastgele", "recent_jobs": "Son İşler", "recents": "Son Kullanılanlar", "recents_notice_message": "Son kullanılanlar, bir dosyayı açtığınızda oluşturulur.", @@ -377,6 +380,8 @@ "reindex": "Yeniden İndeksle", "reject": "Reddet", "reload": "Yeniden Yükle", + "remote_access": "Uzaktan erişimi etkinleştir", + "remote_access_description": "Diğer düğümlerin doğrudan bu düğüme bağlanmasını etkinleştirin.", "remove": "Kaldır", "remove_from_recents": "Son Kullanılanlardan Kaldır", "rename": "Yeniden Adlandır", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedrive Hesabı", "spacedrive_cloud": "Spacedrive Bulutu", "spacedrive_cloud_description": "Spacedrive her zaman yerel odaklıdır, ancak gelecekte kendi isteğe bağlı bulut hizmetlerimizi sunacağız. Şu anda kimlik doğrulama yalnızca Geri Bildirim özelliği için kullanılır, aksi takdirde gerekli değildir.", + "spacedrop": "Uzay damlası görünürlüğü", "spacedrop_a_file": "Bir Dosya Spacedropa", "spacedrop_already_progress": "Spacedrop zaten devam ediyor", + "spacedrop_contacts_only": "Yalnızca Kişiler", "spacedrop_description": "Ağınızda Spacedrive çalıştıran cihazlarla anında paylaşın.", + "spacedrop_disabled": "Engelli", + "spacedrop_everyone": "Herkes", "spacedrop_rejected": "Spacedrop reddedildi", "square_thumbnails": "Kare Küçük Resimler", "star_on_github": "GitHub'da Yıldızla", @@ -468,6 +477,8 @@ "toggle_metadata": "Meta verileri aç/kapat", "toggle_path_bar": "Yol çubuğunu aç/kapat", "toggle_quick_preview": "Hızlı önizlemeyi aç/kapat", + "tools": "Aletler", + "trash": "Çöp", "type": "Tip", "ui_animations": "UI Animasyonları", "ui_animations_description": "Diyaloglar ve diğer UI elementleri açılırken ve kapanırken animasyon gösterecek.", diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json index 96f7e20b2..842e2c698 100644 --- a/interface/locales/zh-CN/common.json +++ b/interface/locales/zh-CN/common.json @@ -1,7 +1,7 @@ { "about": "关于", - "about_vision_text": "我们很多人拥有好几个云账户,磁盘没有备份,数据也有丢失的风险。我们依赖像 Google 照片、iCloud 这样的云服务,但是它们容量有限,互操作性几乎为零,云服务和操作系统之间也无法协作。相册不应该困在一种生态系统中,也不应该当广告数据来收割。它们应该与操作系统无关,永久保存,由我们自己所有。我们创造的数据是我们的遗产,它们将比我们活得更久——开源技术是确保我们对定义我们生活的数据拥有绝对控制权的唯一方式,它没有限制。", - "about_vision_title": "愿景", + "about_vision_text": "我们很多人拥有不止云账户,磁盘没有备份,数据也有丢失的风险。我们依赖于像 Google 照片、iCloud 这样的云服务,但是它们容量有限,且互操作性几乎为零,云服务和操作系统之间也无法协作。我们的照片不应该困在一种生态系统中,也不应该当广告数据来收割。它们应该与操作系统无关、永久保存、由我们自己所有。我们创造的数据是我们的遗产,它们的寿命会比我们还要长——既然这些数据定义了我们的生活,开源技术是确保我们对这些数据拥有绝对控制权的唯一方式,它的规模没有限制。", + "about_vision_title": "项目远景", "accept": "接受", "accessed": "已访问", "account": "账户", @@ -11,18 +11,18 @@ "add_library": "添加库", "add_location": "添加位置", "add_location_description": "通过将您喜爱的位置添加到个人库中,增强您的 Spacedrive 体验,以实现无缝高效的文件管理。", - "add_location_tooltip": "将路径作为索引位置添加", + "add_location_tooltip": "将路径添加为索引", "add_locations": "添加位置", "add_tag": "添加标签", "advanced_settings": "高级设置", - "all_jobs_have_been_cleared": "所有任务已被清除。", - "alpha_release_description": "我们很高兴您能尝试 Spacedrive,现在处于 Alpha 发布阶段,展示了激动人心的新功能。与任何初始版本一样,这个版本可能包含一些错误。我们恳请您在我们的Discord频道上报告您遇到的任何问题。您宝贵的反馈将极大地有助于增强用户体验。", + "all_jobs_have_been_cleared": "所有任务已清除。", + "alpha_release_description": "感谢试用 Spacedrive。现在 Spacedrive 处于 Alpha 发布阶段,展示了激动人心的新功能。作为初始版本,它可能包含一些错误。我们恳请您在我们的 Discord 频道上反馈遇到的任何问题,您宝贵的反馈将有助于极大增强用户体验。", "alpha_release_title": "Alpha 版本", "appearance": "外观", - "appearance_description": "改变客户端的外观。", + "appearance_description": "调整客户端的外观。", "archive": "存档", "archive_coming_soon": "存档位置功能即将推出……", - "archive_info": "将库中的数据作为存档提取,有利于保留位置文件夹结构。", + "archive_info": "将库中的数据作为存档提取,有利于保留位置的目录结构。", "are_you_sure": "您确定吗?", "ask_spacedrive": "询问 Spacedrive", "assign_tag": "分配标签", @@ -36,31 +36,32 @@ "cancel_selection": "取消选择", "celcius": "摄氏度", "change": "更改", - "change_view_setting_description": "更改默认资源管理器视图", "changelog": "更新日志", - "changelog_page_description": "查看我们正在开发的酷炫新功能", + "changelog_page_description": "看看我们在开发哪些酷炫的新功能", + "change_view_setting_description": "更改默认资源管理器视图", "changelog_page_title": "更新日志", "checksum": "校验和", "clear_finished_jobs": "清除已完成的任务", "client": "客户端", "close": "关闭", + "close_current_tab": "关闭当前标签页", + "clouds": "云服务", "close_command_palette": "关闭命令面板", "close_current_tab": "关闭当前标签页", - "clouds": "云", "color": "颜色", "coming_soon": "即将推出", "compress": "压缩", "configure_location": "配置位置", "connected": "已连接", "contacts": "联系人", - "contacts_description": "在Spacedrive中管理您的联系人。", + "contacts_description": "在 Spacedrive 中管理您的联系人。", "content_id": "内容ID", "continue": "继续", "convert_to": "转换为", "coordinates": "坐标", "copied": "已复制", "copy": "复制", - "copy_as_path": "复制为路径", + "copy_as_path": "复制路径", "copy_object": "复制对象", "copy_path_to_clipboard": "复制路径到剪贴板", "copy_success": "项目已复制", @@ -70,15 +71,15 @@ "create_folder_error": "创建文件夹时出错", "create_folder_success": "创建了新文件夹:{{name}}", "create_library": "创建库", - "create_library_description": "库是一个安全的,设备上的数据库。您的文件保持原位,库对其进行目录编制并存储所有Spacedrive相关数据。", + "create_library_description": "库(library),也即数据库(database),存储在设备上,非常安全。您的文件就放在原来的位置上,库对它们的目录结构进行索引,同时存储所有与 Spacedrive 相关的数据。", "create_new_library": "创建新库", - "create_new_library_description": "库是一个安全的,设备上的数据库。您的文件保持原位,库对其进行目录编制并存储所有Spacedrive相关数据。", + "create_new_library_description": "库(library),也即数据库(database),存储在设备上,非常安全。您的文件就放在原来的位置上,库对它们的目录结构进行索引,同时存储所有与 Spacedrive 相关的数据。", "create_new_tag": "创建新标签", - "create_new_tag_description": "选择一个名称和颜色。", + "create_new_tag_description": "设置名称与颜色。", "create_tag": "创建标签", "created": "已创建", "creating_library": "正在创建库…", - "creating_your_library": "正在创建您的库", + "creating_your_library": "正在为您创建库", "current": "当前使用", "current_directory": "当前目录", "current_directory_with_descendants": "当前目录及其子目录", @@ -86,33 +87,33 @@ "cut": "剪切", "cut_object": "剪切对象", "cut_success": "剪切项目", - "data_folder": "数据目录", + "data_folder": "数据文件夹", "date_accessed": "访问日期", "date_created": "创建日期", "date_indexed": "索引日期", "date_modified": "修改日期", "debug_mode": "调试模式", - "debug_mode_description": "在应用内启用额外的调试功能。", + "debug_mode_description": "启用本应用额外的调试功能。", "default": "默认", "default_settings": "默认设置", "delete": "删除", "delete_dialog_title": "删除 {{prefix}} {{type}}", "delete_forever": "永久删除", - "delete_info": "这不会删除磁盘上的实际文件夹。预览媒体将被删除。", + "delete_info": "此操作只删除预览媒体,并不会删除磁盘上实际的文件夹。", "delete_library": "删除库", - "delete_library_description": "这是永久性的,您的文件不会被删除,只有Spacedrive库会被删除。", + "delete_library_description": "Spacedrive 库将会永久删除,但您的文件不会删除。", "delete_location": "删除位置", - "delete_location_description": "删除一个位置也将删除所有与之关联的文件从Spacedrive数据库中,文件本身不会被删除。", + "delete_location_description": "删除位置时,Spacedrive 会从数据库中移除所有与之相关的文件,但是不会删除文件本身。", "delete_object": "删除对象", "delete_rule": "删除规则", "delete_tag": "删除标签", - "delete_tag_description": "您确定要删除这个标签吗?这不能被撤销,打过标签的文件将会被取消链接。", - "delete_warning": "这将永久删除您的{{type}},我们目前还没有回收站…", + "delete_tag_description": "您确定要删除这个标签吗?此操作不能撤销,打过标签的文件将会取消标签。", + "delete_warning": "警告:这将永久删除您的{{type}},我们目前还没有回收站…", "description": "描述", "deselect": "取消选择", "details": "详情", "devices": "设备", - "devices_coming_soon_tooltip": "即将推出!这个Alpha版本不包括库同步,很快就会准备好。", + "devices_coming_soon_tooltip": "即将推出!本 Alpha 版本不支持库同步,此功能很快就会准备就绪。", "dialog": "对话框", "dialog_shortcut_description": "执行操作", "direction": "方向", @@ -125,7 +126,7 @@ "dont_show_again": "不再显示", "double_click_action": "双击操作", "download": "下载", - "downloading_update": "下载更新", + "downloading_update": "正在下载更新", "duplicate": "复制", "duplicate_object": "复制对象", "duplicate_success": "项目已复制", @@ -135,7 +136,7 @@ "empty_file": "空的文件", "enable_networking": "启用网络", "enable_networking_description": "允许您的节点与您周围的其他 Spacedrive 节点进行通信。", - "enable_networking_description_required": "库同步或 Spacedrop 所需!", + "enable_networking_description_required": "库的同步和 Spacedrop 需要开启本功能!", "encrypt": "加密", "encrypt_library": "加密库", "encrypt_library_coming_soon": "库加密即将推出", @@ -194,7 +195,7 @@ "full_reindex_info": "执行对此位置的完全重新扫描。", "general": "通用", "general_settings": "通用设置", - "general_settings_description": "与本客户端相关的一般设置。", + "general_settings_description": "与此客户端相关的一般设置。", "general_shortcut_description": "通用快捷键", "generatePreviewMedia_label": "为这个位置生成预览媒体", "generate_checksums": "生成校验和", @@ -205,7 +206,7 @@ "go_to_recents": "转到最近的内容", "go_to_settings": "前往设置", "go_to_tag": "转到标签", - "got_it": "明白了", + "got_it": "我知道了", "grid_gap": "间隙", "grid_view": "网格视图", "grid_view_notice_description": "通过网格视图直观地了解您的文件。这种视图以缩略图形式显示您的文件和文件夹,方便您快速识别所寻找的文件。", @@ -215,7 +216,7 @@ "hide_in_sidebar": "在侧边栏中隐藏", "hide_in_sidebar_description": "阻止此标签在应用的侧边栏中显示。", "hide_location_from_view": "隐藏位置和内容的视图", - "home": "主页", + "home": "我的文档", "icon_size": "图标大小", "image_labeler_ai_model": "图像标签识别 AI 模型", "image_labeler_ai_model_description": "用于识别图像中对象的模型。较大的模型更准确但速度较慢。", @@ -227,7 +228,11 @@ "install": "安装", "install_update": "安装更新", "installed": "已安装", + "ipv6": "IPv6网络", + "ipv6_description": "允许使用 IPv6 网络进行点对点通信", "item_size": "项目大小", + "icon_size": "图标大小", + "text_size": "文字大小", "item_with_count_one": "{{count}} 项目", "item_with_count_other": "{{count}} 项目", "job_has_been_canceled": "作业已取消。", @@ -269,17 +274,16 @@ "location_is_already_linked": "位置已经链接", "location_path_info": "此位置的路径,这是文件在磁盘上的存储位置。", "location_type": "位置类型", - "location_type_managed": "Spacedrive将为您排序文件。如果位置不为空,将创建一个“spacedrive”文件夹。", + "location_type_managed": "Spacedrive 将为您排序文件。如果位置不为空,将创建一个“spacedrive”文件夹。", "location_type_normal": "内容将按原样索引,新文件不会自动排序。", "location_type_replica": "此位置是另一个位置的副本,其内容将自动同步。", "locations": "位置", "locations_description": "管理您的存储位置。", "lock": "锁定", - "log_in": "登录", - "log_in_with_browser": "用浏览器登录", + "log_in_with_browser": "使用浏览器登录", "log_out": "退出登录", - "logged_in_as": "已登录为{{email}}", - "logging_in": "在登录...", + "logged_in_as": "已登录为 {{email}}", + "logging_in": "正在登录...", "logout": "退出登录", "manage_library": "管理库", "managed": "已管理", @@ -298,7 +302,7 @@ "move_back_within_quick_preview": "在快速预览中后退", "move_files": "移动文件", "move_forward_within_quick_preview": "在快速预览中前进", - "move_to_trash": "移到废纸篓", + "move_to_trash": "移到回收站(废纸篓)", "name": "名称", "navigate_back": "回退", "navigate_backwards": "向后导航", @@ -323,8 +327,8 @@ "new_tag": "新标签", "new_update_available": "新版本可用!", "no_favorite_items": "没有最喜欢的物品", - "no_items_found": "未找到任何项目", - "no_jobs": "没有作业。", + "no_items_found": "找不到任何项目", + "no_jobs": "没有任务。", "no_labels": "无标签", "no_nodes_found": "找不到 Spacedrive 节点.", "no_tag_selected": "没有选中的标签", @@ -368,6 +372,7 @@ "privacy_description": "Spacedrive是为隐私而构建的,这就是为什么我们是开源的,以本地优先。因此,我们会非常明确地告诉您与我们分享了什么数据。", "quick_preview": "快速预览", "quick_view": "快速查看", + "random": "随机的", "recent_jobs": "最近的作业", "recents": "最近使用", "recents_notice_message": "打开文件时会创建最近的文件。", @@ -377,6 +382,8 @@ "reindex": "重新索引", "reject": "拒绝", "reload": "重新加载", + "remote_access": "启用远程访问", + "remote_access_description": "使其他节点能够直接连接到该节点。", "remove": "移除", "remove_from_recents": "从最近使用中移除", "rename": "重命名", @@ -429,9 +436,13 @@ "spacedrive_account": "Spacedrive 账户", "spacedrive_cloud": "Spacedrive 云", "spacedrive_cloud_description": "Spacedrive 始终优先重视本地资源,但我们未来会提供可选的自有云服务。目前,身份验证仅用于反馈功能。", + "spacedrop": "太空空投可见度", "spacedrop_a_file": "使用 Spacedrop 传输文件", "spacedrop_already_progress": "Spacedrop 已在进行中", + "spacedrop_contacts_only": "仅限联系人", "spacedrop_description": "与在您的网络上运行 Spacedrive 的设备即时共享。", + "spacedrop_disabled": "残疾人", + "spacedrop_everyone": "每个人", "spacedrop_rejected": "Spacedrop 被拒绝", "square_thumbnails": "方形缩略图", "star_on_github": "在 GitHub 上送一个 star", @@ -468,6 +479,8 @@ "toggle_metadata": "切换元数据", "toggle_path_bar": "切换显示路径栏", "toggle_quick_preview": "切换快速预览", + "tools": "工具", + "trash": "垃圾", "type": "类型", "ui_animations": "用户界面动画", "ui_animations_description": "打开和关闭时对话框和其他用户界面元素将产生动画效果。", diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json index 91fe11c95..e656e2ae7 100644 --- a/interface/locales/zh-TW/common.json +++ b/interface/locales/zh-TW/common.json @@ -227,6 +227,8 @@ "install": "安裝", "install_update": "安裝更新", "installed": "已安裝", + "ipv6": "IPv6網路", + "ipv6_description": "允許使用 IPv6 網路進行點對點通訊", "item_size": "項目大小", "item_with_count_one": "{{count}} 项目", "item_with_count_other": "{{count}} 项目", @@ -368,6 +370,7 @@ "privacy_description": "Spacedrive是為隱私而構建的,這就是為什麼我們是開源的並且首先在本地。所以我們將非常清楚地告知我們分享了哪些數據。", "quick_preview": "快速預覽", "quick_view": "快速查看", + "random": "隨機的", "recent_jobs": "最近的工作", "recents": "最近的文件", "recents_notice_message": "開啟檔案時會建立最近的檔案。", @@ -377,6 +380,8 @@ "reindex": "重新索引", "reject": "拒絕", "reload": "重載", + "remote_access": "啟用遠端存取", + "remote_access_description": "使其他節點能夠直接連接到該節點。", "remove": "移除", "remove_from_recents": "從最近的文件中移除", "rename": "重命名", @@ -429,9 +434,13 @@ "spacedrive_account": "Spacedrive 帳戶", "spacedrive_cloud": "Spacedrive 雲端", "spacedrive_cloud_description": "Spacedrive 始終以本地為先,但我們將來會提供自己的選擇性雲端服務。目前,身份驗證僅用於反饋功能,否則不需要。", + "spacedrop": "太空空投可見度", "spacedrop_a_file": "Spacedrop文件", "spacedrop_already_progress": "Spacedrop 已在進行中", + "spacedrop_contacts_only": "限聯絡人", "spacedrop_description": "與在您的網路上運行 Spacedrive 的裝置立即分享。", + "spacedrop_disabled": "殘障人士", + "spacedrop_everyone": "每個人", "spacedrop_rejected": "Spacedrop 被拒絕", "square_thumbnails": "方形縮略圖", "star_on_github": "在GitHub上給星", @@ -468,6 +477,8 @@ "toggle_metadata": "切換元數據", "toggle_path_bar": "切換顯示路徑列", "toggle_quick_preview": "切換快速預覽", + "tools": "工具", + "trash": "垃圾", "type": "類型", "ui_animations": "UI動畫", "ui_animations_description": "對話框和其它UI元素在打開和關閉時會有動畫效果。", diff --git a/interface/util/Platform.tsx b/interface/util/Platform.tsx index 2c10c50b8..f70bb5960 100644 --- a/interface/util/Platform.tsx +++ b/interface/util/Platform.tsx @@ -36,6 +36,7 @@ export type Platform = { showDevtools?(): void; openPath?(path: string): void; openLogsDir?(): void; + openTrashInOsExplorer?(): void; userHomeDir?(): Promise; // Opens a file path with a given ID openFilePaths?(library: string, ids: number[]): any; diff --git a/package.json b/package.json index 90a6c5ecb..0da060fd0 100644 --- a/package.json +++ b/package.json @@ -67,5 +67,6 @@ "eslintConfig": { "root": true }, - "packageManager": "pnpm@9.0.2" + "packageManager": "pnpm@9.0.6", + "engineStrict": false } diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index c2214e145..c537b3efe 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -40,6 +40,7 @@ export type Procedures = { { key: "notifications.dismiss", input: NotificationId, result: null } | { key: "notifications.dismissAll", input: never, result: null } | { key: "notifications.get", input: never, result: Notification[] } | + { key: "p2p.listeners", input: never, result: Listeners } | { key: "p2p.state", input: never, result: JsonValue } | { key: "preferences.get", input: LibraryArgs, result: LibraryPreferences } | { key: "search.objects", input: LibraryArgs, result: SearchData } | @@ -133,6 +134,7 @@ export type Procedures = { subscriptions: { key: "auth.loginSession", input: never, result: Response } | { key: "invalidation.listen", input: never, result: InvalidateOperationEvent[] } | + { key: "jobs.newFilePathIdentified", input: LibraryArgs, result: number[] } | { key: "jobs.newThumbnail", input: LibraryArgs, result: string[] } | { key: "jobs.progress", input: LibraryArgs, result: JobProgressEvent } | { key: "library.actors", input: LibraryArgs, result: { [key in string]: boolean } } | @@ -154,7 +156,7 @@ export type AudioMetadata = { duration: number | null; audio_codec: string | nul * * If you want a variant of this to show up on the frontend it must be added to `backendFeatures` in `useFeatureFlag.tsx` */ -export type BackendFeature = "filesOverP2P" | "cloudSync" +export type BackendFeature = "cloudSync" export type Backup = ({ id: string; timestamp: string; library_id: string; library_name: string }) & { path: string } @@ -168,7 +170,7 @@ export type CacheNode = { __type: string; __id: string; "#node": any } export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null } -export type ChangeNodeNameArgs = { name: string | null; p2p_ipv4_port: Port | null; p2p_ipv6_port: Port | null; p2p_discovery: P2PDiscoveryState | null; image_labeler_version: string | null } +export type ChangeNodeNameArgs = { name: string | null; p2p_port: Port | null; p2p_ipv4_enabled: boolean | null; p2p_ipv6_enabled: boolean | null; p2p_discovery: P2PDiscoveryState | null; p2p_remote_access: boolean | null; image_labeler_version: string | null } export type CloudInstance = { id: string; uuid: string; identity: RemoteIdentity; nodeId: string; metadata: { [key in string]: string } } @@ -236,9 +238,11 @@ export type EphemeralFileCreateContextTypes = "empty" | "text" export type EphemeralFileSystemOps = { sources: string[]; target_dir: string } -export type EphemeralPathSearchArgs = { from: PathFrom; path: string; withHiddenFiles: boolean } +export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } -export type EphemeralPathsResultItem = { entries: Reference[]; errors: string[]; nodes: CacheNode[] } +export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null } + +export type EphemeralPathsResultItem = { entries: Reference[]; errors: Error[]; nodes: CacheNode[] } export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind } @@ -248,7 +252,14 @@ export type EphemeralRenameMany = { from_pattern: FromPattern; to_pattern: strin export type EphemeralRenameOne = { from_path: string; to: string } -export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; item: Location } | { type: "NonIndexedPath"; thumbnail: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } +export type Error = { code: ErrorCode; message: string } + +/** + * TODO + */ +export type ErrorCode = "BadRequest" | "Unauthorized" | "Forbidden" | "NotFound" | "Timeout" | "Conflict" | "PreconditionFailed" | "PayloadTooLarge" | "MethodNotSupported" | "ClientClosedRequest" | "InternalServerError" + +export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: ObjectWithFilePaths } | { type: "NonIndexedPath"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: NonIndexedPathItem } | { type: "Location"; item: Location } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } export type ExplorerLayout = "grid" | "list" | "media" @@ -408,7 +419,9 @@ export type LibraryPreferences = { location?: { [key in string]: LocationSetting export type LightScanArgs = { location_id: number; sub_path: string } -export type Listener2 = { id: string; name: string; addrs: string[] } +export type ListenerState = { type: "Listening" } | { type: "Error"; error: string } | { type: "Disabled" } + +export type Listeners = { ipv4: ListenerState; ipv6: ListenerState } export type Location = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; scan_state: number; instance_id: number | null } @@ -447,6 +460,8 @@ export type MediaLocation = { latitude: number; longitude: number; pluscode: Plu export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) +export type NodeConfigP2P = { discovery?: P2PDiscoveryState; port: Port; ipv4: boolean; ipv6: boolean; remote_access: boolean } + export type NodePreferences = { thumbnailer: ThumbnailerPreferences } export type NodeState = ({ @@ -457,7 +472,7 @@ id: string; /** * name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record */ -name: string; identity: RemoteIdentity; p2p_ipv4_port: Port; p2p_ipv6_port: Port; p2p_discovery: P2PDiscoveryState; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; listeners: Listener2[]; device_model: string | null } +name: string; identity: RemoteIdentity; p2p: NodeConfigP2P; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; device_model: string | null } export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean } @@ -530,13 +545,11 @@ export type P2PDiscoveryState = "Everyone" | "ContactsOnly" | "Disabled" export type P2PEvent = { type: "PeerChange"; identity: RemoteIdentity; connection: ConnectionMethod; discovery: DiscoveryMethod; metadata: PeerMetadata } | { type: "PeerDelete"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedOut"; id: string } | { type: "SpacedropRejected"; id: string } -export type PathFrom = "path" - export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; device_model: HardwareModel | null; version: string | null } export type PlusCode = string -export type Port = null | number +export type Port = { type: "random" } | { type: "discrete"; value: number } export type Range = { from: T } | { to: T } diff --git a/packages/client/src/lib/explorerItem.ts b/packages/client/src/lib/explorerItem.ts index ad93fea2a..fb80fd30e 100644 --- a/packages/client/src/lib/explorerItem.ts +++ b/packages/client/src/lib/explorerItem.ts @@ -34,18 +34,20 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { switch (data.type) { // the getItemObject and getItemFilePath type-guards mean we can handle the following types in one case case 'Object': + case 'NonIndexedPath': case 'Path': { // handle object const object = getItemObject(data); - if (object) { - if (object.kind) itemData.kind = ObjectKind[object.kind] ?? 'Unknown'; - if ('media_data' in object && object.media_data?.media_date) { - const byteArray = object.media_data.media_date; - const dateString = String.fromCharCode.apply(null, byteArray); - const [date, time] = dateString.replace(/"/g, '').split(' '); - if (date && time) itemData.dateTaken = `${date}T${time}Z`; - } + if (object?.kind) itemData.kind = ObjectKind[object?.kind] ?? 'Unknown'; + else if (data.type === 'NonIndexedPath') + itemData.kind = ObjectKind[data.item.kind] ?? 'Unknown'; + + if (object && 'media_data' in object && object.media_data?.media_date) { + const byteArray = object.media_data.media_date; + const dateString = String.fromCharCode.apply(null, byteArray); + const [date, time] = dateString.replace(/"/g, '').split(' '); + if (date && time) itemData.dateTaken = `${date}T${time}Z`; } // Objects only have dateCreated and dateAccessed @@ -58,38 +60,7 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { itemData.thumbnailKeys = [data.thumbnail]; } - itemData.hasLocalThumbnail = !!data.thumbnail; - // handle file path - const filePath = getItemFilePath(data); - if (filePath) { - itemData.name = filePath.name; - itemData.fullName = getFullName(filePath.name, filePath.extension); - itemData.size = byteSize(filePath.size_in_bytes_bytes); - itemData.isDir = filePath.is_dir ?? false; - itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null; - // - if ('cas_id' in filePath) itemData.casId = filePath.cas_id; - if ('location_id' in filePath) itemData.locationId = filePath.location_id; - if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed; - if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified; - } - break; - } - case 'NonIndexedPath': { - if (data.item?.kind) itemData.kind = ObjectKind[data.item?.kind] ?? 'Unknown'; - else if (data.type === 'NonIndexedPath') - itemData.kind = ObjectKind[data.item.kind] ?? 'Unknown'; - - // Objects only have dateCreated and dateAccessed - itemData.dateCreated = data.item?.date_created ?? null; - // handle thumbnail based on provided key - // This could be better, but for now we're mapping the backend property to two different local properties (thumbnailKey, thumbnailKeys) for backward compatibility - if (data.thumbnail) { - itemData.thumbnailKey = data.thumbnail; - itemData.thumbnailKeys = [data.thumbnail]; - } - - itemData.hasLocalThumbnail = !!data.thumbnail; + itemData.hasLocalThumbnail = data.has_created_thumbnail; // handle file path const filePath = getItemFilePath(data); if (filePath) { diff --git a/packages/client/src/stores/featureFlags.tsx b/packages/client/src/stores/featureFlags.tsx index f5cf1393e..4001c6f23 100644 --- a/packages/client/src/stores/featureFlags.tsx +++ b/packages/client/src/stores/featureFlags.tsx @@ -11,12 +11,13 @@ export const features = [ 'solidJsDemo', 'hostedLocations', 'debugDragAndDrop', - 'searchTargetSwitcher' + 'searchTargetSwitcher', + 'wipP2P' ] as const; // This defines which backend feature flags show up in the UI. // This is kinda a hack to not having the runtime array of possible features as Specta only exports the types. -export const backendFeatures: BackendFeature[] = ['filesOverP2P', 'cloudSync']; +export const backendFeatures: BackendFeature[] = ['cloudSync']; export type FeatureFlag = (typeof features)[number] | BackendFeature; diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index 1139df489..106947d1f 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -15,7 +15,7 @@ interface ContextMenuProps extends RadixCM.MenuContentProps { export const contextMenuClassNames = clsx( 'z-50 max-h-[calc(100vh-20px)] overflow-y-auto', 'my-2 min-w-48 max-w-64 py-0.5', - 'cool-shadow bg-menu', + 'cool-shadow bg-menu/95 backdrop-blur-lg', 'border border-menu-line', 'cursor-default select-none rounded-md', 'animate-in fade-in' diff --git a/packages/ui/style/colors.scss b/packages/ui/style/colors.scss index 23f545165..2f7bc6f29 100644 --- a/packages/ui/style/colors.scss +++ b/packages/ui/style/colors.scss @@ -45,9 +45,9 @@ --color-app-slider: var(--dark-hue), 15%, 20%; --color-app-explorer-scrollbar: var(--dark-hue), 20%, 25%; // menu - --color-menu: var(--dark-hue), 25%, 5%; - --color-menu-line: var(--dark-hue), 15%, 11%; - --color-menu-ink: var(--dark-hue), 5%, 100%; + --color-menu: var(--dark-hue), 15%, 10%; + --color-menu-line: var(--dark-hue), 15%, 14%; + --color-menu-ink: var(--dark-hue), 10%, 100%; --color-menu-faint: var(--dark-hue), 5%, 80%; --color-menu-hover: var(--dark-hue), 15%, 30%; --color-menu-selected: var(--dark-hue), 5%, 30%; @@ -99,7 +99,7 @@ --color-app-slider: var(--light-hue), 5%, 95%; --color-app-explorer-scrollbar: var(--light-hue), 5%, 75%; // menu - --color-menu: var(--light-hue), 5%, 100%; + --color-menu: var(--light-hue), 5%, 98%; --color-menu-line: var(--light-hue), 5%, 95%; --color-menu-ink: var(--light-hue), 5%, 20%; --color-menu-faint: var(--light-hue), 5%, 80%; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a251aaf005a2742ebf8cca321425b134ae3e1028..28715774e8e14b0afb33a9a3747c982c5ac26a99 100644 GIT binary patch delta 147997 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 delta 117452 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 diff --git a/scripts/utils/fetch.mjs b/scripts/utils/fetch.mjs index dbf5b0225..778148fad 100644 --- a/scripts/utils/fetch.mjs +++ b/scripts/utils/fetch.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { getSystemProxy } from 'os-proxy-config' import { fetch, Headers, Agent, ProxyAgent } from 'undici' +const CONNECT_TIMEOUT = 5 * 60 * 1000 const __debug = env.NODE_ENV === 'debug' const __offline = env.OFFLINE === 'true' const __filename = fileURLToPath(import.meta.url) @@ -15,12 +16,19 @@ const cacheDir = joinPath(__dirname, '.tmp') /** @type {Agent.Options} */ const agentOpts = { allowH2: true, + connect: { timeout: CONNECT_TIMEOUT }, + connectTimeout: CONNECT_TIMEOUT, autoSelectFamily: true, } const { proxyUrl } = (await getSystemProxy()) ?? {} const dispatcher = proxyUrl - ? new ProxyAgent({ ...agentOpts, uri: proxyUrl }) + ? new ProxyAgent({ + ...agentOpts, + proxyTls: { timeout: CONNECT_TIMEOUT }, + requestTls: { timeout: CONNECT_TIMEOUT }, + uri: proxyUrl, + }) : new Agent(agentOpts) await fs.mkdir(cacheDir, { recursive: true, mode: 0o751 })