From 017ef8b005cb9836ad9e504ea8d66ab30e449095 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 11 Jul 2022 10:05:24 +0800 Subject: [PATCH] Library manager (#258) --- Cargo.lock | Bin 167334 -> 168055 bytes apps/desktop/src-tauri/src/main.rs | 23 +- apps/server/k8s/infrastructure.yaml | 42 -- apps/server/k8s/sdserver.yaml | 118 ----- apps/server/src/main.rs | 29 +- apps/web/src/App.tsx | 10 + core/Cargo.toml | 3 +- core/bindings/ClientCommand.ts | 3 +- core/bindings/ClientQuery.ts | 3 +- core/bindings/ConfigMetadata.ts | 3 + core/bindings/CoreResponse.ts | 3 +- core/bindings/LibraryCommand.ts | 3 + core/bindings/LibraryConfig.ts | 3 + core/bindings/LibraryConfigWrapped.ts | 4 + core/bindings/LibraryQuery.ts | 3 + core/bindings/NodeConfig.ts | 3 + core/bindings/NodeState.ts | 3 +- core/index.ts | 6 + .../migration.sql | 29 ++ core/prisma/schema.prisma | 16 +- core/src/encode/thumb.rs | 39 +- core/src/file/cas/identifier.rs | 25 +- core/src/file/explorer/open.rs | 16 +- core/src/file/indexer/mod.rs | 7 +- core/src/file/indexer/scan.rs | 15 +- core/src/file/mod.rs | 33 +- core/src/job/jobs.rs | 114 +++-- core/src/job/worker.rs | 65 ++- core/src/lib.rs | 464 +++++++++--------- core/src/library/library_config.rs | 72 +++ core/src/library/library_ctx.rs | 46 ++ core/src/library/library_manager.rs | 271 ++++++++++ core/src/library/loader.rs | 99 ---- core/src/library/mod.rs | 8 +- core/src/library/statistics.rs | 74 +-- core/src/node/config.rs | 149 ++++++ core/src/node/mod.rs | 75 +-- core/src/node/state.rs | 107 ---- core/src/sys/locations.rs | 63 +-- core/src/sys/volumes.rs | 16 +- core/src/util/db.rs | 220 ++++----- packages/client/package.json | 20 +- packages/client/src/bridge.ts | 75 ++- .../src/context}/AppPropsContext.tsx | 0 packages/client/src/context/index.ts | 1 + packages/client/src/files/index.ts | 2 - packages/client/src/files/query.ts | 21 - packages/client/src/files/state.ts | 23 - packages/client/src/hooks/index.ts | 1 + packages/client/src/hooks/useCoreEvents.tsx | 59 +++ packages/client/src/index.ts | 4 +- packages/client/src/stores/index.ts | 4 + .../src/stores/useExplorerStore.ts} | 8 +- .../src/stores/useInspectorStore.ts} | 9 +- packages/client/src/stores/useLibraryStore.ts | 67 +++ packages/interface/package.json | 3 +- packages/interface/src/App.tsx | 4 +- packages/interface/src/AppLayout.tsx | 2 +- packages/interface/src/AppRouter.tsx | 105 ++-- packages/interface/src/NotFound.tsx | 2 +- .../src/components/file/FileList.tsx | 14 +- .../src/components/file/FileThumb.tsx | 2 +- .../src/components/file/Inspector.tsx | 4 +- .../interface/src/components/file/Sidebar.tsx | 90 ++-- .../interface/src/components/layout/Card.tsx | 15 + .../src/components/layout/Dialog.tsx | 8 +- .../src/components/layout/TopBar.tsx | 10 +- .../components/location/LocationListItem.tsx | 8 +- .../components/settings/SettingsContainer.tsx | 2 +- .../components/settings/SettingsHeader.tsx | 12 +- .../settings/SettingsScreenContainer.tsx | 40 ++ .../interface/src/hooks/useCoreEvents.tsx | 46 -- packages/interface/src/index.ts | 3 +- packages/interface/src/screens/Debug.tsx | 21 +- packages/interface/src/screens/Explorer.tsx | 10 +- packages/interface/src/screens/Overview.tsx | 25 +- packages/interface/src/screens/Settings.tsx | 92 ---- .../settings/CurrentLibrarySettings.tsx | 42 ++ .../src/screens/settings/GeneralSettings.tsx | 40 -- .../src/screens/settings/LibrarySettings.tsx | 32 -- .../src/screens/settings/SecuritySettings.tsx | 23 - .../src/screens/settings/Settings.tsx | 83 ++++ .../{ => client}/AppearanceSettings.tsx | 4 +- .../settings/client/GeneralSettings.tsx | 35 ++ .../{ => library}/ContactsSettings.tsx | 4 +- .../settings/{ => library}/KeysSetting.tsx | 4 +- .../library/LibraryGeneralSettings.tsx | 91 ++++ .../settings/library/LocationSettings.tsx | 55 +++ .../settings/library/SecuritySettings.tsx | 14 + .../{ => library}/SharingSettings.tsx | 4 +- .../settings/{ => library}/SyncSettings.tsx | 4 +- .../settings/{ => library}/TagsSettings.tsx | 4 +- .../{ => node}/ExperimentalSettings.tsx | 12 +- .../settings/node/LibrariesSettings.tsx | 113 +++++ .../screens/settings/node/NodesSettings.tsx | 12 + .../src/screens/settings/node/P2PSettings.tsx | 40 ++ packages/ui/package.json | 2 +- packages/ui/src/Input.tsx | 2 +- pnpm-lock.yaml | Bin 621229 -> 625653 bytes 99 files changed, 2181 insertions(+), 1536 deletions(-) delete mode 100644 apps/server/k8s/infrastructure.yaml delete mode 100644 apps/server/k8s/sdserver.yaml create mode 100644 core/bindings/ConfigMetadata.ts create mode 100644 core/bindings/LibraryCommand.ts create mode 100644 core/bindings/LibraryConfig.ts create mode 100644 core/bindings/LibraryConfigWrapped.ts create mode 100644 core/bindings/LibraryQuery.ts create mode 100644 core/bindings/NodeConfig.ts create mode 100644 core/prisma/migrations/20220625180107_remove_library/migration.sql create mode 100644 core/src/library/library_config.rs create mode 100644 core/src/library/library_ctx.rs create mode 100644 core/src/library/library_manager.rs delete mode 100644 core/src/library/loader.rs create mode 100644 core/src/node/config.rs delete mode 100644 core/src/node/state.rs rename packages/{interface/src => client/src/context}/AppPropsContext.tsx (100%) create mode 100644 packages/client/src/context/index.ts delete mode 100644 packages/client/src/files/index.ts delete mode 100644 packages/client/src/files/query.ts delete mode 100644 packages/client/src/files/state.ts create mode 100644 packages/client/src/hooks/index.ts create mode 100644 packages/client/src/hooks/useCoreEvents.tsx create mode 100644 packages/client/src/stores/index.ts rename packages/{interface/src/hooks/useExplorerState.ts => client/src/stores/useExplorerStore.ts} (81%) rename packages/{interface/src/hooks/useInspectorState.tsx => client/src/stores/useInspectorStore.ts} (82%) create mode 100644 packages/client/src/stores/useLibraryStore.ts create mode 100644 packages/interface/src/components/layout/Card.tsx create mode 100644 packages/interface/src/components/settings/SettingsScreenContainer.tsx delete mode 100644 packages/interface/src/hooks/useCoreEvents.tsx delete mode 100644 packages/interface/src/screens/Settings.tsx create mode 100644 packages/interface/src/screens/settings/CurrentLibrarySettings.tsx delete mode 100644 packages/interface/src/screens/settings/GeneralSettings.tsx delete mode 100644 packages/interface/src/screens/settings/LibrarySettings.tsx delete mode 100644 packages/interface/src/screens/settings/SecuritySettings.tsx create mode 100644 packages/interface/src/screens/settings/Settings.tsx rename packages/interface/src/screens/settings/{ => client}/AppearanceSettings.tsx (58%) create mode 100644 packages/interface/src/screens/settings/client/GeneralSettings.tsx rename packages/interface/src/screens/settings/{ => library}/ContactsSettings.tsx (58%) rename packages/interface/src/screens/settings/{ => library}/KeysSetting.tsx (55%) create mode 100644 packages/interface/src/screens/settings/library/LibraryGeneralSettings.tsx create mode 100644 packages/interface/src/screens/settings/library/LocationSettings.tsx create mode 100644 packages/interface/src/screens/settings/library/SecuritySettings.tsx rename packages/interface/src/screens/settings/{ => library}/SharingSettings.tsx (58%) rename packages/interface/src/screens/settings/{ => library}/SyncSettings.tsx (56%) rename packages/interface/src/screens/settings/{ => library}/TagsSettings.tsx (55%) rename packages/interface/src/screens/settings/{ => node}/ExperimentalSettings.tsx (64%) create mode 100644 packages/interface/src/screens/settings/node/LibrariesSettings.tsx create mode 100644 packages/interface/src/screens/settings/node/NodesSettings.tsx create mode 100644 packages/interface/src/screens/settings/node/P2PSettings.tsx diff --git a/Cargo.lock b/Cargo.lock index ca71bf05e6c80fe1745988f1e5a2a2837b8930c0..3989e138c81bb696ef6e248feb128d429770c148 100644 GIT binary patch delta 4562 zcmYjVd5m3E8PBx z6Ce{kx0b2{O%i?^p8pV-n;KC-}l?T z_xQs@?>{#5)R&h$bOpKMLRU)ADNq|(6rL67b5W8O=Cxo>_!NzZvVesqK%3x zTXa_S^z+C>`|8N*c{_09&=6zmHx8{^f6J~NEg3mu{>bu=4^=kMOizYDP2yCE7*oiM zX6_4jPI+D!(~${5h11fB6dBj9Xzy5}K3krdGqnBV_fDFBX3aTk8pi6olx&zERePGc z>-S{qR8%4rJZ^I2My6tO5>XkfVvDfMbD4ADxdiW| z%N(UHGRI?))^M&B&Z4v^NqZ}!Wu@>)3-^X{9leY!S=HZk-Rop!yYAY-_VR5*$Lz56 zn)i-*wd30HdiXGzoZ(HnG51F2xIzi;kIF zR%eFDc)kB3GSq%;`(XR<=o2)+emZFSzg?} zs(s|m*>>ahpR}{Tn3*&Q%?31L7AQpk4kBlqOQ*6?z@dwh35hPJq$L{Ri_Ed$*nxRd zY~448BcTdogE#Grd3($|cC-sut!PU{popG7cWCK8k^{_S&-s>+9E%kJP7sPR^Qt@1Z-+-dCrmL{7;G z?5}hHNx;7DmNk^7l#j7kbrGe@?b`P1DKlvab?ZVsBliHRP zqNAd_p60lZ7f=S{nNCi79)xir=v-_}kP+&6!j9Y+7A3c$-Lh$-KD7-DdGXMJ{)(G3 zBuG#Vnu7O1;i*8Q*G76SEOa3_>b$eca;L$)po9ro#+(e)h}Id^OmxH>Xj3!;g-%=B3qmo6JsP`6OepxLqg|4KL6eDRhd?qO4jQ>S+<$YLR8I!g~*?(ki-S zVvOxReNwyf)PpJ>jUzy7WNp0PBack3>HbGz=-eyU(6+(x0$Ua{m=rwmGj>_qV?TNKd%(+W+C z-KJRsWK0y!n2A2P`fQJk5BQSja{Fz6at$M7U43CCnVzBsPvpWvv;iQAI7(F-A6ow>3ZZ(n|S@R&yQURXtb1Zk=4F>-S6feCWONWK4E z?4=9@C2|o>)BfR86~AV$xmRsf{l3#3h)xO z%);0JS7sbM?sTgDc~di7|91=p%@GBHi3C%cV3E-zPquP<>WWg_{n5Qt( zBrHUbT0BQ(G_9wo!%EXT^Hy?M(|-QW6Whajb`sX%0y+o=!6|)WDXN?^kdd=?Is~C0 zF6b&}Mq49Uw#>q{D9{6ItNtno_G|xwZQUzJ*Wb8v5zc-0*qwK!-2dO68|N~RFFMlw z@b2cU-pyYmPp%@eU6`JpgrsSYWpig*8l8m?WDoCxYryuS6$R%~K5zzxWvRfwu80U2 z0q1IG4ve=?EG+9CxsS+Ygx4($0RTdc6Y~uELXBeJz@ZP6JE5s$xE3>1!*ep4!aTH- z(oWeR@IF}jVXa1c2P(OH7^={7-zB$=*Zq~Ot^etf6MBF6DIv$zYXZ3CsfT7p!)*cD zEe`Dfq!dHzE&G5g~rXF$;zDUgLA%v)zB&czfN2%X+6BA+L~r zBHYXrgj;ioe>9>9L-tS-_MSbw2T241fZXYo2C+J~iA6Yx=qRMM+}E*!+Rad=9}rvb z_$?Wz-&p`W$$MnxLIjb>aDrqJn;3-&DIQgDUg#{j!e0iUU=tb<2{DZ;oE7>9k1?g4 zJ}}Yy>~F{iE9+ZR&FmB}i6#UJr<_R+dCqFQ1+hl2EF=?)dn09k8Dt9WkO4~U^cxR> zWLcEZrtI@}qw4JIW-_oRz)&y@fA*i*w6ClZBR#3_i z6dXPXidPwygD?t!+HQDg_r0*bx3ILifPl1&lGEzz_mF`eTh_4t^2mA?>ZHLS7=ZGa zU=nKR9A{MPk}PT|k_!NvrqH_Jx=4@dN1??unNE`HvY|#|`8yvZtLo*W&5G)Wn$6we zC#ke3jYN%+)2+_1E&(6|q|_m*Q;B%4lj0P?8cL7DB-G0ploa}yJ6q}NUcGX-S+!)x zjl1{MG~8T#^e)d2H)jtYt9x$^H?#eXJ&KO`0Ak;lw2}aKj02EvY6FI~0E@&d0{P7f z+KU=w&k!kqEX-D>zwsLx#K%sP0|fY^=XK9y{z`-aOK;gJ!xgV-vKW%DyQ= z{*iDQ#yLz&28r8RDzxT4C6D011k>M@!zpr#>;(Y^d)B%x;?o zo0EEf8*5$|u6uZc(FC&*24fgp-~~O7R26CgDx}5~J9Od|P#eG!Kn@f`I(NvA2`0!{ z=QF+Sr#8opv^yW3o(dZ39v#>c6hltLTnT|T9HLnwEG#2^@PLHk!3u%wV^t2g5o8;k zQHu&axUsp6v@6o|BxBJiBxGNdf**GTF-$Ajb-yT_7+F(*IvNUs{!tJDBX&T8Wk-*8 z--CT~ul)Sx)Wu;P0Z1qtFa{S6U6iE@dEEP)prk%xxW-`3VY`9h&WI_H!YnBK2C5cw zcisBhUNYJ{Zd0@CxH@xbgZTrhBEWk5A@*pk@mqp1#v;NXh#-X`VQMHUl0M?20Btc0 z8G#)z17aqs`6F7$&vH?+CyeW&r8&robrTqd*`+c`#gXieEc4(jSH-2u;v5>vrLp@!sQ8 z&DCeOuMA>9%gPFTRRPWDbN3xgV_-=TrN{{VLL@61d0sG&F})idd_YP=E0$D%+ delta 4163 zcmYjUX^dV~8P2)T(w6Do&Xlo0r_v&%g|jarohgWlkuWnLSSTrH4G?QBGzhY!g=h!{ zrC#X?E(s)t7zxIdyNBY!IW|$I~!F@K6po&i8%!y3iT@0I}ehPx_eu*ypH>3XmHgT8;4Qu zrNbKrPfnF%h;0vSJE@)8vZ}5=ge15HdJ>!?Kamo)3!6E9iu-ojfhdGo#huDNu$oql6-oZ-~5Fu^>JI(VuXzV3X` zGsiKRIU{^@k!vAK3>gI_jW2PjbIG>)^<<& z72AH?Z#6+fVO&PTX+K<@1s2sJ7A2|XKG34Ax7HVq(|Rgswk8XF$#9091@`I>bKka; z+h1M2x~^VI&>!Ozj~?Iy4mH)K$drr=lyb^(CSP3AXrWG#s*(g{!YF~+(2zvTwq3ew z{oroDL7!{nm(nGT^Po-=E zfMgknMBBalKGoWDjvu_cZ}<9o)RKNh;SED?jHPH7pv`&AIpv(4GoYtZD5^x9ALI}y zO^V`N2!?4Vvuej~9~rEBxFJ29$0=q%gXm0EavJD%#nY0rC_=i37g#X{ET)Vq$4rqi z8WCA2(twcdp0kGAJKIHr7aw_OcEOM^WD0%Y#oH)^7l|1!N>(@@6@@2k04_QXCT7a< z5XJ~?I8c}Ue-~_Dd}PC5`!60^*#7C6$J^36%_7Deb`OU`ljaK@Fs!6w(0)Kuz!*tE9lHJ?$YNEVu}D zQAO!?=kJH>nQxPM_4aqkiQRL1$;x?zwVOXaIqpH*0CP#eSinM7W`NE~ho#UG1F7+u zQyCTGcou_G5Kk~Qacq<7o(<>(%3joc`Bxx*j@6hU!hHlKI`! zKO=89?Uo~xn+lInSdhYNtuu!cLjHn>?x79Xq%tPQ-r{w6uD zZhw?K+#Pw0JaJ6zY%|jR@F{ZP+}SC3$r5Y@Zs7rY$BoB|hH7CD926NHFc8=TPAHIy7OLxt)2AteIVt6|M_TO`~uMI*OGHpvd7F8p2u(>m!3Ra6X8r zWRTOD-3w-nN@wwhuAf=m^AMD=iOF$?>10rfd*;zQ5tTx+(aa+m2yOuc3q{ilQX1qd7{L?#0CI?|gypqWVz#Ym~bg=C!Q^S2U8ts00bn|txVwc~sG48lJZ@l)(ly_{r=K5>aZfI{l zJX&|mZMF{m|9dU#me(8aCDY@`pi)t|vUQL)1VoCs5jD(!0v*vtt88@0Pa2s*b7kSO zkTKMQGuu+{{UiC%U0-b0wA(*|lAgvmML8juN zt34s0H28xa!R#6IS2)WeIyOtn9Yf8rotxjBLF%4=lQs3k{bWve_kxDaY%;Y8Zh%#U z!n|B>#1zg}kR3~al10yyfShLw)?nF8sS(I$v@eDWC*R*g5n5k=fUK-P9cs>-Egz8? zp@+c-ELy;6E=a8j({jQ@gfW;E-a>@YG+K)OKxCk!3zWMM;RPx0YJ&H6fLX|ts6Ueug9yPt$olmcrr$Y#im27U3pePVlX2sZ|&aGfY1 zE4ZmO#kOFP4Y)thQfAoHuKCL8b?@Tl)9n>^0R6$Bs03Zc3?x-nA_4^s3$;R~UWy{% z+QXANCP6zy@EK*1#XHAmu=Uj?&8c~1_2>+A92G7 z9M5C2unnuYwNNLSU?#v9yvl*Kin1VBETVmV=SUq{*2wyg1t29*>JGxw+|2?oh?m>UQNalo}kYnU|!8_)#!s=>mqoHAU$yS(|v|L{^TThW}- z{^6~WZvTqr*T>d7u4*PXMQ9xongtB4riG$CT;sZclo35#o&|;!1ZW8?j4PA$&|zCZ zzr<;(Q|}_fub8`}6JyOAL+z`-okFNexCtc<`9m-)2}0u`W+0HE*+uFddXD61R4;?d zV=_=L01QW>v2Ok>`tUMuj_H1LTJtf|uKnKBCR{Iik5}4A=ZeFp6XFP{hhX4MzcPLh zN`&AUD9EMsmp;mkGjP3L4eKKmXy|W>#!#qq-&I(ig!jg*`s+e~9OE(uXOGT<;1(** zS>VuI52oV*t#y2sDx)80hPo$Nv+JmO^BPz>;s%-m(}gS}bqQ0n%(_UCP*nj;(FnK~ zQn8TP`rd(&!2|8@cDRGJ&n_LQFRNx@{gG{!%-imF#VhjF*L00;w#}_u#+p^#Z69eq zF}MBlb5j#=um091U?$AFjA)6VVxg1>@-X~>tWY?_)L~XaDTx!odTqfe5j=|P{MpUt z)>m}}nHt9x+2V4Ji9xY(*M#as2!}EhhXJC41bQ)VZ!vlZp+`VX1|kgzA{ADD6D0Tg ztD2GHacO&f^VB%Rn!!If29gr|j^o;m&O#zR{$yYip%Q6?<8BxoAkx?9KxyPVIC#Bx N4;ks~ZO!<+{{SfO>S+J~ diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 3dcad488c..3c6210e46 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,16 +1,15 @@ use std::time::{Duration, Instant}; use dotenvy::dotenv; -use sdcore::{ClientCommand, ClientQuery, CoreController, CoreEvent, CoreResponse, Node}; -use tauri::api::path; -use tauri::Manager; +use sdcore::{ClientCommand, ClientQuery, CoreEvent, CoreResponse, Node, NodeController}; +use tauri::{api::path, Manager}; #[cfg(target_os = "macos")] mod macos; mod menu; #[tauri::command(async)] async fn client_query_transport( - core: tauri::State<'_, CoreController>, + core: tauri::State<'_, NodeController>, data: ClientQuery, ) -> Result { match core.query(data).await { @@ -24,7 +23,7 @@ async fn client_query_transport( #[tauri::command(async)] async fn client_command_transport( - core: tauri::State<'_, CoreController>, + core: tauri::State<'_, NodeController>, data: ClientCommand, ) -> Result { match core.command(data).await { @@ -48,17 +47,11 @@ async fn main() { dotenv().ok(); env_logger::init(); - let data_dir = path::data_dir().unwrap_or(std::path::PathBuf::from("./")); + let mut data_dir = path::data_dir().unwrap_or(std::path::PathBuf::from("./")); + data_dir = data_dir.join("spacedrive"); // create an instance of the core - let (mut node, mut event_receiver) = Node::new(data_dir).await; - // run startup tasks - node.initializer().await; - // extract the node controller - let controller = node.get_controller(); - // throw the node into a dedicated thread - tokio::spawn(async move { - node.start().await; - }); + let (controller, mut event_receiver, node) = Node::new(data_dir).await; + tokio::spawn(node.start()); // create tauri app tauri::Builder::default() // pass controller to the tauri state manager diff --git a/apps/server/k8s/infrastructure.yaml b/apps/server/k8s/infrastructure.yaml deleted file mode 100644 index a5e44b4ee..000000000 --- a/apps/server/k8s/infrastructure.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Infrastructure setups up the Kubernetes cluster for Spacedrive! -# -# To get the service account token use the following: -# ```bash -# TOKENNAME=`kubectl -n spacedrive get sa/spacedrive-ci -o jsonpath='{.secrets[0].name}'` -# kubectl -n spacedrive get secret $TOKENNAME -o jsonpath='{.data.token}' | base64 -d -# ``` - -apiVersion: v1 -kind: Namespace -metadata: - name: spacedrive ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: spacedrive-ci - namespace: spacedrive ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: spacedrive-ns-full - namespace: spacedrive -rules: - - apiGroups: ['apps'] - resources: ['deployments'] - verbs: ['get', 'patch'] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: spacedrive-ci-rb - namespace: spacedrive -subjects: - - kind: ServiceAccount - name: spacedrive-ci - namespace: spacedrive -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: spacedrive-ns-full diff --git a/apps/server/k8s/sdserver.yaml b/apps/server/k8s/sdserver.yaml deleted file mode 100644 index 00f02c1c1..000000000 --- a/apps/server/k8s/sdserver.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# This will deploy the Spacedrive Server container to the `spacedrive`` namespace on Kubernetes. - -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: sdserver-ingress - namespace: spacedrive - labels: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver - annotations: - traefik.ingress.kubernetes.io/router.tls.certresolver: le - traefik.ingress.kubernetes.io/router.middlewares: kube-system-antiseo@kubernetescrd -spec: - rules: - - host: spacedrive.otbeaumont.me - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: sdserver-service - port: - number: 8080 ---- -apiVersion: v1 -kind: Service -metadata: - name: sdserver-service - namespace: spacedrive - labels: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver -spec: - ports: - - port: 8080 - targetPort: 8080 - protocol: TCP - selector: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: sdserver-pvc - namespace: spacedrive -spec: - accessModes: - - ReadWriteOnce - storageClassName: local-path - resources: - requests: - storage: 512M ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sdserver-deployment - namespace: spacedrive - labels: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver - template: - metadata: - labels: - app.kubernetes.io/name: sdserver - app.kubernetes.io/component: webserver - spec: - restartPolicy: Always - # refer to Dockerfile to find securityContext values - securityContext: - runAsUser: 101 - runAsGroup: 101 - fsGroup: 101 - containers: - - name: sdserver - image: ghcr.io/oscartbeaumont/spacedrive/server:staging - imagePullPolicy: Always - ports: - - containerPort: 8080 - volumeMounts: - - name: data-volume - mountPath: /data - securityContext: - allowPrivilegeEscalation: false - resources: - limits: - memory: 100Mi - cpu: 100m - requests: - memory: 5Mi - cpu: 10m - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 10 - failureThreshold: 4 - periodSeconds: 5 - livenessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 20 - failureThreshold: 3 - periodSeconds: 10 - volumes: - - name: data-volume - persistentVolumeClaim: - claimName: sdserver-pvc diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 7e9c4683e..5d7c85331 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,4 +1,4 @@ -use sdcore::{ClientCommand, ClientQuery, CoreController, CoreEvent, CoreResponse, Node}; +use sdcore::{ClientCommand, ClientQuery, CoreEvent, CoreResponse, Node, NodeController}; use std::{env, path::Path}; use actix::{ @@ -19,7 +19,7 @@ const DATA_DIR_ENV_VAR: &'static str = "DATA_DIR"; /// Define HTTP actor struct Socket { _event_receiver: web::Data>, - core: web::Data, + core: web::Data, } impl Actor for Socket { @@ -52,7 +52,15 @@ impl StreamHandler> for Socket { match msg { Ok(ws::Message::Ping(msg)) => ctx.pong(&msg), Ok(ws::Message::Text(text)) => { - let msg: SocketMessage = serde_json::from_str(&text).unwrap(); + let msg = serde_json::from_str::(&text); + + let msg = match msg { + Ok(msg) => msg, + Err(err) => { + println!("Error parsing message: {}", err); + return; + }, + }; let core = self.core.clone(); @@ -133,7 +141,7 @@ async fn ws_handler( req: HttpRequest, stream: web::Payload, event_receiver: web::Data>, - controller: web::Data, + controller: web::Data, ) -> Result { let resp = ws::start( Socket { @@ -178,7 +186,7 @@ async fn main() -> std::io::Result<()> { async fn setup() -> ( web::Data>, - web::Data, + web::Data, ) { let data_dir_path = match env::var(DATA_DIR_ENV_VAR) { Ok(path) => Path::new(&path).to_path_buf(), @@ -196,15 +204,8 @@ async fn setup() -> ( }, }; - let (mut node, event_receiver) = Node::new(data_dir_path).await; - - node.initializer().await; - - let controller = node.get_controller(); - - tokio::spawn(async move { - node.start().await; - }); + let (controller, event_receiver, node) = Node::new(data_dir_path).await; + tokio::spawn(node.start()); (web::Data::new(event_receiver), web::Data::new(controller)) } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 72560e3ff..07b125639 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,6 +31,16 @@ class Transport extends BaseTransport { }); } async query(query: ClientQuery) { + if (websocket.readyState == 0) { + let resolve: () => void; + const promise = new Promise((res) => { + resolve = () => res(undefined); + }); + // @ts-ignore + websocket.addEventListener('open', resolve); + await promise; + } + const id = randomId(); let resolve: (data: any) => void; diff --git a/core/Cargo.toml b/core/Cargo.toml index e748bfa2f..95e5f7546 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -24,10 +24,9 @@ ring = "0.17.0-alpha.10" int-enum = "0.4.0" # Project dependencies -ts-rs = { version = "6.1", features = ["chrono-impl"] } +ts-rs = { version = "6.1", features = ["chrono-impl", "uuid-impl"] } prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.5.0" } walkdir = "^2.3.2" -lazy_static = "1.4.0" uuid = "0.8" sysinfo = "0.23.9" thiserror = "1.0.30" diff --git a/core/bindings/ClientCommand.ts b/core/bindings/ClientCommand.ts index 2677dd55f..fd9b1f0a1 100644 --- a/core/bindings/ClientCommand.ts +++ b/core/bindings/ClientCommand.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LibraryCommand } from "./LibraryCommand"; -export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } }; \ No newline at end of file +export type ClientCommand = { key: "CreateLibrary", params: { name: string, } } | { key: "EditLibrary", params: { id: string, name: string | null, description: string | null, } } | { key: "DeleteLibrary", params: { id: string, } } | { key: "LibraryCommand", params: { library_id: string, command: LibraryCommand, } }; \ No newline at end of file diff --git a/core/bindings/ClientQuery.ts b/core/bindings/ClientQuery.ts index 9d4792a66..56e37988c 100644 --- a/core/bindings/ClientQuery.ts +++ b/core/bindings/ClientQuery.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LibraryQuery } from "./LibraryQuery"; -export type ClientQuery = { key: "NodeGetState" } | { key: "SysGetVolumes" } | { key: "LibGetTags" } | { key: "JobGetRunning" } | { key: "JobGetHistory" } | { key: "SysGetLocations" } | { key: "SysGetLocation", params: { id: number, } } | { key: "LibGetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" } | { key: "GetNodes" }; \ No newline at end of file +export type ClientQuery = { key: "NodeGetLibraries" } | { key: "NodeGetState" } | { key: "SysGetVolumes" } | { key: "JobGetRunning" } | { key: "GetNodes" } | { key: "LibraryQuery", params: { library_id: string, query: LibraryQuery, } }; \ No newline at end of file diff --git a/core/bindings/ConfigMetadata.ts b/core/bindings/ConfigMetadata.ts new file mode 100644 index 000000000..d12aaa575 --- /dev/null +++ b/core/bindings/ConfigMetadata.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface ConfigMetadata { version: string | null, } \ No newline at end of file diff --git a/core/bindings/CoreResponse.ts b/core/bindings/CoreResponse.ts index 94dc0568c..cabf79dfa 100644 --- a/core/bindings/CoreResponse.ts +++ b/core/bindings/CoreResponse.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectoryWithContents } from "./DirectoryWithContents"; import type { JobReport } from "./JobReport"; +import type { LibraryConfigWrapped } from "./LibraryConfigWrapped"; import type { LocationResource } from "./LocationResource"; import type { NodeState } from "./NodeState"; import type { Statistics } from "./Statistics"; import type { Volume } from "./Volume"; -export type CoreResponse = { key: "Success", data: null } | { key: "SysGetVolumes", data: Array } | { key: "SysGetLocation", data: LocationResource } | { key: "SysGetLocations", data: Array } | { key: "LibGetExplorerDir", data: DirectoryWithContents } | { key: "NodeGetState", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "JobGetRunning", data: Array } | { key: "JobGetHistory", data: Array } | { key: "GetLibraryStatistics", data: Statistics }; \ No newline at end of file +export type CoreResponse = { key: "Success", data: null } | { key: "Error", data: string } | { key: "NodeGetLibraries", data: Array } | { key: "SysGetVolumes", data: Array } | { key: "SysGetLocation", data: LocationResource } | { key: "SysGetLocations", data: Array } | { key: "LibGetExplorerDir", data: DirectoryWithContents } | { key: "NodeGetState", data: NodeState } | { key: "LocCreate", data: LocationResource } | { key: "JobGetRunning", data: Array } | { key: "JobGetHistory", data: Array } | { key: "GetLibraryStatistics", data: Statistics }; \ No newline at end of file diff --git a/core/bindings/LibraryCommand.ts b/core/bindings/LibraryCommand.ts new file mode 100644 index 000000000..713fc8989 --- /dev/null +++ b/core/bindings/LibraryCommand.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LibraryCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } }; \ No newline at end of file diff --git a/core/bindings/LibraryConfig.ts b/core/bindings/LibraryConfig.ts new file mode 100644 index 000000000..8a371014b --- /dev/null +++ b/core/bindings/LibraryConfig.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface LibraryConfig { version: string | null, name: string, description: string, } \ No newline at end of file diff --git a/core/bindings/LibraryConfigWrapped.ts b/core/bindings/LibraryConfigWrapped.ts new file mode 100644 index 000000000..ee5b5ccfe --- /dev/null +++ b/core/bindings/LibraryConfigWrapped.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LibraryConfig } from "./LibraryConfig"; + +export interface LibraryConfigWrapped { uuid: string, config: LibraryConfig, } \ No newline at end of file diff --git a/core/bindings/LibraryQuery.ts b/core/bindings/LibraryQuery.ts new file mode 100644 index 000000000..2aa14279c --- /dev/null +++ b/core/bindings/LibraryQuery.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LibraryQuery = { key: "LibGetTags" } | { key: "JobGetHistory" } | { key: "SysGetLocations" } | { key: "SysGetLocation", params: { id: number, } } | { key: "LibGetExplorerDir", params: { location_id: number, path: string, limit: number, } } | { key: "GetLibraryStatistics" }; \ No newline at end of file diff --git a/core/bindings/NodeConfig.ts b/core/bindings/NodeConfig.ts new file mode 100644 index 000000000..512f0202c --- /dev/null +++ b/core/bindings/NodeConfig.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface NodeConfig { version: string | null, id: string, name: string, p2p_port: number | null, } \ No newline at end of file diff --git a/core/bindings/NodeState.ts b/core/bindings/NodeState.ts index 6fc2d5c22..978fb3103 100644 --- a/core/bindings/NodeState.ts +++ b/core/bindings/NodeState.ts @@ -1,4 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LibraryState } from "./LibraryState"; -export interface NodeState { node_pub_id: string, node_id: number, node_name: string, data_path: string, tcp_port: number, libraries: Array, current_library_uuid: string, } \ No newline at end of file +export interface NodeState { version: string | null, id: string, name: string, p2p_port: number | null, data_path: string, } \ No newline at end of file diff --git a/core/index.ts b/core/index.ts index 85eee6629..60cc1bc54 100644 --- a/core/index.ts +++ b/core/index.ts @@ -2,6 +2,7 @@ export * from './bindings/Client'; export * from './bindings/ClientCommand'; export * from './bindings/ClientQuery'; export * from './bindings/ClientState'; +export * from './bindings/ConfigMetadata'; export * from './bindings/CoreEvent'; export * from './bindings/CoreResource'; export * from './bindings/CoreResponse'; @@ -12,9 +13,14 @@ export * from './bindings/FileKind'; export * from './bindings/FilePath'; export * from './bindings/JobReport'; export * from './bindings/JobStatus'; +export * from './bindings/LibraryCommand'; +export * from './bindings/LibraryConfig'; +export * from './bindings/LibraryConfigWrapped'; export * from './bindings/LibraryNode'; +export * from './bindings/LibraryQuery'; export * from './bindings/LibraryState'; export * from './bindings/LocationResource'; +export * from './bindings/NodeConfig'; export * from './bindings/NodeState'; export * from './bindings/Platform'; export * from './bindings/Statistics'; diff --git a/core/prisma/migrations/20220625180107_remove_library/migration.sql b/core/prisma/migrations/20220625180107_remove_library/migration.sql new file mode 100644 index 000000000..63e4f056f --- /dev/null +++ b/core/prisma/migrations/20220625180107_remove_library/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the `libraries` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `library_statistics` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "libraries"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "library_statistics"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "statistics" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "date_captured" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "total_file_count" INTEGER NOT NULL DEFAULT 0, + "library_db_size" TEXT NOT NULL DEFAULT '0', + "total_bytes_used" TEXT NOT NULL DEFAULT '0', + "total_bytes_capacity" TEXT NOT NULL DEFAULT '0', + "total_unique_bytes" TEXT NOT NULL DEFAULT '0', + "total_bytes_free" TEXT NOT NULL DEFAULT '0', + "preview_media_bytes" TEXT NOT NULL DEFAULT '0' +); diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index e8f911004..130151f62 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -35,21 +35,9 @@ model SyncEvent { @@map("sync_events") } -model Library { - id Int @id @default(autoincrement()) - pub_id String @unique - name String - is_primary Boolean @default(true) - date_created DateTime @default(now()) - timezone String? - - @@map("libraries") -} - -model LibraryStatistics { +model Statistics { id Int @id @default(autoincrement()) date_captured DateTime @default(now()) - library_id Int @unique total_file_count Int @default(0) library_db_size String @default("0") total_bytes_used String @default("0") @@ -58,7 +46,7 @@ model LibraryStatistics { total_bytes_free String @default("0") preview_media_bytes String @default("0") - @@map("library_statistics") + @@map("statistics") } model Node { diff --git a/core/src/encode/thumb.rs b/core/src/encode/thumb.rs index bb148d46b..f4665551e 100644 --- a/core/src/encode/thumb.rs +++ b/core/src/encode/thumb.rs @@ -1,9 +1,8 @@ -use crate::job::JobReportUpdate; -use crate::node::get_nodestate; +use crate::job::{JobReportUpdate, JobResult}; +use crate::library::LibraryContext; use crate::{ job::{Job, WorkerContext}, prisma::file_path, - CoreContext, }; use crate::{sys, CoreEvent}; use futures::executor::block_on; @@ -29,11 +28,18 @@ impl Job for ThumbnailJob { fn name(&self) -> &'static str { "thumbnailer" } - async fn run(&self, ctx: WorkerContext) -> Result<(), Box> { - let config = get_nodestate(); - let core_ctx = ctx.core_ctx.clone(); + async fn run(&self, ctx: WorkerContext) -> JobResult { + let library_ctx = ctx.library_ctx(); + let thumbnail_dir = Path::new(&library_ctx.config().data_directory()) + .join(THUMBNAIL_CACHE_DIR_NAME) + .join(format!("{}", self.location_id)); - let location = sys::get_location(&core_ctx, self.location_id).await?; + let location = sys::get_location(&library_ctx, self.location_id).await?; + + info!( + "Searching for images in location {} at path {}", + location.id, self.path + ); info!( "Searching for images in location {} at path {}", @@ -41,17 +47,12 @@ impl Job for ThumbnailJob { ); // create all necessary directories if they don't exist - fs::create_dir_all( - Path::new(&config.data_path) - .join(THUMBNAIL_CACHE_DIR_NAME) - .join(format!("{}", self.location_id)), - )?; + fs::create_dir_all(&thumbnail_dir)?; let root_path = location.path.unwrap(); // query database for all files in this location that need thumbnails - let image_files = get_images(&core_ctx, self.location_id, &self.path).await?; + let image_files = get_images(&library_ctx, self.location_id, &self.path).await?; info!("Found {:?} files", image_files.len()); - let is_background = self.background.clone(); tokio::task::spawn_blocking(move || { @@ -89,9 +90,7 @@ impl Job for ThumbnailJob { }; // Define and write the WebP-encoded file to a given path - let output_path = Path::new(&config.data_path) - .join(THUMBNAIL_CACHE_DIR_NAME) - .join(format!("{}", location.id)) + let output_path = Path::new(&thumbnail_dir) .join(&cas_id) .with_extension("webp"); @@ -107,7 +106,7 @@ impl Job for ThumbnailJob { ctx.progress(vec![JobReportUpdate::CompletedTaskCount(i + 1)]); if !is_background { - block_on(ctx.core_ctx.emit(CoreEvent::NewThumbnail { cas_id })); + block_on(ctx.library_ctx().emit(CoreEvent::NewThumbnail { cas_id })); }; } else { info!("Thumb exists, skipping... {}", output_path.display()); @@ -146,7 +145,7 @@ pub fn generate_thumbnail( } pub async fn get_images( - ctx: &CoreContext, + ctx: &LibraryContext, location_id: i32, path: &str, ) -> Result, std::io::Error> { @@ -166,7 +165,7 @@ pub async fn get_images( } let image_files = ctx - .database + .db .file_path() .find_many(params) .with(file_path::file::fetch()) diff --git a/core/src/file/cas/identifier.rs b/core/src/file/cas/identifier.rs index 94e5c5aaf..251ae4120 100644 --- a/core/src/file/cas/identifier.rs +++ b/core/src/file/cas/identifier.rs @@ -2,10 +2,10 @@ use super::checksum::generate_cas_id; use crate::{ file::FileError, job::JobReportUpdate, - job::{Job, WorkerContext}, + job::{Job, JobResult, WorkerContext}, + library::LibraryContext, prisma::{file, file_path}, sys::get_location, - CoreContext, }; use chrono::{DateTime, FixedOffset}; use futures::executor::block_on; @@ -33,13 +33,14 @@ impl Job for FileIdentifierJob { fn name(&self) -> &'static str { "file_identifier" } - async fn run(&self, ctx: WorkerContext) -> Result<(), Box> { + + async fn run(&self, ctx: WorkerContext) -> JobResult { info!("Identifying orphan file paths..."); - let location = get_location(&ctx.core_ctx, self.location_id).await?; + let location = get_location(&ctx.library_ctx(), self.location_id).await?; let location_path = location.path.unwrap_or("".to_string()); - let total_count = count_orphan_file_paths(&ctx.core_ctx, location.id.into()).await?; + let total_count = count_orphan_file_paths(&ctx.library_ctx(), location.id.into()).await?; info!("Found {} orphan file paths", total_count); let task_count = (total_count as f64 / CHUNK_SIZE as f64).ceil() as usize; @@ -48,9 +49,9 @@ impl Job for FileIdentifierJob { // update job with total task count based on orphan file_paths count ctx.progress(vec![JobReportUpdate::TaskCount(task_count)]); - let db = ctx.core_ctx.database.clone(); // dedicated tokio thread for task let _ctx = tokio::task::spawn_blocking(move || { + let db = ctx.library_ctx().db; let mut completed: usize = 0; let mut cursor: i32 = 1; // loop until task count is complete @@ -60,7 +61,7 @@ impl Job for FileIdentifierJob { let mut cas_lookup: HashMap = HashMap::new(); // get chunk of orphans to process - let file_paths = match block_on(get_orphan_file_paths(&ctx.core_ctx, cursor)) { + let file_paths = match block_on(get_orphan_file_paths(&ctx.library_ctx(), cursor)) { Ok(file_paths) => file_paths, Err(e) => { info!("Error getting orphan file paths: {}", e); @@ -192,11 +193,10 @@ struct CountRes { } pub async fn count_orphan_file_paths( - ctx: &CoreContext, + ctx: &LibraryContext, location_id: i64, ) -> Result { - let db = &ctx.database; - let files_count = db + let files_count = ctx.db ._query_raw::(raw!( "SELECT COUNT(*) AS count FROM file_paths WHERE file_id IS NULL AND is_dir IS FALSE AND location_id = {}", PrismaValue::Int(location_id) @@ -206,10 +206,10 @@ pub async fn count_orphan_file_paths( } pub async fn get_orphan_file_paths( - ctx: &CoreContext, + ctx: &LibraryContext, cursor: i32, ) -> Result, FileError> { - let db = &ctx.database; + let db = &ctx.db; info!( "discovering {} orphan file paths at cursor: {:?}", CHUNK_SIZE, cursor @@ -225,6 +225,7 @@ pub async fn get_orphan_file_paths( .take(CHUNK_SIZE as i64) .exec() .await?; + Ok(files) } diff --git a/core/src/file/explorer/open.rs b/core/src/file/explorer/open.rs index bedfba5a0..8afe1c9d8 100644 --- a/core/src/file/explorer/open.rs +++ b/core/src/file/explorer/open.rs @@ -1,25 +1,22 @@ use crate::{ encode::THUMBNAIL_CACHE_DIR_NAME, file::{DirectoryWithContents, FileError, FilePath}, - node::get_nodestate, + library::LibraryContext, prisma::file_path, sys::get_location, - CoreContext, }; use std::path::Path; pub async fn open_dir( - ctx: &CoreContext, + ctx: &LibraryContext, location_id: &i32, path: &str, ) -> Result { - let db = &ctx.database; - let config = get_nodestate(); - // get location let location = get_location(ctx, location_id.clone()).await?; - let directory = db + let directory = ctx + .db .file_path() .find_first(vec![ file_path::location_id::equals(Some(location.id)), @@ -32,7 +29,8 @@ pub async fn open_dir( println!("DIRECTORY: {:?}", directory); - let mut file_paths: Vec = db + let mut file_paths: Vec = ctx + .db .file_path() .find_many(vec![ file_path::location_id::equals(Some(location.id)), @@ -47,7 +45,7 @@ pub async fn open_dir( for file_path in &mut file_paths { if let Some(file) = &mut file_path.file { - let thumb_path = Path::new(&config.data_path) + let thumb_path = Path::new(&ctx.config().data_directory()) .join(THUMBNAIL_CACHE_DIR_NAME) .join(format!("{}", location.id)) .join(file.cas_id.clone()) diff --git a/core/src/file/indexer/mod.rs b/core/src/file/indexer/mod.rs index 4415b252c..c5e54d036 100644 --- a/core/src/file/indexer/mod.rs +++ b/core/src/file/indexer/mod.rs @@ -1,4 +1,4 @@ -use crate::job::{Job, JobReportUpdate, WorkerContext}; +use crate::job::{Job, JobReportUpdate, JobResult, WorkerContext}; use self::scan::ScanProgress; mod scan; @@ -17,9 +17,8 @@ impl Job for IndexerJob { fn name(&self) -> &'static str { "indexer" } - async fn run(&self, ctx: WorkerContext) -> Result<(), Box> { - let core_ctx = ctx.core_ctx.clone(); - scan_path(&core_ctx, self.path.as_str(), move |p| { + async fn run(&self, ctx: WorkerContext) -> JobResult { + scan_path(&ctx.library_ctx(), self.path.as_str(), move |p| { ctx.progress( p.iter() .map(|p| match p.clone() { diff --git a/core/src/file/indexer/scan.rs b/core/src/file/indexer/scan.rs index 19c3ee53d..0651b7b40 100644 --- a/core/src/file/indexer/scan.rs +++ b/core/src/file/indexer/scan.rs @@ -1,6 +1,7 @@ +use crate::job::JobResult; +use crate::library::LibraryContext; use crate::sys::{create_location, LocationResource}; -use crate::CoreContext; -use chrono::{DateTime, FixedOffset, Utc}; +use chrono::{DateTime, Utc}; use log::{error, info}; use prisma_client_rust::prisma_models::PrismaValue; use prisma_client_rust::raw; @@ -21,11 +22,10 @@ static BATCH_SIZE: usize = 100; // creates a vector of valid path buffers from a directory pub async fn scan_path( - ctx: &CoreContext, + ctx: &LibraryContext, path: &str, on_progress: impl Fn(Vec) + Send + Sync + 'static, -) -> Result<(), Box> { - let db = &ctx.database; +) -> JobResult { let path = path.to_string(); let location = create_location(&ctx, &path).await?; @@ -36,7 +36,8 @@ pub async fn scan_path( id: Option, } // grab the next id so we can increment in memory for batch inserting - let first_file_id = match db + let first_file_id = match ctx + .db ._query_raw::(raw!("SELECT MAX(id) id FROM file_paths")) .await { @@ -162,7 +163,7 @@ pub async fn scan_path( files ); - let count = db._execute_raw(raw).await; + let count = ctx.db._execute_raw(raw).await; info!("Inserted {:?} records", count); } diff --git a/core/src/file/mod.rs b/core/src/file/mod.rs index bc632ecec..c8955e7f1 100644 --- a/core/src/file/mod.rs +++ b/core/src/file/mod.rs @@ -1,12 +1,14 @@ +use chrono::{DateTime, Utc}; use int_enum::IntEnum; use serde::{Deserialize, Serialize}; use thiserror::Error; use ts_rs::TS; use crate::{ + library::LibraryContext, prisma::{self, file, file_path}, sys::SysError, - ClientQuery, CoreContext, CoreError, CoreEvent, CoreResponse, + ClientQuery, CoreError, CoreEvent, CoreResponse, LibraryQuery, }; pub mod cas; pub mod explorer; @@ -32,9 +34,9 @@ pub struct File { pub ipfs_id: Option, pub note: Option, - pub date_created: chrono::DateTime, - pub date_modified: chrono::DateTime, - pub date_indexed: chrono::DateTime, + pub date_created: DateTime, + pub date_modified: DateTime, + pub date_indexed: DateTime, pub paths: Vec, // pub media_data: Option, @@ -55,9 +57,9 @@ pub struct FilePath { pub file_id: Option, pub parent_id: Option, - pub date_created: chrono::DateTime, - pub date_modified: chrono::DateTime, - pub date_indexed: chrono::DateTime, + pub date_created: DateTime, + pub date_modified: DateTime, + pub date_indexed: DateTime, pub file: Option, } @@ -141,12 +143,12 @@ pub enum FileError { } pub async fn set_note( - ctx: CoreContext, + ctx: LibraryContext, id: i32, note: Option, ) -> Result { - let response = ctx - .database + let _response = ctx + .db .file() .find_unique(file::id::equals(id)) .update(vec![file::note::set(note.clone())]) @@ -154,10 +156,13 @@ pub async fn set_note( .await .unwrap(); - ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibGetExplorerDir { - limit: 0, - path: "".to_string(), - location_id: 0, + ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { + library_id: ctx.id.to_string(), + query: LibraryQuery::LibGetExplorerDir { + limit: 0, + path: "".to_string(), + location_id: 0, + }, })) .await; diff --git a/core/src/job/jobs.rs b/core/src/job/jobs.rs index efacd6cb8..cb4f42cab 100644 --- a/core/src/job/jobs.rs +++ b/core/src/job/jobs.rs @@ -3,48 +3,69 @@ use super::{ JobError, }; use crate::{ - node::get_nodestate, + library::LibraryContext, prisma::{job, node}, - CoreContext, }; use int_enum::IntEnum; use log::info; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, + error::Error, fmt::Debug, sync::Arc, }; -use tokio::sync::Mutex; +use tokio::sync::{mpsc, Mutex, RwLock}; use ts_rs::TS; // db is single threaded, nerd const MAX_WORKERS: usize = 1; +pub type JobResult = Result<(), Box>; + #[async_trait::async_trait] pub trait Job: Send + Sync + Debug { - async fn run(&self, ctx: WorkerContext) -> Result<(), Box>; + async fn run(&self, ctx: WorkerContext) -> JobResult; fn name(&self) -> &'static str; } -// jobs struct is maintained by the core -pub struct Jobs { - job_queue: VecDeque>, - // workers are spawned when jobs are picked off the queue - running_workers: HashMap>>, +pub enum JobManagerEvent { + IngestJob(LibraryContext, Box), } -impl Jobs { - pub fn new() -> Self { - Self { - job_queue: VecDeque::new(), - running_workers: HashMap::new(), - } +// jobs struct is maintained by the core +pub struct JobManager { + job_queue: RwLock>>, + // workers are spawned when jobs are picked off the queue + running_workers: RwLock>>>, + internal_sender: mpsc::UnboundedSender, +} + +impl JobManager { + pub fn new() -> Arc { + let (internal_sender, mut internal_reciever) = mpsc::unbounded_channel(); + let this = Arc::new(Self { + job_queue: RwLock::new(VecDeque::new()), + running_workers: RwLock::new(HashMap::new()), + internal_sender, + }); + + let this2 = this.clone(); + tokio::spawn(async move { + while let Some(event) = internal_reciever.recv().await { + match event { + JobManagerEvent::IngestJob(ctx, job) => this2.clone().ingest(&ctx, job).await, + } + } + }); + + this } - pub async fn ingest(&mut self, ctx: &CoreContext, job: Box) { + pub async fn ingest(self: Arc, ctx: &LibraryContext, job: Box) { // create worker to process job - if self.running_workers.len() < MAX_WORKERS { + let mut running_workers = self.running_workers.write().await; + if running_workers.len() < MAX_WORKERS { info!("Running job: {:?}", job.name()); let worker = Worker::new(job); @@ -52,51 +73,57 @@ impl Jobs { let wrapped_worker = Arc::new(Mutex::new(worker)); - Worker::spawn(wrapped_worker.clone(), ctx).await; + Worker::spawn(self.clone(), wrapped_worker.clone(), ctx).await; - self.running_workers.insert(id, wrapped_worker); + running_workers.insert(id, wrapped_worker); } else { - self.job_queue.push_back(job); + self.job_queue.write().await.push_back(job); } } - pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box) { - self.job_queue.push_back(job); + pub async fn ingest_queue(&self, _ctx: &LibraryContext, job: Box) { + self.job_queue.write().await.push_back(job); } - pub async fn complete(&mut self, ctx: &CoreContext, job_id: String) { + + pub async fn complete(self: Arc, ctx: &LibraryContext, job_id: String) { // remove worker from running workers - self.running_workers.remove(&job_id); + self.running_workers.write().await.remove(&job_id); // continue queue - let job = self.job_queue.pop_front(); + let job = self.job_queue.write().await.pop_front(); if let Some(job) = job { - self.ingest(ctx, job).await; + // We can't directly execute `self.ingest` here because it would cause an async cycle. + self.internal_sender + .send(JobManagerEvent::IngestJob(ctx.clone(), job)) + .unwrap_or_else(|_| { + println!("Failed to ingest job!"); + }); } } pub async fn get_running(&self) -> Vec { let mut ret = vec![]; - for worker in self.running_workers.values() { + for worker in self.running_workers.read().await.values() { let worker = worker.lock().await; ret.push(worker.job_report.clone()); } ret } - pub async fn queue_pending_job(ctx: &CoreContext) -> Result<(), JobError> { - let db = &ctx.database; + // pub async fn queue_pending_job(ctx: &LibraryContext) -> Result<(), JobError> { + // let db = &ctx.db; - let next_job = db - .job() - .find_first(vec![job::status::equals(JobStatus::Queued.int_value())]) - .exec() - .await?; + // let _next_job = db + // .job() + // .find_first(vec![job::status::equals(JobStatus::Queued.int_value())]) + // .exec() + // .await?; - Ok(()) - } + // Ok(()) + // } - pub async fn get_history(ctx: &CoreContext) -> Result, JobError> { - let db = &ctx.database; + pub async fn get_history(ctx: &LibraryContext) -> Result, JobError> { + let db = &ctx.db; let jobs = db .job() .find_many(vec![job::status::not(JobStatus::Running.int_value())]) @@ -172,30 +199,29 @@ impl JobReport { seconds_elapsed: 0, } } - pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> { - let config = get_nodestate(); + pub async fn create(&self, ctx: &LibraryContext) -> Result<(), JobError> { let mut params = Vec::new(); if let Some(_) = &self.data { params.push(job::data::set(self.data.clone())) } - ctx.database + ctx.db .job() .create( job::id::set(self.id.clone()), job::name::set(self.name.clone()), job::action::set(1), - job::nodes::link(node::id::equals(config.node_id)), + job::nodes::link(node::id::equals(ctx.node_local_id)), params, ) .exec() .await?; Ok(()) } - pub async fn update(&self, ctx: &CoreContext) -> Result<(), JobError> { - ctx.database + pub async fn update(&self, ctx: &LibraryContext) -> Result<(), JobError> { + ctx.db .job() .find_unique(job::id::equals(self.id.clone())) .update(vec![ diff --git a/core/src/job/worker.rs b/core/src/job/worker.rs index 6022e603e..29c0ec17e 100644 --- a/core/src/job/worker.rs +++ b/core/src/job/worker.rs @@ -1,8 +1,8 @@ use super::{ jobs::{JobReport, JobReportUpdate, JobStatus}, - Job, + Job, JobManager, }; -use crate::{ClientQuery, CoreContext, CoreEvent, InternalEvent}; +use crate::{library::LibraryContext, ClientQuery, CoreEvent, LibraryQuery}; use std::{sync::Arc, time::Duration}; use tokio::{ sync::{ @@ -26,8 +26,8 @@ enum WorkerState { #[derive(Clone)] pub struct WorkerContext { pub uuid: String, - pub core_ctx: CoreContext, - pub sender: UnboundedSender, + library_ctx: LibraryContext, + sender: UnboundedSender, } impl WorkerContext { @@ -36,9 +36,13 @@ impl WorkerContext { .send(WorkerEvent::Progressed(updates)) .unwrap_or(()); } + + pub fn library_ctx(&self) -> LibraryContext { + self.library_ctx.clone() + } + // save the job data to // pub fn save_data () { - // } } @@ -63,7 +67,11 @@ impl Worker { } } // spawns a thread and extracts channel sender to communicate with it - pub async fn spawn(worker: Arc>, ctx: &CoreContext) { + pub async fn spawn( + job_manager: Arc, + worker: Arc>, + ctx: &LibraryContext, + ) { // we capture the worker receiver channel so state can be updated from inside the worker let mut worker_mut = worker.lock().await; // extract owned job and receiver from Self @@ -76,10 +84,11 @@ impl Worker { WorkerState::Running => unreachable!(), }; let worker_sender = worker_mut.worker_sender.clone(); - let core_ctx = ctx.clone(); worker_mut.job_report.status = JobStatus::Running; + let ctx = ctx.clone(); + worker_mut.job_report.create(&ctx).await.unwrap_or(()); // spawn task to handle receiving events from the worker @@ -94,7 +103,7 @@ impl Worker { tokio::spawn(async move { let worker_ctx = WorkerContext { uuid, - core_ctx, + library_ctx: ctx.clone(), sender: worker_sender, }; let job_start = Instant::now(); @@ -113,20 +122,17 @@ impl Worker { } }); - let result = job.run(worker_ctx.clone()).await; - - if let Err(e) = result { - println!("job failed {:?}", e); - worker_ctx.sender.send(WorkerEvent::Failed).unwrap_or(()); - } else { - // handle completion - worker_ctx.sender.send(WorkerEvent::Completed).unwrap_or(()); + match job.run(worker_ctx.clone()).await { + Ok(_) => { + worker_ctx.sender.send(WorkerEvent::Completed).unwrap_or(()); + } + Err(err) => { + println!("job '{}' failed with error: {}", worker_ctx.uuid, err); + worker_ctx.sender.send(WorkerEvent::Failed).unwrap_or(()); + } } - worker_ctx - .core_ctx - .internal_sender - .send(InternalEvent::JobComplete(worker_ctx.uuid.clone())) - .unwrap_or(()); + + job_manager.complete(&ctx, worker_ctx.uuid).await; }); } @@ -137,7 +143,7 @@ impl Worker { async fn track_progress( worker: Arc>, mut channel: UnboundedReceiver, - ctx: CoreContext, + ctx: LibraryContext, ) { while let Some(command) = channel.recv().await { let mut worker = worker.lock().await; @@ -176,16 +182,23 @@ impl Worker { ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetRunning)) .await; - ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetHistory)) - .await; + + ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { + library_id: ctx.id.to_string(), + query: LibraryQuery::JobGetHistory, + })) + .await; break; } WorkerEvent::Failed => { worker.job_report.status = JobStatus::Failed; worker.job_report.update(&ctx).await.unwrap_or(()); - ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::JobGetHistory)) - .await; + ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { + library_id: ctx.id.to_string(), + query: LibraryQuery::JobGetHistory, + })) + .await; break; } } diff --git a/core/src/lib.rs b/core/src/lib.rs index bb096a666..21153848b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,11 +1,13 @@ -use crate::{ - file::cas::FileIdentifierJob, library::get_library_path, node::NodeState, - prisma::file as prisma_file, prisma::location, util::db::create_connection, -}; -use job::{Job, JobReport, Jobs}; -use prisma::PrismaClient; +use crate::{file::cas::FileIdentifierJob, prisma::file as prisma_file, prisma::location}; +use job::{JobManager, JobReport}; +use library::{LibraryConfig, LibraryConfigWrapped, LibraryManager}; +use node::{NodeConfig, NodeConfigManager}; use serde::{Deserialize, Serialize}; -use std::{fs, sync::Arc}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; use thiserror::Error; use tokio::sync::{ mpsc::{self, unbounded_channel, UnboundedReceiver, UnboundedSender}, @@ -32,12 +34,12 @@ pub struct ReturnableMessage> { } // core controller is passed to the client to communicate with the core which runs in a dedicated thread -pub struct CoreController { +pub struct NodeController { query_sender: UnboundedSender>, command_sender: UnboundedSender>, } -impl CoreController { +impl NodeController { pub async fn query(&self, query: ClientQuery) -> Result { // a one time use channel to send and await a response let (sender, recv) = oneshot::channel(); @@ -64,35 +66,14 @@ impl CoreController { } } -#[derive(Debug)] -pub enum InternalEvent { - JobIngest(Box), - JobQueue(Box), - JobComplete(String), -} - #[derive(Clone)] -pub struct CoreContext { - pub database: Arc, +pub struct NodeContext { pub event_sender: mpsc::Sender, - pub internal_sender: UnboundedSender, + pub config: Arc, + pub jobs: Arc, } -impl CoreContext { - pub fn spawn_job(&self, job: Box) { - self.internal_sender - .send(InternalEvent::JobIngest(job)) - .unwrap_or_else(|e| { - println!("Failed to spawn job. {:?}", e); - }); - } - pub fn queue_job(&self, job: Box) { - self.internal_sender - .send(InternalEvent::JobQueue(job)) - .unwrap_or_else(|e| { - println!("Failed to queue job. {:?}", e); - }); - } +impl NodeContext { pub async fn emit(&self, event: CoreEvent) { self.event_sender.send(event).await.unwrap_or_else(|e| { println!("Failed to emit event. {:?}", e); @@ -101,11 +82,9 @@ impl CoreContext { } pub struct Node { - state: NodeState, - jobs: job::Jobs, - database: Arc, - // filetype_registry: library::TypeRegistry, - // extension_registry: library::ExtensionRegistry, + config: Arc, + library_manager: Arc, + jobs: Arc, // global messaging channels query_channel: ( @@ -117,73 +96,52 @@ pub struct Node { UnboundedReceiver>, ), event_sender: mpsc::Sender, - - // a channel for child threads to send events back to the core - internal_channel: ( - UnboundedSender, - UnboundedReceiver, - ), } impl Node { // create new instance of node, run startup tasks - pub async fn new(mut data_dir: std::path::PathBuf) -> (Node, mpsc::Receiver) { - let (event_sender, event_recv) = mpsc::channel(100); - - data_dir = data_dir.join("spacedrive"); - let data_dir = data_dir.to_str().unwrap(); - // create data directory if it doesn't exist + pub async fn new(data_dir: PathBuf) -> (NodeController, mpsc::Receiver, Node) { fs::create_dir_all(&data_dir).unwrap(); - // prepare basic client state - let mut state = NodeState::new(data_dir, "diamond-mastering-space-dragon").unwrap(); - // load from disk - state - .read_disk() - .unwrap_or(println!("Error: No node state found, creating new one...")); - state.save(); - - println!("Node State: {:?}", state); - - // connect to default library - let database = Arc::new( - create_connection(&get_library_path(&data_dir)) - .await - .unwrap(), - ); - - let internal_channel = unbounded_channel::(); - - let node = Node { - state, - query_channel: unbounded_channel(), - command_channel: unbounded_channel(), - jobs: Jobs::new(), - event_sender, - database, - internal_channel, + let (event_sender, event_recv) = mpsc::channel(100); + let config = NodeConfigManager::new(data_dir.clone()).await.unwrap(); + let jobs = JobManager::new(); + let node_ctx = NodeContext { + event_sender: event_sender.clone(), + config: config.clone(), + jobs: jobs.clone(), }; - (node, event_recv) + let node = Node { + config, + library_manager: LibraryManager::new(Path::new(&data_dir).join("libraries"), node_ctx) + .await + .unwrap(), + query_channel: unbounded_channel(), + command_channel: unbounded_channel(), + jobs, + event_sender, + }; + + ( + NodeController { + query_sender: node.query_channel.0.clone(), + command_sender: node.command_channel.0.clone(), + }, + event_recv, + node, + ) } - pub fn get_context(&self) -> CoreContext { - CoreContext { - database: self.database.clone(), + pub fn get_context(&self) -> NodeContext { + NodeContext { event_sender: self.event_sender.clone(), - internal_sender: self.internal_channel.0.clone(), + config: self.config.clone(), + jobs: self.jobs.clone(), } } - pub fn get_controller(&self) -> CoreController { - CoreController { - query_sender: self.query_channel.0.clone(), - command_sender: self.command_channel.0.clone(), - } - } - - pub async fn start(&mut self) { - let ctx = self.get_context(); + pub async fn start(mut self) { loop { // listen on global messaging channels for incoming messages tokio::select! { @@ -195,174 +153,200 @@ impl Node { let res = self.exec_command(msg.data).await; msg.return_sender.send(res).unwrap_or(()); } - Some(event) = self.internal_channel.1.recv() => { - match event { - InternalEvent::JobIngest(job) => { - self.jobs.ingest(&ctx, job).await; - }, - InternalEvent::JobQueue(job) => { - self.jobs.ingest_queue(&ctx, job); - }, - InternalEvent::JobComplete(id) => { - self.jobs.complete(&ctx, id).await; - }, - } - } } } } - // load library database + initialize client with db - pub async fn initializer(&self) { - println!("Initializing..."); - let ctx = self.get_context(); - - if self.state.libraries.len() == 0 { - match library::create(&ctx, None).await { - Ok(library) => println!("Created new library: {:?}", library), - Err(e) => println!("Error creating library: {:?}", e), - } - } else { - for library in self.state.libraries.iter() { - // init database for library - match library::load(&ctx, &library.library_path, &library.library_uuid).await { - Ok(library) => println!("Loaded library: {:?}", library), - Err(e) => println!("Error loading library: {:?}", e), - } - } - } - // init node data within library - match node::LibraryNode::create(&self).await { - Ok(_) => println!("Spacedrive online"), - Err(e) => println!("Error initializing node: {:?}", e), - }; - } async fn exec_command(&mut self, cmd: ClientCommand) -> Result { - println!("Core command: {:?}", cmd); - let ctx = self.get_context(); Ok(match cmd { - // CRUD for locations - ClientCommand::LocCreate { path } => { - let loc = sys::new_location_and_scan(&ctx, &path).await?; - // ctx.queue_job(Box::new(FileIdentifierJob)); - CoreResponse::LocCreate(loc) + ClientCommand::CreateLibrary { name } => { + self.library_manager + .create(LibraryConfig { + name: name.to_string(), + ..Default::default() + }) + .await + .unwrap(); + CoreResponse::Success(()) } - ClientCommand::LocUpdate { id, name } => { - ctx.database - .location() - .find_unique(location::id::equals(id)) - .update(vec![location::name::set(name)]) - .exec() - .await?; + ClientCommand::EditLibrary { + id, + name, + description, + } => { + self.library_manager + .edit_library(id, name, description) + .await + .unwrap(); + CoreResponse::Success(()) + } + ClientCommand::DeleteLibrary { id } => { + self.library_manager.delete_library(id).await.unwrap(); + CoreResponse::Success(()) + } + ClientCommand::LibraryCommand { + library_id, + command, + } => { + let ctx = self.library_manager.get_ctx(library_id).await.unwrap(); + match command { + // CRUD for locations + LibraryCommand::LocCreate { path } => { + let loc = sys::new_location_and_scan(&ctx, &path).await?; + // ctx.queue_job(Box::new(FileIdentifierJob)); + CoreResponse::LocCreate(loc) + } + LibraryCommand::LocUpdate { id, name } => { + ctx.db + .location() + .find_unique(location::id::equals(id)) + .update(vec![location::name::set(name)]) + .exec() + .await?; - CoreResponse::Success(()) - } - ClientCommand::LocDelete { id } => { - sys::delete_location(&ctx, id).await?; - CoreResponse::Success(()) - } - ClientCommand::LocRescan { id } => { - sys::scan_location(&ctx, id, String::new()); - CoreResponse::Success(()) - } - // CRUD for files - ClientCommand::FileReadMetaData { id: _ } => todo!(), - ClientCommand::FileSetNote { id, note } => file::set_note(ctx, id, note).await?, - // ClientCommand::FileEncrypt { id: _, algorithm: _ } => todo!(), - ClientCommand::FileDelete { id } => { - ctx.database - .file() - .find_unique(prisma_file::id::equals(id)) - .delete() - .exec() - .await?; + CoreResponse::Success(()) + } + LibraryCommand::LocDelete { id } => { + sys::delete_location(&ctx, id).await?; + CoreResponse::Success(()) + } + LibraryCommand::LocRescan { id } => { + sys::scan_location(&ctx, id, String::new()).await; + CoreResponse::Success(()) + } + // CRUD for files + LibraryCommand::FileReadMetaData { id: _ } => todo!(), + LibraryCommand::FileSetNote { id, note } => { + file::set_note(ctx, id, note).await? + } + // ClientCommand::FileEncrypt { id: _, algorithm: _ } => todo!(), + LibraryCommand::FileDelete { id } => { + ctx.db + .file() + .find_unique(prisma_file::id::equals(id)) + .delete() + .exec() + .await?; - CoreResponse::Success(()) - } - // CRUD for tags - ClientCommand::TagCreate { name: _, color: _ } => todo!(), - ClientCommand::TagAssign { - file_id: _, - tag_id: _, - } => todo!(), - ClientCommand::TagDelete { id: _ } => todo!(), - // CRUD for libraries - ClientCommand::SysVolumeUnmount { id: _ } => todo!(), - ClientCommand::LibDelete { id: _ } => todo!(), - ClientCommand::TagUpdate { name: _, color: _ } => todo!(), - ClientCommand::GenerateThumbsForLocation { id, path } => { - ctx.spawn_job(Box::new(ThumbnailJob { - location_id: id, - path, - background: false, // fix - })); - CoreResponse::Success(()) - } - // ClientCommand::PurgeDatabase => { - // println!("Purging database..."); - // fs::remove_file(Path::new(&self.state.data_path).join("library.db")).unwrap(); - // CoreResponse::Success(()) - // } - ClientCommand::IdentifyUniqueFiles { id, path } => { - ctx.spawn_job(Box::new(FileIdentifierJob { - location_id: id, - path, - })); - CoreResponse::Success(()) + CoreResponse::Success(()) + } + // CRUD for tags + LibraryCommand::TagCreate { name: _, color: _ } => todo!(), + LibraryCommand::TagAssign { + file_id: _, + tag_id: _, + } => todo!(), + LibraryCommand::TagUpdate { name: _, color: _ } => todo!(), + LibraryCommand::TagDelete { id: _ } => todo!(), + // CRUD for libraries + LibraryCommand::SysVolumeUnmount { id: _ } => todo!(), + LibraryCommand::GenerateThumbsForLocation { id, path } => { + ctx.spawn_job(Box::new(ThumbnailJob { + location_id: id, + path, + background: false, // fix + })) + .await; + CoreResponse::Success(()) + } + LibraryCommand::IdentifyUniqueFiles { id, path } => { + ctx.spawn_job(Box::new(FileIdentifierJob { + location_id: id, + path, + })) + .await; + CoreResponse::Success(()) + } + } } }) } // query sources of data async fn exec_query(&self, query: ClientQuery) -> Result { - let ctx = self.get_context(); Ok(match query { - // return the client state from memory - ClientQuery::NodeGetState => CoreResponse::NodeGetState(self.state.clone()), - // get system volumes without saving to library - ClientQuery::SysGetVolumes => CoreResponse::SysGetVolumes(sys::Volume::get_volumes()?), - ClientQuery::SysGetLocations => { - CoreResponse::SysGetLocations(sys::get_locations(&ctx).await?) - } - // get location from library - ClientQuery::SysGetLocation { id } => { - CoreResponse::SysGetLocation(sys::get_location(&ctx, id).await?) - } - // return contents of a directory for the explorer - ClientQuery::LibGetExplorerDir { - path, - location_id, - limit: _, - } => CoreResponse::LibGetExplorerDir( - file::explorer::open_dir(&ctx, &location_id, &path).await?, + ClientQuery::NodeGetLibraries => CoreResponse::NodeGetLibraries( + self.library_manager.get_all_libraries_config().await, ), - ClientQuery::LibGetTags => todo!(), + ClientQuery::NodeGetState => CoreResponse::NodeGetState(NodeState { + config: self.config.get().await, + data_path: self.config.data_directory().to_str().unwrap().to_string(), + }), + ClientQuery::SysGetVolumes => CoreResponse::SysGetVolumes(sys::Volume::get_volumes()?), ClientQuery::JobGetRunning => { CoreResponse::JobGetRunning(self.jobs.get_running().await) } - ClientQuery::JobGetHistory => { - CoreResponse::JobGetHistory(Jobs::get_history(&ctx).await?) - } - ClientQuery::GetLibraryStatistics => { - CoreResponse::GetLibraryStatistics(library::Statistics::calculate(&ctx).await?) - } ClientQuery::GetNodes => todo!(), + ClientQuery::LibraryQuery { library_id, query } => { + let ctx = match self.library_manager.get_ctx(library_id.clone()).await { + Some(ctx) => ctx, + None => { + println!("Library '{}' not found!", library_id); + return Ok(CoreResponse::Error("Library not found".into())); + } + }; + match query { + LibraryQuery::SysGetLocations => { + CoreResponse::SysGetLocations(sys::get_locations(&ctx).await?) + } + // get location from library + LibraryQuery::SysGetLocation { id } => { + CoreResponse::SysGetLocation(sys::get_location(&ctx, id).await?) + } + // return contents of a directory for the explorer + LibraryQuery::LibGetExplorerDir { + path, + location_id, + limit: _, + } => CoreResponse::LibGetExplorerDir( + file::explorer::open_dir(&ctx, &location_id, &path).await?, + ), + LibraryQuery::LibGetTags => todo!(), + LibraryQuery::JobGetHistory => { + CoreResponse::JobGetHistory(JobManager::get_history(&ctx).await?) + } + LibraryQuery::GetLibraryStatistics => CoreResponse::GetLibraryStatistics( + library::Statistics::calculate(&ctx).await?, + ), + } + } }) } } -// represents an event this library can emit +/// is a command destined for the core #[derive(Serialize, Deserialize, Debug, TS)] #[serde(tag = "key", content = "params")] #[ts(export)] pub enum ClientCommand { + // Libraries + CreateLibrary { + name: String, + }, + EditLibrary { + id: String, + name: Option, + description: Option, + }, + DeleteLibrary { + id: String, + }, + LibraryCommand { + library_id: String, + command: LibraryCommand, + }, +} + +/// is a command destined for a specific library which is loaded into the core. +#[derive(Serialize, Deserialize, Debug, TS)] +#[serde(tag = "key", content = "params")] +#[ts(export)] +pub enum LibraryCommand { // Files FileReadMetaData { id: i32 }, FileSetNote { id: i32, note: Option }, // FileEncrypt { id: i32, algorithm: EncryptionAlgorithm }, FileDelete { id: i32 }, - // Library - LibDelete { id: i32 }, // Tags TagCreate { name: String, color: String }, TagUpdate { name: String, color: String }, @@ -380,15 +364,28 @@ pub enum ClientCommand { IdentifyUniqueFiles { id: i32, path: String }, } -// represents an event this library can emit +/// is a query destined for the core #[derive(Serialize, Deserialize, Debug, TS)] #[serde(tag = "key", content = "params")] #[ts(export)] pub enum ClientQuery { + NodeGetLibraries, NodeGetState, SysGetVolumes, - LibGetTags, JobGetRunning, + GetNodes, + LibraryQuery { + library_id: String, + query: LibraryQuery, + }, +} + +/// is a query destined for a specific library which is loaded into the core. +#[derive(Serialize, Deserialize, Debug, TS)] +#[serde(tag = "key", content = "params")] +#[ts(export)] +pub enum LibraryQuery { + LibGetTags, JobGetHistory, SysGetLocations, SysGetLocation { @@ -400,7 +397,6 @@ pub enum ClientQuery { limit: i32, }, GetLibraryStatistics, - GetNodes, } // represents an event this library can emit @@ -417,11 +413,21 @@ pub enum CoreEvent { DatabaseDisconnected { reason: Option }, } +#[derive(Serialize, Deserialize, Debug, TS)] +#[ts(export)] +pub struct NodeState { + #[serde(flatten)] + pub config: NodeConfig, + pub data_path: String, +} + #[derive(Serialize, Deserialize, Debug, TS)] #[serde(tag = "key", content = "data")] #[ts(export)] pub enum CoreResponse { Success(()), + Error(String), + NodeGetLibraries(Vec), SysGetVolumes(Vec), SysGetLocation(sys::LocationResource), SysGetLocations(Vec), diff --git a/core/src/library/library_config.rs b/core/src/library/library_config.rs new file mode 100644 index 000000000..5d9baf65a --- /dev/null +++ b/core/src/library/library_config.rs @@ -0,0 +1,72 @@ +use std::{ + fs::File, + io::{BufReader, Seek, SeekFrom}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use ts_rs::TS; + +use crate::node::ConfigMetadata; + +use super::LibraryManagerError; + +/// LibraryConfig holds the configuration for a specific library. This is stored as a '{uuid}.sdlibrary' file. +#[derive(Debug, Serialize, Deserialize, Clone, TS, Default)] +#[ts(export)] +pub struct LibraryConfig { + #[serde(flatten)] + pub metadata: ConfigMetadata, + /// name is the display name of the library. This is used in the UI and is set by the user. + pub name: String, + /// description is a user set description of the library. This is used in the UI and is set by the user. + pub description: String, +} + +impl LibraryConfig { + /// read will read the configuration from disk and return it. + pub(super) async fn read(file_dir: PathBuf) -> Result { + let mut file = File::open(&file_dir)?; + let base_config: ConfigMetadata = serde_json::from_reader(BufReader::new(&mut file))?; + + Self::migrate_config(base_config.version, file_dir)?; + + file.seek(SeekFrom::Start(0))?; + Ok(serde_json::from_reader(BufReader::new(&mut file))?) + } + + /// save will write the configuration back to disk + pub(super) async fn save( + file_dir: PathBuf, + config: &LibraryConfig, + ) -> Result<(), LibraryManagerError> { + File::create(file_dir) + .map_err(LibraryManagerError::IOError)? + .write_all(serde_json::to_string(config)?.as_bytes()) + .map_err(LibraryManagerError::IOError)?; + Ok(()) + } + + /// migrate_config is a function used to apply breaking changes to the library config file. + fn migrate_config( + current_version: Option, + config_path: PathBuf, + ) -> Result<(), LibraryManagerError> { + match current_version { + None => Err(LibraryManagerError::MigrationError(format!( + "Your Spacedrive library at '{}' is missing the `version` field", + config_path.display() + ))), + _ => Ok(()), + } + } +} + +// used to return to the frontend with uuid context +#[derive(Serialize, Deserialize, Debug, TS)] +#[ts(export)] +pub struct LibraryConfigWrapped { + pub uuid: String, + pub config: LibraryConfig, +} diff --git a/core/src/library/library_ctx.rs b/core/src/library/library_ctx.rs new file mode 100644 index 000000000..50bc5ea94 --- /dev/null +++ b/core/src/library/library_ctx.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::{job::Job, node::NodeConfigManager, prisma::PrismaClient, CoreEvent, NodeContext}; + +use super::LibraryConfig; + +/// LibraryContext holds context for a library which can be passed around the application. +#[derive(Clone)] +pub struct LibraryContext { + /// id holds the ID of the current library. + pub id: Uuid, + /// config holds the configuration of the current library. + pub config: LibraryConfig, + /// db holds the database client for the current library. + pub db: Arc, + /// node_local_id holds the local ID of the node which is running the library. + pub node_local_id: i32, + /// node_context holds the node context for the node which this library is running on. + pub(super) node_context: NodeContext, +} + +impl LibraryContext { + pub(crate) async fn spawn_job(&self, job: Box) { + self.node_context.jobs.clone().ingest(self, job).await; + } + + pub(crate) async fn queue_job(&self, job: Box) { + self.node_context.jobs.ingest_queue(self, job).await; + } + + pub(crate) async fn emit(&self, event: CoreEvent) { + self.node_context + .event_sender + .send(event) + .await + .unwrap_or_else(|e| { + println!("Failed to emit event. {:?}", e); + }); + } + + pub(crate) fn config(&self) -> Arc { + self.node_context.config.clone() + } +} diff --git a/core/src/library/library_manager.rs b/core/src/library/library_manager.rs new file mode 100644 index 000000000..a4da40acd --- /dev/null +++ b/core/src/library/library_manager.rs @@ -0,0 +1,271 @@ +use std::{ + env, fs, io, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + +use thiserror::Error; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::{ + node::Platform, + prisma::{self, node}, + util::db::load_and_migrate, + ClientQuery, CoreEvent, NodeContext, +}; + +use super::{LibraryConfig, LibraryConfigWrapped, LibraryContext}; + +/// LibraryManager is a singleton that manages all libraries for a node. +pub struct LibraryManager { + /// libraries_dir holds the path to the directory where libraries are stored. + libraries_dir: PathBuf, + /// libraries holds the list of libraries which are currently loaded into the node. + libraries: RwLock>, + /// node_context holds the context for the node which this library manager is running on. + node_context: NodeContext, +} + +#[derive(Error, Debug)] +pub enum LibraryManagerError { + #[error("error saving or loading the config from the filesystem")] + IOError(#[from] io::Error), + #[error("error serializing or deserializing the JSON in the config file")] + JsonError(#[from] serde_json::Error), + #[error("Database error")] + DatabaseError(#[from] prisma::QueryError), + #[error("Library not found error")] + LibraryNotFoundError, + #[error("error migrating the config file")] + MigrationError(String), + #[error("failed to parse uuid")] + UuidError(#[from] uuid::Error), +} + +impl LibraryManager { + pub(crate) async fn new( + libraries_dir: PathBuf, + node_context: NodeContext, + ) -> Result, LibraryManagerError> { + fs::create_dir_all(&libraries_dir)?; + + let mut libraries = Vec::new(); + for entry in fs::read_dir(&libraries_dir)? + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.path().is_file() + && entry + .path() + .extension() + .map(|v| &*v == "sdlibrary") + .unwrap_or(false) + }) { + let config_path = entry.path(); + let library_id = match Path::new(&config_path) + .file_stem() + .map(|v| v.to_str().map(|v| Uuid::from_str(v))) + { + Some(Some(Ok(id))) => id, + _ => { + println!("Attempted to load library from path '{}' but it has an invalid filename. Skipping...", config_path.display()); + continue; + } + }; + + let db_path = config_path.clone().with_extension("db"); + if !db_path.exists() { + println!( + "Found library '{}' but no matching database file was found. Skipping...", + config_path.display() + ); + continue; + } + + let config = LibraryConfig::read(config_path).await?; + libraries.push( + Self::load( + library_id, + db_path.to_str().unwrap(), + config, + node_context.clone(), + ) + .await?, + ); + } + + let this = Arc::new(Self { + libraries: RwLock::new(libraries), + libraries_dir, + node_context, + }); + + // TODO: Remove this before merging PR -> Currently it exists to make the app usable + if this.libraries.read().await.len() == 0 { + this.create(LibraryConfig { + name: "My Default Library".into(), + ..Default::default() + }) + .await + .unwrap(); + } + + Ok(this) + } + + /// create creates a new library with the given config and mounts it into the running [LibraryManager]. + pub(crate) async fn create(&self, config: LibraryConfig) -> Result<(), LibraryManagerError> { + let id = Uuid::new_v4(); + LibraryConfig::save( + Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", id.to_string())), + &config, + ) + .await?; + + let library = Self::load( + id, + &Path::new(&self.libraries_dir) + .join(format!("{}.db", id.to_string())) + .to_str() + .unwrap(), + config, + self.node_context.clone(), + ) + .await?; + + self.libraries.write().await.push(library); + + self.node_context + .emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries)) + .await; + + Ok(()) + } + + pub(crate) async fn get_all_libraries_config(&self) -> Vec { + self.libraries + .read() + .await + .iter() + .map(|lib| LibraryConfigWrapped { + config: lib.config.clone(), + uuid: lib.id.to_string(), + }) + .collect() + } + + pub(crate) async fn edit_library( + &self, + id: String, + name: Option, + description: Option, + ) -> Result<(), LibraryManagerError> { + // check library is valid + let mut libraries = self.libraries.write().await; + let library = libraries + .iter_mut() + .find(|lib| lib.id == Uuid::from_str(&id).unwrap()) + .ok_or(LibraryManagerError::LibraryNotFoundError)?; + + // update the library + if let Some(name) = name { + library.config.name = name; + } + if let Some(description) = description { + library.config.description = description; + } + + LibraryConfig::save( + Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", id.to_string())), + &library.config, + ) + .await?; + + self.node_context + .emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries)) + .await; + Ok(()) + } + + pub async fn delete_library(&self, id: String) -> Result<(), LibraryManagerError> { + let mut libraries = self.libraries.write().await; + + let id = Uuid::parse_str(&id)?; + + let library = libraries + .iter() + .find(|l| l.id == id) + .ok_or(LibraryManagerError::LibraryNotFoundError)?; + + fs::remove_file( + Path::new(&self.libraries_dir).join(format!("{}.db", library.id.to_string())), + )?; + fs::remove_file( + Path::new(&self.libraries_dir).join(format!("{}.sdlibrary", library.id.to_string())), + )?; + + libraries.retain(|l| l.id != id); + + self.node_context + .emit(CoreEvent::InvalidateQuery(ClientQuery::NodeGetLibraries)) + .await; + Ok(()) + } + + // get_ctx will return the library context for the given library id. + pub(crate) async fn get_ctx(&self, library_id: String) -> Option { + self.libraries + .read() + .await + .iter() + .find(|lib| lib.id.to_string() == library_id) + .map(|v| v.clone()) + } + + /// load the library from a given path + pub(crate) async fn load( + id: Uuid, + db_path: &str, + config: LibraryConfig, + node_context: NodeContext, + ) -> Result { + let db = Arc::new( + load_and_migrate(&format!("file:{}", db_path)) + .await + .unwrap(), + ); + + let node_config = node_context.config.get().await; + + let platform = match env::consts::OS { + "windows" => Platform::Windows, + "macos" => Platform::MacOS, + "linux" => Platform::Linux, + _ => Platform::Unknown, + }; + + let node_data = db + .node() + .upsert( + node::pub_id::equals(id.to_string()), + ( + node::pub_id::set(id.to_string()), + node::name::set(node_config.name.clone()), + vec![node::platform::set(platform as i32)], + ), + vec![node::name::set(node_config.name.clone())], + ) + .exec() + .await?; + + Ok(LibraryContext { + id, + config, + db, + node_local_id: node_data.id, + node_context, + }) + } +} diff --git a/core/src/library/loader.rs b/core/src/library/loader.rs deleted file mode 100644 index c2fa47beb..000000000 --- a/core/src/library/loader.rs +++ /dev/null @@ -1,99 +0,0 @@ -use uuid::Uuid; - -use crate::node::{get_nodestate, LibraryState}; -use crate::prisma::library; -use crate::util::db::{run_migrations, DatabaseError}; -use crate::CoreContext; - -pub static LIBRARY_DB_NAME: &str = "library.db"; -pub static DEFAULT_NAME: &str = "My Library"; - -pub fn get_library_path(data_path: &str) -> String { - let path = data_path.to_owned(); - format!("{}/{}", path, LIBRARY_DB_NAME) -} - -// pub async fn get(core: &Node) -> Result { -// let config = get_nodestate(); -// let db = &core.database; - -// let library_state = config.get_current_library(); - -// println!("{:?}", library_state); - -// // get library from db -// let library = match db -// .library() -// .find_unique(library::pub_id::equals(library_state.library_uuid.clone())) -// .exec() -// .await? -// { -// Some(library) => Ok(library), -// None => { -// // update config library state to offline -// // config.libraries - -// Err(anyhow::anyhow!("library_not_found")) -// } -// }; - -// Ok(library.unwrap()) -// } - -pub async fn load( - ctx: &CoreContext, - library_path: &str, - library_id: &str, -) -> Result<(), DatabaseError> { - let mut config = get_nodestate(); - - println!("Initializing library: {} {}", &library_id, library_path); - - if config.current_library_uuid != library_id { - config.current_library_uuid = library_id.to_string(); - config.save(); - } - // create connection with library database & run migrations - run_migrations(&ctx).await?; - // if doesn't exist, mark as offline - Ok(()) -} - -pub async fn create(ctx: &CoreContext, name: Option) -> Result<(), ()> { - let mut config = get_nodestate(); - - let uuid = Uuid::new_v4().to_string(); - - println!("Creating library {:?}, UUID: {:?}", name, uuid); - - let library_state = LibraryState { - library_uuid: uuid.clone(), - library_path: get_library_path(&config.data_path), - ..LibraryState::default() - }; - - run_migrations(&ctx).await.unwrap(); - - config.libraries.push(library_state); - - config.current_library_uuid = uuid; - - config.save(); - - let db = &ctx.database; - - let library = db - .library() - .create( - library::pub_id::set(config.current_library_uuid), - library::name::set(name.unwrap_or(DEFAULT_NAME.into())), - vec![], - ) - .exec() - .await - .unwrap(); - - println!("library created in database: {:?}", library); - - Ok(()) -} diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index 17dc6db10..23aed8efa 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -1,7 +1,11 @@ -mod loader; +mod library_config; +mod library_ctx; +mod library_manager; mod statistics; -pub use loader::*; +pub use library_config::*; +pub use library_ctx::*; +pub use library_manager::*; pub use statistics::*; use thiserror::Error; diff --git a/core/src/library/statistics.rs b/core/src/library/statistics.rs index f866999b5..f1df98dd0 100644 --- a/core/src/library/statistics.rs +++ b/core/src/library/statistics.rs @@ -1,15 +1,10 @@ -use crate::{ - node::get_nodestate, - prisma::{library, library_statistics::*}, - sys::Volume, - CoreContext, -}; +use crate::{prisma::statistics::*, sys::Volume}; use fs_extra::dir::get_size; use serde::{Deserialize, Serialize}; use std::fs; use ts_rs::TS; -use super::LibraryError; +use super::{LibraryContext, LibraryError}; #[derive(Debug, Serialize, Deserialize, TS, Clone)] #[ts(export)] @@ -52,14 +47,11 @@ impl Default for Statistics { } impl Statistics { - pub async fn retrieve(ctx: &CoreContext) -> Result { - let config = get_nodestate(); - let db = &ctx.database; - let library_data = config.get_current_library(); - - let library_statistics_db = match db - .library_statistics() - .find_unique(id::equals(library_data.library_id)) + pub async fn retrieve(ctx: &LibraryContext) -> Result { + let library_statistics_db = match ctx + .db + .statistics() + .find_unique(id::equals(ctx.node_local_id)) .exec() .await? { @@ -70,31 +62,11 @@ impl Statistics { Ok(library_statistics_db.into()) } - pub async fn calculate(ctx: &CoreContext) -> Result { - let config = get_nodestate(); - let db = &ctx.database; - // get library from client state - let library_data = config.get_current_library(); - println!( - "Calculating library statistics {:?}", - library_data.library_uuid - ); - // get library from db - let library = db - .library() - .find_unique(library::pub_id::equals( - library_data.library_uuid.to_string(), - )) - .exec() - .await?; - - if library.is_none() { - return Err(LibraryError::LibraryNotFound); - } - - let library_statistics = db - .library_statistics() - .find_unique(id::equals(library_data.library_id)) + pub async fn calculate(ctx: &LibraryContext) -> Result { + let _statistics = ctx + .db + .statistics() + .find_unique(id::equals(ctx.node_local_id)) .exec() .await?; @@ -113,14 +85,15 @@ impl Statistics { } } - let library_db_size = match fs::metadata(library_data.library_path.as_str()) { + let library_db_size = match fs::metadata(ctx.config().data_directory()) { Ok(metadata) => metadata.len(), Err(_) => 0, }; - println!("{:?}", library_statistics); + let mut thumbsnails_dir = ctx.config().data_directory(); + thumbsnails_dir.push("thumbnails"); - let thumbnail_folder_size = get_size(&format!("{}/{}", config.data_path, "thumbnails")); + let thumbnail_folder_size = get_size(&thumbsnails_dir); let statistics = Statistics { library_db_size: library_db_size.to_string(), @@ -130,18 +103,11 @@ impl Statistics { ..Statistics::default() }; - let library_local_id = match library { - Some(library) => library.id, - None => library_data.library_id, - }; - - db.library_statistics() + ctx.db + .statistics() .upsert( - library_id::equals(library_local_id), - ( - library_id::set(library_local_id), - vec![library_db_size::set(statistics.library_db_size.clone())], - ), + id::equals(1), + vec![library_db_size::set(statistics.library_db_size.clone())], vec![ total_file_count::set(statistics.total_file_count.clone()), total_bytes_used::set(statistics.total_bytes_used.clone()), diff --git a/core/src/node/config.rs b/core/src/node/config.rs new file mode 100644 index 000000000..c45833a1c --- /dev/null +++ b/core/src/node/config.rs @@ -0,0 +1,149 @@ +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{self, BufReader, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::{RwLock, RwLockWriteGuard}; +use ts_rs::TS; +use uuid::Uuid; + +/// NODE_STATE_CONFIG_NAME is the name of the file which stores the NodeState +pub const NODE_STATE_CONFIG_NAME: &str = "node_state.sdconfig"; + +/// ConfigMetadata is a part of node configuration that is loaded before the main configuration and contains information about the schema of the config. +/// This allows us to migrate breaking changes to the config format between Spacedrive releases. +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct ConfigMetadata { + /// version of Spacedrive. Determined from `CARGO_PKG_VERSION` environment variable. + pub version: Option, +} + +impl Default for ConfigMetadata { + fn default() -> Self { + Self { + version: Some(env!("CARGO_PKG_VERSION").into()), + } + } +} + +/// NodeConfig is the configuration for a node. This is shared between all libraries and is stored in a JSON file on disk. +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct NodeConfig { + #[serde(flatten)] + pub metadata: ConfigMetadata, + /// id is a unique identifier for the current node. Each node has a public identifier (this one) and is given a local id for each library (done within the library code). + pub id: Uuid, + /// 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, + // the port this node uses for peer to peer communication. By default a random free port will be chosen each time the application is started. + pub p2p_port: Option, +} + +#[derive(Error, Debug)] +pub enum NodeConfigError { + #[error("error saving or loading the config from the filesystem")] + IOError(#[from] io::Error), + #[error("error serializing or deserializing the JSON in the config file")] + JsonError(#[from] serde_json::Error), + #[error("error migrating the config file")] + MigrationError(String), +} + +impl NodeConfig { + fn default() -> Self { + NodeConfig { + id: Uuid::new_v4(), + name: match hostname::get() { + Ok(hostname) => hostname.to_string_lossy().into_owned(), + Err(err) => { + eprintln!("Falling back to default node name as an error occurred getting your systems hostname: '{}'", err); + "my-spacedrive".into() + } + }, + p2p_port: None, + metadata: ConfigMetadata { + version: Some(env!("CARGO_PKG_VERSION").into()), + }, + } + } +} + +pub struct NodeConfigManager(RwLock, PathBuf); + +impl NodeConfigManager { + /// new will create a new NodeConfigManager with the given path to the config file. + pub(crate) async fn new(data_path: PathBuf) -> Result, NodeConfigError> { + Ok(Arc::new(Self( + RwLock::new(Self::read(&data_path).await?), + data_path, + ))) + } + + /// get will return the current NodeConfig in a read only state. + pub(crate) async fn get(&self) -> NodeConfig { + self.0.read().await.clone() + } + + /// data_directory returns the path to the directory storing the configuration data. + pub(crate) fn data_directory(&self) -> PathBuf { + self.1.clone() + } + + /// write allows the user to update the configuration. This is done in a closure while a Mutex lock is held so that the user can't cause a race condition if the config were to be updated in multiple parts of the app at the same time. + #[allow(unused)] + pub(crate) async fn write)>( + &self, + mutation_fn: F, + ) -> Result { + mutation_fn(self.0.write().await); + let config = self.0.read().await; + Self::save(&self.1, &config).await?; + Ok(config.clone()) + } + + /// read will read the configuration from disk and return it. + async fn read(base_path: &PathBuf) -> Result { + let path = Path::new(base_path).join(NODE_STATE_CONFIG_NAME); + + match path.exists() { + true => { + let mut file = File::open(&path)?; + let base_config: ConfigMetadata = + serde_json::from_reader(BufReader::new(&mut file))?; + + Self::migrate_config(base_config.version, path)?; + + file.seek(SeekFrom::Start(0))?; + Ok(serde_json::from_reader(BufReader::new(&mut file))?) + } + false => { + let config = NodeConfig::default(); + Self::save(base_path, &config).await?; + Ok(config) + } + } + } + + /// save will write the configuration back to disk + async fn save(base_path: &PathBuf, config: &NodeConfig) -> Result<(), NodeConfigError> { + let path = Path::new(base_path).join(NODE_STATE_CONFIG_NAME); + File::create(path)?.write_all(serde_json::to_string(config)?.as_bytes())?; + Ok(()) + } + + /// migrate_config is a function used to apply breaking changes to the config file. + fn migrate_config( + current_version: Option, + config_path: PathBuf, + ) -> Result<(), NodeConfigError> { + match current_version { + None => { + Err(NodeConfigError::MigrationError(format!("Your Spacedrive config file stored at '{}' is missing the `version` field. If you just upgraded please delete the file and restart Spacedrive! Please note this upgrade will stop using your old 'library.db' as the folder structure has changed.", config_path.display()))) + } + _ => Ok(()), + } + } +} diff --git a/core/src/node/mod.rs b/core/src/node/mod.rs index 52bed0ef9..502a64f15 100644 --- a/core/src/node/mod.rs +++ b/core/src/node/mod.rs @@ -1,17 +1,10 @@ -use crate::{ - prisma::{self, node}, - Node, -}; use chrono::{DateTime, Utc}; use int_enum::IntEnum; use serde::{Deserialize, Serialize}; -use std::env; -use thiserror::Error; use ts_rs::TS; - -mod state; - -pub use state::*; +mod config; +use crate::prisma::node; +pub use config::*; #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] @@ -44,65 +37,3 @@ pub enum Platform { IOS = 4, Android = 5, } - -impl LibraryNode { - pub async fn create(node: &Node) -> Result<(), NodeError> { - println!("Creating node..."); - let mut config = state::get_nodestate(); - - let db = &node.database; - - let hostname = match hostname::get() { - Ok(hostname) => hostname.to_str().unwrap_or_default().to_owned(), - Err(_) => "unknown".to_owned(), - }; - - let platform = match env::consts::OS { - "windows" => Platform::Windows, - "macos" => Platform::MacOS, - "linux" => Platform::Linux, - _ => Platform::Unknown, - }; - - let _node = match db - .node() - .find_unique(node::pub_id::equals(config.node_pub_id.clone())) - .exec() - .await? - { - Some(node) => node, - None => { - db.node() - .create( - node::pub_id::set(config.node_pub_id.clone()), - node::name::set(hostname.clone()), - vec![node::platform::set(platform as i32)], - ) - .exec() - .await? - } - }; - - config.node_name = hostname; - config.node_id = _node.id; - config.save(); - - println!("node: {:?}", &_node); - - Ok(()) - } - - // pub async fn get_nodes(ctx: &CoreContext) -> Result, NodeError> { - // let db = &ctx.database; - - // let _node = db.node().find_many(vec![]).exec().await?; - - // Ok(_node) - // } -} - -#[derive(Error, Debug)] -pub enum NodeError { - #[error("Database error")] - DatabaseError(#[from] prisma::QueryError), -} diff --git a/core/src/node/state.rs b/core/src/node/state.rs deleted file mode 100644 index b7124686f..000000000 --- a/core/src/node/state.rs +++ /dev/null @@ -1,107 +0,0 @@ -use lazy_static::lazy_static; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::{BufReader, Write}; -use std::sync::RwLock; -use ts_rs::TS; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize, Clone, Default, TS)] -#[ts(export)] -pub struct NodeState { - pub node_pub_id: String, - pub node_id: i32, - pub node_name: String, - // config path is stored as struct can exist only in memory during startup and be written to disk later without supplying path - pub data_path: String, - // the port this node uses to listen for incoming connections - pub tcp_port: u32, - // all the libraries loaded by this node - pub libraries: Vec, - // used to quickly find the default library - pub current_library_uuid: String, -} - -pub static NODE_STATE_CONFIG_NAME: &str = "node_state.json"; - -#[derive(Debug, Serialize, Deserialize, Clone, Default, TS)] -#[ts(export)] -pub struct LibraryState { - pub library_uuid: String, - pub library_id: i32, - pub library_path: String, - pub offline: bool, -} - -// global, thread-safe storage for node state -lazy_static! { - static ref CONFIG: RwLock> = RwLock::new(None); -} - -pub fn get_nodestate() -> NodeState { - match CONFIG.read() { - Ok(guard) => guard.clone().unwrap_or(NodeState::default()), - Err(_) => return NodeState::default(), - } -} - -impl NodeState { - pub fn new(data_path: &str, node_name: &str) -> Result { - let uuid = Uuid::new_v4().to_string(); - // create struct and assign defaults - let config = Self { - node_pub_id: uuid, - data_path: data_path.to_string(), - node_name: node_name.to_string(), - ..Default::default() - }; - Ok(config) - } - - pub fn save(&self) { - self.write_memory(); - // only write to disk if config path is set - if !&self.data_path.is_empty() { - let config_path = format!("{}/{}", &self.data_path, NODE_STATE_CONFIG_NAME); - let mut file = fs::File::create(config_path).unwrap(); - let json = serde_json::to_string(&self).unwrap(); - file.write_all(json.as_bytes()).unwrap(); - } - } - - pub fn read_disk(&mut self) -> Result<(), ()> { - let config_path = format!("{}/{}", &self.data_path, NODE_STATE_CONFIG_NAME); - - // open the file and parse json - match fs::File::open(config_path) { - Ok(file) => { - let reader = BufReader::new(file); - let data = serde_json::from_reader(reader).unwrap(); - // assign to self - *self = data; - } - _ => {} - } - Ok(()) - } - - fn write_memory(&self) { - let mut writeable = CONFIG.write().unwrap(); - *writeable = Some(self.clone()); - } - - pub fn get_current_library(&self) -> LibraryState { - match self - .libraries - .iter() - .find(|lib| lib.library_uuid == self.current_library_uuid) - { - Some(lib) => lib.clone(), - None => LibraryState::default(), - } - } - - pub fn get_current_library_db_path(&self) -> String { - format!("{}/library.db", &self.get_current_library().library_path) - } -} diff --git a/core/src/sys/locations.rs b/core/src/sys/locations.rs index 0b4dafac3..220bacecb 100644 --- a/core/src/sys/locations.rs +++ b/core/src/sys/locations.rs @@ -1,11 +1,10 @@ use crate::{ - encode::ThumbnailJob, file::{cas::FileIdentifierJob, indexer::IndexerJob}, - node::{get_nodestate, LibraryNode}, + library::LibraryContext, + node::LibraryNode, prisma::{file_path, location}, - ClientQuery, CoreContext, CoreEvent, + ClientQuery, CoreEvent, LibraryQuery, }; -use prisma_client_rust::{raw, PrismaValue}; use serde::{Deserialize, Serialize}; use std::{fs, io, io::Write, path::Path}; use thiserror::Error; @@ -66,13 +65,12 @@ static DOTFILE_NAME: &str = ".spacedrive"; // } pub async fn get_location( - ctx: &CoreContext, + ctx: &LibraryContext, location_id: i32, ) -> Result { - let db = &ctx.database; - // get location by location_id from db and include location_paths - let location = match db + let location = match ctx + .db .location() .find_unique(location::id::equals(location_id)) .exec() @@ -84,9 +82,11 @@ pub async fn get_location( Ok(location.into()) } -pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) { - ctx.spawn_job(Box::new(IndexerJob { path: path.clone() })); - ctx.queue_job(Box::new(FileIdentifierJob { location_id, path })); +pub async fn scan_location(ctx: &LibraryContext, location_id: i32, path: String) { + ctx.spawn_job(Box::new(IndexerJob { path: path.clone() })) + .await; + ctx.queue_job(Box::new(FileIdentifierJob { location_id, path })) + .await; // TODO: make a way to stop jobs so this can be canceled without rebooting app // ctx.queue_job(Box::new(ThumbnailJob { // location_id, @@ -96,18 +96,18 @@ pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) { } pub async fn new_location_and_scan( - ctx: &CoreContext, + ctx: &LibraryContext, path: &str, ) -> Result { let location = create_location(&ctx, path).await?; - scan_location(&ctx, location.id, path.to_string()); + scan_location(&ctx, location.id, path.to_string()).await; Ok(location) } -pub async fn get_locations(ctx: &CoreContext) -> Result, SysError> { - let db = &ctx.database; +pub async fn get_locations(ctx: &LibraryContext) -> Result, SysError> { + let db = &ctx.db; let locations = db .location() @@ -125,10 +125,10 @@ pub async fn get_locations(ctx: &CoreContext) -> Result, S Ok(locations) } -pub async fn create_location(ctx: &CoreContext, path: &str) -> Result { - let db = &ctx.database; - let config = get_nodestate(); - +pub async fn create_location( + ctx: &LibraryContext, + path: &str, +) -> Result { // check if we have access to this location if !Path::new(path).exists() { Err(LocationError::NotFound(path.to_string()))?; @@ -155,7 +155,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result Result Result Result Result Err(LocationError::DotfileWriteFailure(e, path.to_string()))?, } - ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations)) - .await; + // ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations)) + // .await; location } @@ -220,8 +222,8 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result Result<(), SysError> { - let db = &ctx.database; +pub async fn delete_location(ctx: &LibraryContext, location_id: i32) -> Result<(), SysError> { + let db = &ctx.db; db.file_path() .find_many(vec![file_path::location_id::equals(Some(location_id))]) @@ -235,8 +237,11 @@ pub async fn delete_location(ctx: &CoreContext, location_id: i32) -> Result<(), .exec() .await?; - ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations)) - .await; + ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::LibraryQuery { + library_id: ctx.id.to_string(), + query: LibraryQuery::SysGetLocations, + })) + .await; println!("Location {} deleted", location_id); diff --git a/core/src/sys/volumes.rs b/core/src/sys/volumes.rs index 6a043d991..3268787a7 100644 --- a/core/src/sys/volumes.rs +++ b/core/src/sys/volumes.rs @@ -1,5 +1,5 @@ // use crate::native; -use crate::{node::get_nodestate, prisma::volume::*}; +use crate::{library::LibraryContext, prisma::volume::*}; use serde::{Deserialize, Serialize}; use ts_rs::TS; // #[cfg(not(target_os = "macos"))] @@ -7,8 +7,6 @@ use std::process::Command; // #[cfg(not(target_os = "macos"))] use sysinfo::{DiskExt, System, SystemExt}; -use crate::CoreContext; - use super::SysError; #[derive(Serialize, Deserialize, Debug, Default, Clone, TS)] @@ -26,23 +24,21 @@ pub struct Volume { } impl Volume { - pub async fn save(ctx: &CoreContext) -> Result<(), SysError> { - let db = &ctx.database; - let config = get_nodestate(); - + pub async fn save(ctx: &LibraryContext) -> Result<(), SysError> { let volumes = Self::get_volumes()?; // enter all volumes associate with this client add to db for volume in volumes { - db.volume() + ctx.db + .volume() .upsert( node_id_mount_point_name( - config.node_id.clone(), + ctx.node_local_id.clone(), volume.mount_point.to_string(), volume.name.to_string(), ), ( - node_id::set(config.node_id), + node_id::set(ctx.node_local_id), name::set(volume.name), mount_point::set(volume.mount_point), vec![ diff --git a/core/src/util/db.rs b/core/src/util/db.rs index d0b31b2c8..0d1b3c33b 100644 --- a/core/src/util/db.rs +++ b/core/src/util/db.rs @@ -1,159 +1,123 @@ use crate::prisma::{self, migration, PrismaClient}; -use crate::CoreContext; use data_encoding::HEXLOWER; use include_dir::{include_dir, Dir}; -use prisma_client_rust::raw; -use ring::digest::{Context, Digest, SHA256}; -use std::ffi::OsStr; -use std::io::{self, BufReader, Read}; +use prisma_client_rust::{raw, NewClientError}; +use ring::digest::{Context, SHA256}; use thiserror::Error; const INIT_MIGRATION: &str = include_str!("../../prisma/migrations/migration_table/migration.sql"); static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/prisma/migrations"); +/// MigrationError represents an error that occurring while opening a initialising and running migrations on the database. #[derive(Error, Debug)] -pub enum DatabaseError { - #[error("Unable to initialize the Prisma client")] - ClientError(#[from] prisma::NewClientError), +pub enum MigrationError { + #[error("An error occurred while initialising a new database connection")] + DatabaseIntialisation(#[from] NewClientError), + #[error("An error occurred with the database while applying migrations")] + DatabaseError(#[from] prisma_client_rust::queries::Error), + #[error("An error occured reading the embedded migration files. {0}. Please report to Spacedrive developers!")] + InvalidEmbeddedMigration(&'static str), } -pub async fn create_connection(path: &str) -> Result { - println!("Creating database connection: {:?}", path); - let client = prisma::new_client_with_url(&format!("file:{}", &path)).await?; +/// load_and_migrate will load the database from the given path and migrate it to the latest version of the schema. +pub async fn load_and_migrate(db_url: &str) -> Result { + let client = prisma::new_client_with_url(db_url).await?; - Ok(client) -} - -pub fn sha256_digest(mut reader: R) -> Result { - let mut context = Context::new(&SHA256); - let mut buffer = [0; 1024]; - loop { - let count = reader.read(&mut buffer)?; - if count == 0 { - break; - } - context.update(&buffer[..count]); - } - Ok(context.finish()) -} - -pub async fn run_migrations(ctx: &CoreContext) -> Result<(), DatabaseError> { - let client = &ctx.database; - - match client + let migrations_table_missing = client ._query_raw::(raw!( "SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'" )) - .await - { - Ok(data) => { - if data.len() == 0 { - // execute migration - match client._execute_raw(raw!(INIT_MIGRATION)).await { - Ok(_) => {} - Err(e) => { - println!("Failed to create migration table: {}", e); - } - }; + .await? + .len() == 0; - let value: Vec = client - ._query_raw(raw!( - "SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'" - )) - .await - .unwrap(); + if migrations_table_missing { + client._execute_raw(raw!(INIT_MIGRATION)).await?; + } - #[cfg(debug_assertions)] - println!("Migration table created: {:?}", value); - } - - let mut migration_subdirs = MIGRATIONS_DIR - .dirs() - .filter(|subdir| { - subdir - .path() - .file_name() - .map(|name| name != OsStr::new("migration_table")) - .unwrap_or(false) + let mut migration_directories = MIGRATIONS_DIR + .dirs() + .map(|dir| { + dir.path() + .file_name() + .ok_or(MigrationError::InvalidEmbeddedMigration( + "File has malformed name", + )) + .and_then(|name| { + name.to_str() + .ok_or_else(|| { + MigrationError::InvalidEmbeddedMigration( + "File name contains malformed characters", + ) + }) + .map(|name| (name, dir)) }) - .collect::>(); + }) + .filter_map(|v| match v { + Ok((name, _)) if name == "migration_table" => None, + Ok((name, dir)) => match name[..14].parse::() { + Ok(timestamp) => Some(Ok((name, timestamp, dir))), + Err(_) => Some(Err(MigrationError::InvalidEmbeddedMigration( + "File name is incorrectly formatted", + ))), + }, + Err(v) => Some(Err(v)), + }) + .collect::, _>>()?; - migration_subdirs.sort_by(|a, b| { - let a_name = a.path().file_name().unwrap().to_str().unwrap(); - let b_name = b.path().file_name().unwrap().to_str().unwrap(); + // We sort the migrations so they are always applied in the correct order + migration_directories.sort_by(|(_, a_time, _), (_, b_time, _)| a_time.cmp(&b_time)); - let a_time = a_name[..14].parse::().unwrap(); - let b_time = b_name[..14].parse::().unwrap(); + for (name, _, dir) in migration_directories { + let migration_file_raw = dir + .get_file(dir.path().join("./migration.sql")) + .ok_or(MigrationError::InvalidEmbeddedMigration( + "Failed to find 'migration.sql' file in '{}' migration subdirectory", + ))? + .contents_utf8() + .ok_or( + MigrationError::InvalidEmbeddedMigration( + "Failed to open the contents of 'migration.sql' file in '{}' migration subdirectory", + ) + )?; - a_time.cmp(&b_time) - }); + // Generate SHA256 checksum of migration + let mut checksum = Context::new(&SHA256); + checksum.update(migration_file_raw.as_bytes()); + let checksum = HEXLOWER.encode(checksum.finish().as_ref()); - for subdir in migration_subdirs { - println!("{:?}", subdir.path()); - let migration_file = subdir - .get_file(subdir.path().join("./migration.sql")) - .unwrap(); - let migration_sql = migration_file.contents_utf8().unwrap(); + // get existing migration by checksum, if it doesn't exist run the migration + if client + .migration() + .find_unique(migration::checksum::equals(checksum.clone())) + .exec() + .await? + .is_none() + { + // Create migration record + client + .migration() + .create( + migration::name::set(name.to_string()), + migration::checksum::set(checksum.clone()), + vec![], + ) + .exec() + .await?; - let digest = sha256_digest(BufReader::new(migration_file.contents())).unwrap(); - // create a lowercase hash from - let checksum = HEXLOWER.encode(digest.as_ref()); - let name = subdir.path().file_name().unwrap().to_str().unwrap(); - - // get existing migration by checksum, if it doesn't exist run the migration - let existing_migration = client + // Split the migrations file up into each individual step and apply them all + let steps = migration_file_raw.split(";").collect::>(); + let steps = &steps[0..steps.len() - 1]; + for (i, step) in steps.iter().enumerate() { + client._execute_raw(raw!(*step)).await?; + client .migration() .find_unique(migration::checksum::equals(checksum.clone())) + .update(vec![migration::steps_applied::set(i as i32 + 1)]) .exec() - .await - .unwrap(); - - if existing_migration.is_none() { - #[cfg(debug_assertions)] - println!("Running migration: {}", name); - - let steps = migration_sql.split(";").collect::>(); - let steps = &steps[0..steps.len() - 1]; - - client - .migration() - .create( - migration::name::set(name.to_string()), - migration::checksum::set(checksum.clone()), - vec![], - ) - .exec() - .await - .unwrap(); - - for (i, step) in steps.iter().enumerate() { - match client._execute_raw(raw!(*step)).await { - Ok(_) => { - client - .migration() - .find_unique(migration::checksum::equals(checksum.clone())) - .update(vec![migration::steps_applied::set(i as i32 + 1)]) - .exec() - .await - .unwrap(); - } - Err(e) => { - println!("Error running migration: {}", name); - println!("{}", e); - break; - } - } - } - - #[cfg(debug_assertions)] - println!("Migration {} recorded successfully", name); - } + .await?; } } - Err(err) => { - panic!("Failed to check migration table existence: {:?}", err); - } } - Ok(()) + Ok(client) } diff --git a/packages/client/package.json b/packages/client/package.json index 03ee06fc3..db68bbf13 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -13,25 +13,27 @@ "lint": "TIMING=1 eslint src --fix", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, - "devDependencies": { - "@types/react": "^18.0.9", - "scripts": "*", - "tsconfig": "*", - "typescript": "^4.7.2" - }, "jest": { "preset": "scripts/jest/node" }, "dependencies": { "@sd/config": "workspace:*", "@sd/core": "workspace:*", + "@sd/interface": "workspace:*", "eventemitter3": "^4.0.7", "immer": "^9.0.14", - "react-query": "^3.39.1", + "lodash": "^4.17.21", + "react-query": "^3.34.19", "zustand": "4.0.0-rc.1" }, + "devDependencies": { + "@types/react": "^18.0.9", + "scripts": "*", + "tsconfig": "*", + "typescript": "^4.7.2", + "@types/lodash": "^4.14.182" + }, "peerDependencies": { - "react": "^18.0.0", - "react-query": "^3.34.19" + "react": "^18.0.0" } } diff --git a/packages/client/src/bridge.ts b/packages/client/src/bridge.ts index 712cbddde..41e430c66 100644 --- a/packages/client/src/bridge.ts +++ b/packages/client/src/bridge.ts @@ -1,12 +1,8 @@ -import { ClientCommand, ClientQuery, CoreResponse } from '@sd/core'; +import { ClientCommand, ClientQuery, CoreResponse, LibraryCommand, LibraryQuery } from '@sd/core'; import { EventEmitter } from 'eventemitter3'; -import { - UseMutationOptions, - UseQueryOptions, - UseQueryResult, - useMutation, - useQuery -} from 'react-query'; +import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from 'react-query'; + +import { useLibraryStore } from './stores'; // global var to store the transport TODO: not global :D export let transport: BaseTransport | null = null; @@ -23,11 +19,15 @@ export function setTransport(_transport: BaseTransport) { // extract keys from generated Rust query/command types type QueryKeyType = ClientQuery['key']; +type LibraryQueryKeyType = LibraryQuery['key']; type CommandKeyType = ClientCommand['key']; +type LibraryCommandKeyType = LibraryCommand['key']; // extract the type from the union type CQType = Extract; +type LQType = Extract; type CCType = Extract; +type LCType = Extract; type CRType = Extract; // extract payload type @@ -35,20 +35,18 @@ type ExtractParams

= P extends { params: any } ? P['params'] : never; type ExtractData = D extends { data: any } ? D['data'] : never; // vanilla method to call the transport -export async function queryBridge< - K extends QueryKeyType, - CQ extends CQType, - CR extends CRType ->(key: K, params?: ExtractParams): Promise> { +async function queryBridge, CR extends CRType>( + key: K, + params?: ExtractParams +): Promise> { const result = (await transport?.query({ key, params } as any)) as any; return result?.data; } -export async function commandBridge< - K extends CommandKeyType, - CC extends CCType, - CR extends CRType ->(key: K, params?: ExtractParams): Promise> { +async function commandBridge, CR extends CRType>( + key: K, + params?: ExtractParams +): Promise> { const result = (await transport?.command({ key, params } as any)) as any; return result?.data; } @@ -66,6 +64,21 @@ export function useBridgeQuery, CR ); } +export function useLibraryQuery< + K extends LibraryQueryKeyType, + CQ extends LQType, + CR extends CRType +>(key: K, params?: ExtractParams, options: UseQueryOptions> = {}) { + const library_id = useLibraryStore((state) => state.currentLibraryUuid); + if (!library_id) throw new Error(`Attempted to do library query '${key}' with no library set!`); + + return useQuery>( + [library_id, key, params], + async () => await queryBridge('LibraryQuery', { library_id, query: { key, params } as any }), + options + ); +} + export function useBridgeCommand< K extends CommandKeyType, CC extends CCType, @@ -78,9 +91,35 @@ export function useBridgeCommand< ); } +export function useLibraryCommand< + K extends LibraryCommandKeyType, + LC extends LCType, + CR extends CRType +>(key: K, options: UseMutationOptions> = {}) { + const library_id = useLibraryStore((state) => state.currentLibraryUuid); + if (!library_id) throw new Error(`Attempted to do library command '${key}' with no library set!`); + + return useMutation, unknown, ExtractParams>( + [library_id, key], + async (vars?: ExtractParams) => + await commandBridge('LibraryCommand', { library_id, command: { key, params: vars } as any }), + options + ); +} + export function command, CR extends CRType>( key: K, vars: ExtractParams ): Promise> { return commandBridge(key, vars); } + +export function libraryCommand< + K extends LibraryCommandKeyType, + LC extends LCType, + CR extends CRType +>(key: K, vars: ExtractParams): Promise> { + const library_id = useLibraryStore((state) => state.currentLibraryUuid); + if (!library_id) throw new Error(`Attempted to do library command '${key}' with no library set!`); + return commandBridge('LibraryCommand', { library_id, command: { key, params: vars } as any }); +} diff --git a/packages/interface/src/AppPropsContext.tsx b/packages/client/src/context/AppPropsContext.tsx similarity index 100% rename from packages/interface/src/AppPropsContext.tsx rename to packages/client/src/context/AppPropsContext.tsx diff --git a/packages/client/src/context/index.ts b/packages/client/src/context/index.ts new file mode 100644 index 000000000..70d70d64e --- /dev/null +++ b/packages/client/src/context/index.ts @@ -0,0 +1 @@ +export * from './AppPropsContext'; diff --git a/packages/client/src/files/index.ts b/packages/client/src/files/index.ts deleted file mode 100644 index 1b09d522d..000000000 --- a/packages/client/src/files/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './query'; -export * from './state'; diff --git a/packages/client/src/files/query.ts b/packages/client/src/files/query.ts deleted file mode 100644 index a8ce691f0..000000000 --- a/packages/client/src/files/query.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useState } from 'react'; -import { useQuery } from 'react-query'; - -import { useBridgeCommand, useBridgeQuery } from '../bridge'; -import { useFileExplorerState } from './state'; - -// this hook initializes the explorer state and queries the core -export function useFileExplorer(initialPath = '/', initialLocation: number | null = null) { - const fileState = useFileExplorerState(); - // file explorer hooks maintain their own local state relative to exploration - const [path, setPath] = useState(initialPath); - const [locationId, setLocationId] = useState(initialPath); - - // const { data: volumes } = useQuery(['sys_get_volumes'], () => bridge('sys_get_volumes')); - - return { setPath, setLocationId }; -} - -// export function useVolumes() { -// return useQuery(['SysGetVolumes'], () => bridge('SysGetVolumes')); -// } diff --git a/packages/client/src/files/state.ts b/packages/client/src/files/state.ts deleted file mode 100644 index d817daee4..000000000 --- a/packages/client/src/files/state.ts +++ /dev/null @@ -1,23 +0,0 @@ -import produce from 'immer'; -import create from 'zustand'; - -export interface FileExplorerState { - current_location_id: number | null; - row_limit: number; -} - -interface FileExplorerStore extends FileExplorerState { - update_row_limit: (new_limit: number) => void; -} - -export const useFileExplorerState = create((set, get) => ({ - current_location_id: null, - row_limit: 10, - update_row_limit: (new_limit: number) => { - set((store) => - produce(store, (draft) => { - draft.row_limit = new_limit; - }) - ); - } -})); diff --git a/packages/client/src/hooks/index.ts b/packages/client/src/hooks/index.ts new file mode 100644 index 000000000..25c15a805 --- /dev/null +++ b/packages/client/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCoreEvents'; diff --git a/packages/client/src/hooks/useCoreEvents.tsx b/packages/client/src/hooks/useCoreEvents.tsx new file mode 100644 index 000000000..178153c2c --- /dev/null +++ b/packages/client/src/hooks/useCoreEvents.tsx @@ -0,0 +1,59 @@ +import { CoreEvent } from '@sd/core'; +import { useContext, useEffect } from 'react'; +import { useQueryClient } from 'react-query'; + +import { transport, useExplorerStore } from '..'; + +export function useCoreEvents() { + const client = useQueryClient(); + + const { addNewThumbnail } = useExplorerStore(); + useEffect(() => { + function handleCoreEvent(e: CoreEvent) { + switch (e?.key) { + case 'NewThumbnail': + addNewThumbnail(e.data.cas_id); + break; + case 'InvalidateQuery': + case 'InvalidateQueryDebounced': + let query = []; + if (e.data.key === 'LibraryQuery') { + query = [e.data.params.library_id, e.data.params.query.key]; + + // TODO: find a way to make params accessible in TS + // also this method will only work for queries that use the whole params obj as the second key + // @ts-expect-error + if (e.data.params.query.params) { + // @ts-expect-error + query.push(e.data.params.query.params); + } + } else { + query = [e.data.key]; + + // TODO: find a way to make params accessible in TS + // also this method will only work for queries that use the whole params obj as the second key + // @ts-expect-error + if (e.data.params) { + // @ts-expect-error + query.push(e.data.params); + } + } + + client.invalidateQueries(query); + break; + + default: + break; + } + } + // check Tauri Event type + transport?.on('core_event', handleCoreEvent); + + return () => { + transport?.off('core_event', handleCoreEvent); + }; + + // listen('core_event', (e: { payload: CoreEvent }) => { + // }); + }, [transport]); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 75d5861c4..673524d79 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ export * from './bridge'; -export * from './files'; export * from './ClientProvider'; +export * from './stores'; +export * from './hooks'; +export * from './context'; diff --git a/packages/client/src/stores/index.ts b/packages/client/src/stores/index.ts new file mode 100644 index 000000000..c38ebef11 --- /dev/null +++ b/packages/client/src/stores/index.ts @@ -0,0 +1,4 @@ +export * from './useLibraryStore'; +export * from './useExplorerStore'; +export * from './useInspectorStore'; +export * from './useInspectorStore'; diff --git a/packages/interface/src/hooks/useExplorerState.ts b/packages/client/src/stores/useExplorerStore.ts similarity index 81% rename from packages/interface/src/hooks/useExplorerState.ts rename to packages/client/src/stores/useExplorerStore.ts index ef2a5ce17..185d88dd1 100644 --- a/packages/interface/src/hooks/useExplorerState.ts +++ b/packages/client/src/stores/useExplorerStore.ts @@ -1,15 +1,16 @@ import create from 'zustand'; -type ExplorerState = { +type ExplorerStore = { selectedRowIndex: number; setSelectedRowIndex: (index: number) => void; locationId: number; setLocationId: (index: number) => void; newThumbnails: Record; addNewThumbnail: (cas_id: string) => void; + reset: () => void; }; -export const useExplorerState = create((set) => ({ +export const useExplorerStore = create((set) => ({ selectedRowIndex: 1, setSelectedRowIndex: (index) => set((state) => ({ ...state, selectedRowIndex: index })), locationId: -1, @@ -19,5 +20,6 @@ export const useExplorerState = create((set) => ({ set((state) => ({ ...state, newThumbnails: { ...state.newThumbnails, [cas_id]: true } - })) + })), + reset: () => set(() => ({})) })); diff --git a/packages/interface/src/hooks/useInspectorState.tsx b/packages/client/src/stores/useInspectorStore.ts similarity index 82% rename from packages/interface/src/hooks/useInspectorState.tsx rename to packages/client/src/stores/useInspectorStore.ts index 7a7450645..97e9f29b8 100644 --- a/packages/interface/src/hooks/useInspectorState.tsx +++ b/packages/client/src/stores/useInspectorStore.ts @@ -1,17 +1,18 @@ -import { command } from '@sd/client'; import produce from 'immer'; import { debounce } from 'lodash'; import create from 'zustand'; +import { libraryCommand } from '../bridge'; + export type UpdateNoteFN = (vars: { id: number; note: string }) => void; -interface UseInspectorState { +interface InspectorStore { notes: Record; setNote: (file_id: number, note: string) => void; unCacheNote: (file_id: number) => void; } -export const useInspectorState = create((set) => ({ +export const useInspectorStore = create((set) => ({ notes: {}, // set the note locally setNote: (file_id, note) => { @@ -35,7 +36,7 @@ export const useInspectorState = create((set) => ({ // direct command call to update note export const updateNote = debounce(async (file_id: number, note: string) => { - return await command('FileSetNote', { + return await libraryCommand('FileSetNote', { id: file_id, note }); diff --git a/packages/client/src/stores/useLibraryStore.ts b/packages/client/src/stores/useLibraryStore.ts new file mode 100644 index 000000000..53a8a4e55 --- /dev/null +++ b/packages/client/src/stores/useLibraryStore.ts @@ -0,0 +1,67 @@ +import { LibraryConfigWrapped } from '@sd/core'; +import produce from 'immer'; +import { useMemo } from 'react'; +import { useQueryClient } from 'react-query'; +import create from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; + +import { useBridgeQuery } from '../bridge'; +import { useExplorerStore } from './useExplorerStore'; + +type LibraryStore = { + // the uuid of the currently active library + currentLibraryUuid: string | null; + // for full functionality this should be triggered along-side query invalidation + switchLibrary: (uuid: string) => void; + // a function + init: (libraries: LibraryConfigWrapped[]) => Promise; +}; + +export const useLibraryStore = create()( + devtools( + persist( + (set) => ({ + currentLibraryUuid: null, + switchLibrary: (uuid) => { + set((state) => + produce(state, (draft) => { + draft.currentLibraryUuid = uuid; + }) + ); + // reset other stores + useExplorerStore().reset(); + }, + init: async (libraries) => { + set((state) => + produce(state, (draft) => { + // use first library default if none set + if (!state.currentLibraryUuid) { + draft.currentLibraryUuid = libraries[0].uuid; + } + }) + ); + } + }), + { name: 'sd-library-store' } + ) + ) +); + +// this must be used at least once in the app to correct the initial state +// is memorized and can be used safely in any component +export const useCurrentLibrary = () => { + const { currentLibraryUuid, switchLibrary } = useLibraryStore(); + const { data: libraries } = useBridgeQuery('NodeGetLibraries', undefined, {}); + + // memorize library to avoid re-running find function + const currentLibrary = useMemo(() => { + const current = libraries?.find((l) => l.uuid === currentLibraryUuid); + // switch to first library if none set + if (Array.isArray(libraries) && !current && libraries[0]?.uuid) { + switchLibrary(libraries[0]?.uuid); + } + return current; + }, [libraries, currentLibraryUuid]); + + return { currentLibrary, libraries, currentLibraryUuid }; +}; diff --git a/packages/interface/package.json b/packages/interface/package.json index c532d24d4..8577584cd 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -46,7 +46,7 @@ "react-loading-icons": "^1.1.0", "react-loading-skeleton": "^3.1.0", "react-portal": "^4.2.2", - "react-query": "^3.39.1", + "react-query": "^3.34.19", "react-router": "6.3.0", "react-router-dom": "6.3.0", "react-scrollbars-custom": "^4.0.27", @@ -55,6 +55,7 @@ "react-virtuoso": "^2.12.1", "rooks": "^5.11.2", "tailwindcss": "^3.0.24", + "use-debounce": "^8.0.1", "zustand": "4.0.0-rc.1" }, "devDependencies": { diff --git a/packages/interface/src/App.tsx b/packages/interface/src/App.tsx index 339ff96fd..514d60a36 100644 --- a/packages/interface/src/App.tsx +++ b/packages/interface/src/App.tsx @@ -1,14 +1,14 @@ import '@fontsource/inter/variable.css'; import { BaseTransport, ClientProvider, setTransport } from '@sd/client'; +import { useCoreEvents } from '@sd/client'; +import { AppProps, AppPropsContext } from '@sd/client'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; -import { AppProps, AppPropsContext } from './AppPropsContext'; import { AppRouter } from './AppRouter'; import { ErrorFallback } from './ErrorFallback'; -import { useCoreEvents } from './hooks/useCoreEvents'; import './style.scss'; const queryClient = new QueryClient(); diff --git a/packages/interface/src/AppLayout.tsx b/packages/interface/src/AppLayout.tsx index 7b1b2dfc5..0c3ecbb11 100644 --- a/packages/interface/src/AppLayout.tsx +++ b/packages/interface/src/AppLayout.tsx @@ -1,8 +1,8 @@ +import { AppPropsContext } from '@sd/client'; import clsx from 'clsx'; import React, { useContext } from 'react'; import { Outlet } from 'react-router-dom'; -import { AppPropsContext } from './AppPropsContext'; import { Sidebar } from './components/file/Sidebar'; export function AppLayout() { diff --git a/packages/interface/src/AppRouter.tsx b/packages/interface/src/AppRouter.tsx index 2c3bbfde1..db0ffdacd 100644 --- a/packages/interface/src/AppRouter.tsx +++ b/packages/interface/src/AppRouter.tsx @@ -1,3 +1,5 @@ +import { useBridgeQuery } from '@sd/client'; +import { useLibraryStore } from '@sd/client'; import React, { useEffect } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; @@ -9,56 +11,81 @@ import { ExplorerScreen } from './screens/Explorer'; import { OverviewScreen } from './screens/Overview'; import { PhotosScreen } from './screens/Photos'; import { RedirectPage } from './screens/Redirect'; -import { SettingsScreen } from './screens/Settings'; import { TagScreen } from './screens/Tag'; -import AppearanceSettings from './screens/settings/AppearanceSettings'; -import ContactsSettings from './screens/settings/ContactsSettings'; -import ExperimentalSettings from './screens/settings/ExperimentalSettings'; -import GeneralSettings from './screens/settings/GeneralSettings'; -import KeysSettings from './screens/settings/KeysSetting'; -import LibrarySettings from './screens/settings/LibrarySettings'; -import LocationSettings from './screens/settings/LocationSettings'; -import SecuritySettings from './screens/settings/SecuritySettings'; -import SharingSettings from './screens/settings/SharingSettings'; -import SyncSettings from './screens/settings/SyncSettings'; -import TagsSettings from './screens/settings/TagsSettings'; +import { CurrentLibrarySettings } from './screens/settings/CurrentLibrarySettings'; +import { SettingsScreen } from './screens/settings/Settings'; +import AppearanceSettings from './screens/settings/client/AppearanceSettings'; +import GeneralSettings from './screens/settings/client/GeneralSettings'; +import ContactsSettings from './screens/settings/library/ContactsSettings'; +import KeysSettings from './screens/settings/library/KeysSetting'; +import LibraryGeneralSettings from './screens/settings/library/LibraryGeneralSettings'; +import LocationSettings from './screens/settings/library/LocationSettings'; +import SecuritySettings from './screens/settings/library/SecuritySettings'; +import SharingSettings from './screens/settings/library/SharingSettings'; +import SyncSettings from './screens/settings/library/SyncSettings'; +import TagsSettings from './screens/settings/library/TagsSettings'; +import ExperimentalSettings from './screens/settings/node/ExperimentalSettings'; +import LibrarySettings from './screens/settings/node/LibrariesSettings'; +import NodesSettings from './screens/settings/node/NodesSettings'; +import P2PSettings from './screens/settings/node/P2PSettings'; export function AppRouter() { let location = useLocation(); let state = location.state as { backgroundLocation?: Location }; + const libraryState = useLibraryStore(); + const { data: libraries } = useBridgeQuery('NodeGetLibraries'); + // TODO: This can be removed once we add a setup flow to the app useEffect(() => { - console.log({ url: location.pathname }); - }, [state]); + if (libraryState.currentLibraryUuid === null && libraries && libraries.length > 0) { + libraryState.switchLibrary(libraries[0].uuid); + } + }, [libraryState.currentLibraryUuid, libraries]); return ( <> - - }> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {libraryState.currentLibraryUuid === null ? ( + <> + {/* TODO: Remove this when adding app setup flow */} +

No Library Loaded...

+ + ) : ( + + }> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> - } /> - } /> - } /> - - + + )} ); } diff --git a/packages/interface/src/NotFound.tsx b/packages/interface/src/NotFound.tsx index c48e8141e..ae4acef1b 100644 --- a/packages/interface/src/NotFound.tsx +++ b/packages/interface/src/NotFound.tsx @@ -10,7 +10,7 @@ export function NotFound() { role="alert" className="flex flex-col items-center justify-center w-full h-full p-4 rounded-lg dark:text-white" > -

Error: 404

+

Error: 404

You chose nothingness.

+ {(locations?.length || 0) < 1 && ( + + )}
Tags diff --git a/packages/interface/src/components/layout/Card.tsx b/packages/interface/src/components/layout/Card.tsx new file mode 100644 index 000000000..6059bf0c0 --- /dev/null +++ b/packages/interface/src/components/layout/Card.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx'; +import React, { ReactNode } from 'react'; + +export default function Card(props: { children: ReactNode; className?: string }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/interface/src/components/layout/Dialog.tsx b/packages/interface/src/components/layout/Dialog.tsx index 3a30992ab..1c32ba432 100644 --- a/packages/interface/src/components/layout/Dialog.tsx +++ b/packages/interface/src/components/layout/Dialog.tsx @@ -5,7 +5,7 @@ import React, { ReactNode } from 'react'; import Loader from '../primitive/Loader'; -export interface DialogProps { +export interface DialogProps extends DialogPrimitive.DialogProps { trigger: ReactNode; ctaLabel?: string; ctaDanger?: boolean; @@ -18,13 +18,15 @@ export interface DialogProps { export default function Dialog(props: DialogProps) { return ( - + {props.trigger}
- {props.title} + + {props.title} + {props.description} diff --git a/packages/interface/src/components/layout/TopBar.tsx b/packages/interface/src/components/layout/TopBar.tsx index a451dc7f1..828666a01 100644 --- a/packages/interface/src/components/layout/TopBar.tsx +++ b/packages/interface/src/components/layout/TopBar.tsx @@ -1,5 +1,6 @@ import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline'; -import { useBridgeCommand } from '@sd/client'; +import { useLibraryCommand } from '@sd/client'; +import { useExplorerStore } from '@sd/client'; import { Dropdown } from '@sd/ui'; import clsx from 'clsx'; import { @@ -15,7 +16,6 @@ import { import React, { DetailedHTMLProps, HTMLAttributes } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useExplorerState } from '../../hooks/useExplorerState'; import { Shortcut } from '../primitive/Shortcut'; import { DefaultProps } from '../primitive/types'; @@ -50,14 +50,14 @@ const TopBarButton: React.FC = ({ icon: Icon, ...props }) => }; export const TopBar: React.FC = (props) => { - const { locationId } = useExplorerState(); - const { mutate: generateThumbsForLocation } = useBridgeCommand('GenerateThumbsForLocation', { + const { locationId } = useExplorerStore(); + const { mutate: generateThumbsForLocation } = useLibraryCommand('GenerateThumbsForLocation', { onMutate: (data) => { console.log('GenerateThumbsForLocation', data); } }); - const { mutate: identifyUniqueFiles } = useBridgeCommand('IdentifyUniqueFiles', { + const { mutate: identifyUniqueFiles } = useLibraryCommand('IdentifyUniqueFiles', { onMutate: (data) => { console.log('IdentifyUniqueFiles', data); }, diff --git a/packages/interface/src/components/location/LocationListItem.tsx b/packages/interface/src/components/location/LocationListItem.tsx index 7b20e759c..3ccff9eb4 100644 --- a/packages/interface/src/components/location/LocationListItem.tsx +++ b/packages/interface/src/components/location/LocationListItem.tsx @@ -1,6 +1,6 @@ import { DotsVerticalIcon, RefreshIcon } from '@heroicons/react/outline'; -import { CogIcon, TrashIcon } from '@heroicons/react/solid'; -import { command, useBridgeCommand } from '@sd/client'; +import { TrashIcon } from '@heroicons/react/solid'; +import { useLibraryCommand } from '@sd/client'; import { LocationResource } from '@sd/core'; import { Button } from '@sd/ui'; import clsx from 'clsx'; @@ -16,9 +16,9 @@ interface LocationListItemProps { export default function LocationListItem({ location }: LocationListItemProps) { const [hide, setHide] = useState(false); - const { mutate: locRescan } = useBridgeCommand('LocRescan'); + const { mutate: locRescan } = useLibraryCommand('LocRescan'); - const { mutate: deleteLoc, isLoading: locDeletePending } = useBridgeCommand('LocDelete', { + const { mutate: deleteLoc, isLoading: locDeletePending } = useLibraryCommand('LocDelete', { onSuccess: () => { setHide(true); } diff --git a/packages/interface/src/components/settings/SettingsContainer.tsx b/packages/interface/src/components/settings/SettingsContainer.tsx index bcd448ab3..a5e313680 100644 --- a/packages/interface/src/components/settings/SettingsContainer.tsx +++ b/packages/interface/src/components/settings/SettingsContainer.tsx @@ -5,5 +5,5 @@ interface SettingsContainerProps { } export const SettingsContainer: React.FC = (props) => { - return
{props.children}
; + return
{props.children}
; }; diff --git a/packages/interface/src/components/settings/SettingsHeader.tsx b/packages/interface/src/components/settings/SettingsHeader.tsx index 633fa0328..f64df584b 100644 --- a/packages/interface/src/components/settings/SettingsHeader.tsx +++ b/packages/interface/src/components/settings/SettingsHeader.tsx @@ -1,15 +1,19 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; interface SettingsHeaderProps { title: string; description: string; + rightArea?: ReactNode; } export const SettingsHeader: React.FC = (props) => { return ( -
-

{props.title}

-

{props.description}

+
+
+

{props.title}

+

{props.description}

+
+ {props.rightArea}
); diff --git a/packages/interface/src/components/settings/SettingsScreenContainer.tsx b/packages/interface/src/components/settings/SettingsScreenContainer.tsx new file mode 100644 index 000000000..fd0363a59 --- /dev/null +++ b/packages/interface/src/components/settings/SettingsScreenContainer.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; +import React from 'react'; +import { Outlet } from 'react-router'; + +interface SettingsScreenContainerProps { + children: React.ReactNode; +} + +export const SettingsIcon = ({ component: Icon, ...props }: any) => ( + +); + +export const SettingsHeading: React.FC<{ className?: string; children: string }> = ({ + children, + className +}) => ( +
+ {children} +
+); + +export const SettingsScreenContainer: React.FC = (props) => { + return ( +
+
+
+
{props.children}
+
+
+
+
+
+ +
+
+
+
+
+ ); +}; diff --git a/packages/interface/src/hooks/useCoreEvents.tsx b/packages/interface/src/hooks/useCoreEvents.tsx deleted file mode 100644 index 729068f73..000000000 --- a/packages/interface/src/hooks/useCoreEvents.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { transport } from '@sd/client'; -import { CoreEvent } from '@sd/core'; -import { useContext, useEffect } from 'react'; -import { useQueryClient } from 'react-query'; - -import { AppPropsContext } from '../AppPropsContext'; -import { useExplorerState } from './useExplorerState'; - -export function useCoreEvents() { - const client = useQueryClient(); - - const { addNewThumbnail } = useExplorerState(); - useEffect(() => { - function handleCoreEvent(e: CoreEvent) { - switch (e?.key) { - case 'NewThumbnail': - addNewThumbnail(e.data.cas_id); - break; - case 'InvalidateQuery': - case 'InvalidateQueryDebounced': - let query = [e.data.key]; - // TODO: find a way to make params accessible in TS - // also this method will only work for queries that use the whole params obj as the second key - // @ts-expect-error - if (e.data.params) { - // @ts-expect-error - query.push(e.data.params); - } - client.invalidateQueries(e.data.key); - break; - - default: - break; - } - } - // check Tauri Event type - transport?.on('core_event', handleCoreEvent); - - return () => { - transport?.off('core_event', handleCoreEvent); - }; - - // listen('core_event', (e: { payload: CoreEvent }) => { - // }); - }, [transport]); -} diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index eefb2853f..896dd051e 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -1,5 +1,6 @@ +import { AppProps, Platform } from '@sd/client'; + import App from './App'; -import { AppProps, Platform } from './AppPropsContext'; export type { AppProps, Platform }; diff --git a/packages/interface/src/screens/Debug.tsx b/packages/interface/src/screens/Debug.tsx index b5788c203..d0758facb 100644 --- a/packages/interface/src/screens/Debug.tsx +++ b/packages/interface/src/screens/Debug.tsx @@ -1,21 +1,22 @@ -import { useBridgeCommand, useBridgeQuery } from '@sd/client'; +import { useBridgeQuery, useLibraryCommand, useLibraryQuery } from '@sd/client'; +import { AppPropsContext } from '@sd/client'; import { Button } from '@sd/ui'; import React, { useContext } from 'react'; -import { AppPropsContext } from '../AppPropsContext'; import CodeBlock from '../components/primitive/Codeblock'; export const DebugScreen: React.FC<{}> = (props) => { const appPropsContext = useContext(AppPropsContext); - const { data: client } = useBridgeQuery('NodeGetState'); + const { data: nodeState } = useBridgeQuery('NodeGetState'); + const { data: libraryState } = useBridgeQuery('NodeGetLibraries'); const { data: jobs } = useBridgeQuery('JobGetRunning'); - const { data: jobHistory } = useBridgeQuery('JobGetHistory'); + const { data: jobHistory } = useLibraryQuery('JobGetHistory'); // const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', { // onMutate: () => { // alert('Database purged'); // } // }); - const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles'); + const { mutate: identifyFiles } = useLibraryCommand('IdentifyUniqueFiles'); return (
@@ -27,8 +28,8 @@ export const DebugScreen: React.FC<{}> = (props) => { variant="gray" size="sm" onClick={() => { - if (client && appPropsContext?.onOpen) { - appPropsContext.onOpen(client.data_path); + if (nodeState && appPropsContext?.onOpen) { + appPropsContext.onOpen(nodeState.data_path); } }} > @@ -39,8 +40,10 @@ export const DebugScreen: React.FC<{}> = (props) => {

Job History

-

Client State

- +

Node State

+ +

Libraries

+
); diff --git a/packages/interface/src/screens/Explorer.tsx b/packages/interface/src/screens/Explorer.tsx index 742339eab..4c1df35ec 100644 --- a/packages/interface/src/screens/Explorer.tsx +++ b/packages/interface/src/screens/Explorer.tsx @@ -1,11 +1,11 @@ -import { useBridgeQuery } from '@sd/client'; +import { useLibraryQuery } from '@sd/client'; +import { useExplorerStore } from '@sd/client'; import React from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { FileList } from '../components/file/FileList'; import { Inspector } from '../components/file/Inspector'; import { TopBar } from '../components/layout/TopBar'; -import { useExplorerState } from '../hooks/useExplorerState'; export const ExplorerScreen: React.FC<{}> = () => { let [searchParams] = useSearchParams(); @@ -16,13 +16,13 @@ export const ExplorerScreen: React.FC<{}> = () => { const [limit, setLimit] = React.useState(100); - const { selectedRowIndex } = useExplorerState(); + const { selectedRowIndex } = useExplorerStore(); // Current Location - const { data: currentLocation } = useBridgeQuery('SysGetLocation', { id: location_id }); + const { data: currentLocation } = useLibraryQuery('SysGetLocation', { id: location_id }); // Current Directory - const { data: currentDir } = useBridgeQuery( + const { data: currentDir } = useLibraryQuery( 'LibGetExplorerDir', { location_id: location_id!, path, limit }, { enabled: !!location_id } diff --git a/packages/interface/src/screens/Overview.tsx b/packages/interface/src/screens/Overview.tsx index 38617eb0d..2b0cd4d13 100644 --- a/packages/interface/src/screens/Overview.tsx +++ b/packages/interface/src/screens/Overview.tsx @@ -1,5 +1,6 @@ -import { PlusIcon } from '@heroicons/react/solid'; -import { useBridgeQuery } from '@sd/client'; +import { DatabaseIcon, ExclamationCircleIcon, PlusIcon } from '@heroicons/react/solid'; +import { useBridgeQuery, useLibraryQuery } from '@sd/client'; +import { AppPropsContext } from '@sd/client'; import { Statistics } from '@sd/core'; import { Button, Input } from '@sd/ui'; import byteSize from 'byte-size'; @@ -10,7 +11,6 @@ import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; import create from 'zustand'; -import { AppPropsContext } from '../AppPropsContext'; import { Device } from '../components/device/Device'; import Dialog from '../components/layout/Dialog'; @@ -102,7 +102,7 @@ const StatItem: React.FC = (props) => { export const OverviewScreen = () => { const { data: libraryStatistics, isLoading: isStatisticsLoading } = - useBridgeQuery('GetLibraryStatistics'); + useLibraryQuery('GetLibraryStatistics'); const { data: nodeState } = useBridgeQuery('NodeGetState'); const { overviewStats, setOverviewStats } = useOverviewState(); @@ -157,7 +157,17 @@ export const OverviewScreen = () => { {/* STAT HEADER */}
{/* STAT CONTAINER */} -
+
+ {!libraryStatistics && ( +
+
+ Missing library +
+ + Ensure the library you have loaded still exists on disk + +
+ )} {Object.entries(overviewStats).map(([key, value]) => { if (!displayableStatItems.includes(key)) return null; @@ -171,8 +181,9 @@ export const OverviewScreen = () => { ); })}
+
-
+
{
-
+
( - -); - -const Heading: React.FC<{ className?: string; children: string }> = ({ children, className }) => ( -
- {children} -
-); - -export const SettingsScreen: React.FC<{}> = () => { - return ( -
-
-
-
- Client - - - General - - - - Security - - - - Appearance - - - - Experimental - - - Library - - - Database - - - - Locations - - - - - Keys - - - - Tags - - - Cloud - - - Sync - - - - Contacts - -
-
-
-
-
-
- -
-
-
-
-
- ); -}; diff --git a/packages/interface/src/screens/settings/CurrentLibrarySettings.tsx b/packages/interface/src/screens/settings/CurrentLibrarySettings.tsx new file mode 100644 index 000000000..ec8426952 --- /dev/null +++ b/packages/interface/src/screens/settings/CurrentLibrarySettings.tsx @@ -0,0 +1,42 @@ +import { CogIcon, DatabaseIcon, KeyIcon, TagIcon } from '@heroicons/react/outline'; +import { HardDrive, ShareNetwork } from 'phosphor-react'; +import React from 'react'; + +import { SidebarLink } from '../../components/file/Sidebar'; +import { + SettingsHeading, + SettingsIcon, + SettingsScreenContainer +} from '../../components/settings/SettingsScreenContainer'; + +export const CurrentLibrarySettings: React.FC = () => { + return ( + + Library Settings + + + General + + + + Locations + + + + Tags + + + + Keys + + + + Backups + + + + Sync + + + ); +}; diff --git a/packages/interface/src/screens/settings/GeneralSettings.tsx b/packages/interface/src/screens/settings/GeneralSettings.tsx deleted file mode 100644 index e908b3789..000000000 --- a/packages/interface/src/screens/settings/GeneralSettings.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useBridgeQuery } from '@sd/client'; -import React from 'react'; - -import { InputContainer } from '../../components/primitive/InputContainer'; -import Listbox from '../../components/primitive/Listbox'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; - -export default function GeneralSettings() { - const { data: volumes } = useBridgeQuery('SysGetVolumes'); - - return ( - - - -
-
- { - const name = volume.name && volume.name.length ? volume.name : volume.mount_point; - return { - key: name, - option: name, - description: volume.mount_point - }; - }) ?? [] - } - /> -
-
-
- - {/*
{JSON.stringify({ config })}
*/} -
- ); -} diff --git a/packages/interface/src/screens/settings/LibrarySettings.tsx b/packages/interface/src/screens/settings/LibrarySettings.tsx deleted file mode 100644 index 9b54f8725..000000000 --- a/packages/interface/src/screens/settings/LibrarySettings.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; - -import { Toggle } from '../../components/primitive'; -import { InputContainer } from '../../components/primitive/InputContainer'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; - -// type LibrarySecurity = 'public' | 'password' | 'vault'; - -export default function LibrarySettings() { - // const locations = useBridgeQuery("SysGetLocation") - const [encryptOnCloud, setEncryptOnCloud] = React.useState(false); - - return ( - - {/* */} - - -
- -
-
-
- ); -} diff --git a/packages/interface/src/screens/settings/SecuritySettings.tsx b/packages/interface/src/screens/settings/SecuritySettings.tsx deleted file mode 100644 index e8b39dec3..000000000 --- a/packages/interface/src/screens/settings/SecuritySettings.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from '@sd/ui'; -import React from 'react'; - -import { InputContainer } from '../../components/primitive/InputContainer'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; - -export default function SecuritySettings() { - return ( - - - -
- - {/**/} -
-
-
- ); -} diff --git a/packages/interface/src/screens/settings/Settings.tsx b/packages/interface/src/screens/settings/Settings.tsx new file mode 100644 index 000000000..fc903d4aa --- /dev/null +++ b/packages/interface/src/screens/settings/Settings.tsx @@ -0,0 +1,83 @@ +import { + CogIcon, + CollectionIcon, + GlobeAltIcon, + KeyIcon, + TerminalIcon +} from '@heroicons/react/outline'; +import { HardDrive, PaintBrush, ShareNetwork } from 'phosphor-react'; +import React from 'react'; + +import { SidebarLink } from '../../components/file/Sidebar'; +import { + SettingsHeading, + SettingsIcon, + SettingsScreenContainer +} from '../../components/settings/SettingsScreenContainer'; + +export const SettingsScreen: React.FC = () => { + return ( + + Client + + + General + + + + Appearance + + + Node + + + Nodes + + + + P2P + + + + Libraries + + + + Security + + Developer + + + Experimental + + {/* Library + + + My Libraries + + + + Locations + + + + + Keys + + + + Tags + */} + + {/* Cloud + + + Sync + + + + Contacts + */} + + ); +}; diff --git a/packages/interface/src/screens/settings/AppearanceSettings.tsx b/packages/interface/src/screens/settings/client/AppearanceSettings.tsx similarity index 58% rename from packages/interface/src/screens/settings/AppearanceSettings.tsx rename to packages/interface/src/screens/settings/client/AppearanceSettings.tsx index 746d3d273..177b5b1a4 100644 --- a/packages/interface/src/screens/settings/AppearanceSettings.tsx +++ b/packages/interface/src/screens/settings/client/AppearanceSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function AppearanceSettings() { return ( diff --git a/packages/interface/src/screens/settings/client/GeneralSettings.tsx b/packages/interface/src/screens/settings/client/GeneralSettings.tsx new file mode 100644 index 000000000..66550f7c8 --- /dev/null +++ b/packages/interface/src/screens/settings/client/GeneralSettings.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +export default function GeneralSettings() { + // const { data: volumes } = useBridgeQuery('SysGetVolumes'); + + return ( + + + {/* +
+
+ { + const name = volume.name && volume.name.length ? volume.name : volume.mount_point; + return { + key: name, + option: name, + description: volume.mount_point + }; + }) ?? [] + } + /> +
+
+
*/} +
+ ); +} diff --git a/packages/interface/src/screens/settings/ContactsSettings.tsx b/packages/interface/src/screens/settings/library/ContactsSettings.tsx similarity index 58% rename from packages/interface/src/screens/settings/ContactsSettings.tsx rename to packages/interface/src/screens/settings/library/ContactsSettings.tsx index 581c1df18..014a7316d 100644 --- a/packages/interface/src/screens/settings/ContactsSettings.tsx +++ b/packages/interface/src/screens/settings/library/ContactsSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function ContactsSettings() { return ( diff --git a/packages/interface/src/screens/settings/KeysSetting.tsx b/packages/interface/src/screens/settings/library/KeysSetting.tsx similarity index 55% rename from packages/interface/src/screens/settings/KeysSetting.tsx rename to packages/interface/src/screens/settings/library/KeysSetting.tsx index 5e9087fce..388d3fc44 100644 --- a/packages/interface/src/screens/settings/KeysSetting.tsx +++ b/packages/interface/src/screens/settings/library/KeysSetting.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function KeysSettings() { return ( diff --git a/packages/interface/src/screens/settings/library/LibraryGeneralSettings.tsx b/packages/interface/src/screens/settings/library/LibraryGeneralSettings.tsx new file mode 100644 index 000000000..87e70e4e0 --- /dev/null +++ b/packages/interface/src/screens/settings/library/LibraryGeneralSettings.tsx @@ -0,0 +1,91 @@ +import { useBridgeCommand, useBridgeQuery } from '@sd/client'; +import { useCurrentLibrary } from '@sd/client'; +import { Button, Input } from '@sd/ui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDebounce } from 'use-debounce'; + +import { Toggle } from '../../../components/primitive'; +import { InputContainer } from '../../../components/primitive/InputContainer'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +export default function LibraryGeneralSettings() { + const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary(); + + const { mutate: editLibrary } = useBridgeCommand('EditLibrary'); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [encryptLibrary, setEncryptLibrary] = useState(false); + + const [nameDebounced] = useDebounce(name, 500); + const [descriptionDebounced] = useDebounce(description, 500); + + useEffect(() => { + if (currentLibrary) { + const { name, description } = currentLibrary.config; + // currentLibrary must be loaded, name must not be empty, and must be different from the current + if (nameDebounced && (nameDebounced !== name || descriptionDebounced !== description)) { + editLibrary({ + id: currentLibraryUuid!, + name: nameDebounced, + description: descriptionDebounced + }); + } + } + }, [nameDebounced, descriptionDebounced]); + + useEffect(() => { + if (currentLibrary) { + setName(currentLibrary.config.name); + setDescription(currentLibrary.config.description); + } + }, [libraries]); + + return ( + + +
+
+ Name + setName(e.target.value)} + defaultValue="My Default Library" + /> +
+
+ Description + setDescription(e.target.value)} + placeholder="" + /> +
+
+ + +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/packages/interface/src/screens/settings/library/LocationSettings.tsx b/packages/interface/src/screens/settings/library/LocationSettings.tsx new file mode 100644 index 000000000..3b6c67c26 --- /dev/null +++ b/packages/interface/src/screens/settings/library/LocationSettings.tsx @@ -0,0 +1,55 @@ +import { PlusIcon } from '@heroicons/react/solid'; +import { useBridgeQuery, useLibraryCommand, useLibraryQuery } from '@sd/client'; +import { AppPropsContext } from '@sd/client'; +import { Button } from '@sd/ui'; +import React, { useContext } from 'react'; + +import LocationListItem from '../../../components/location/LocationListItem'; +import { InputContainer } from '../../../components/primitive/InputContainer'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +// const exampleLocations = [ +// { option: 'Macintosh HD', key: 'macintosh_hd' }, +// { option: 'LaCie External', key: 'lacie_external' }, +// { option: 'Seagate 8TB', key: 'seagate_8tb' } +// ]; + +export default function LocationSettings() { + const { data: locations } = useLibraryQuery('SysGetLocations'); + + const appProps = useContext(AppPropsContext); + + const { mutate: createLocation } = useLibraryCommand('LocCreate'); + + return ( + + {/**/} + + +
+ } + /> + +
+ {locations?.map((location) => ( + + ))} +
+ + ); +} diff --git a/packages/interface/src/screens/settings/library/SecuritySettings.tsx b/packages/interface/src/screens/settings/library/SecuritySettings.tsx new file mode 100644 index 000000000..ac3e7a87d --- /dev/null +++ b/packages/interface/src/screens/settings/library/SecuritySettings.tsx @@ -0,0 +1,14 @@ +import { Button } from '@sd/ui'; +import React from 'react'; + +import { InputContainer } from '../../../components/primitive/InputContainer'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +export default function SecuritySettings() { + return ( + + + + ); +} diff --git a/packages/interface/src/screens/settings/SharingSettings.tsx b/packages/interface/src/screens/settings/library/SharingSettings.tsx similarity index 58% rename from packages/interface/src/screens/settings/SharingSettings.tsx rename to packages/interface/src/screens/settings/library/SharingSettings.tsx index 4403271c1..23ef94e67 100644 --- a/packages/interface/src/screens/settings/SharingSettings.tsx +++ b/packages/interface/src/screens/settings/library/SharingSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function SharingSettings() { return ( diff --git a/packages/interface/src/screens/settings/SyncSettings.tsx b/packages/interface/src/screens/settings/library/SyncSettings.tsx similarity index 56% rename from packages/interface/src/screens/settings/SyncSettings.tsx rename to packages/interface/src/screens/settings/library/SyncSettings.tsx index 73842468d..9cdb85193 100644 --- a/packages/interface/src/screens/settings/SyncSettings.tsx +++ b/packages/interface/src/screens/settings/library/SyncSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function SyncSettings() { return ( diff --git a/packages/interface/src/screens/settings/TagsSettings.tsx b/packages/interface/src/screens/settings/library/TagsSettings.tsx similarity index 55% rename from packages/interface/src/screens/settings/TagsSettings.tsx rename to packages/interface/src/screens/settings/library/TagsSettings.tsx index d1aac3e81..19bb977f6 100644 --- a/packages/interface/src/screens/settings/TagsSettings.tsx +++ b/packages/interface/src/screens/settings/library/TagsSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function TagsSettings() { return ( diff --git a/packages/interface/src/screens/settings/ExperimentalSettings.tsx b/packages/interface/src/screens/settings/node/ExperimentalSettings.tsx similarity index 64% rename from packages/interface/src/screens/settings/ExperimentalSettings.tsx rename to packages/interface/src/screens/settings/node/ExperimentalSettings.tsx index 62a253f8a..c274da7dd 100644 --- a/packages/interface/src/screens/settings/ExperimentalSettings.tsx +++ b/packages/interface/src/screens/settings/node/ExperimentalSettings.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { useNodeStore } from '../../components/device/Stores'; -import { Toggle } from '../../components/primitive'; -import { InputContainer } from '../../components/primitive/InputContainer'; -import { SettingsContainer } from '../../components/settings/SettingsContainer'; -import { SettingsHeader } from '../../components/settings/SettingsHeader'; +import { useNodeStore } from '../../../components/device/Stores'; +import { Toggle } from '../../../components/primitive'; +import { InputContainer } from '../../../components/primitive/InputContainer'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; export default function ExperimentalSettings() { - // const locations = useBridgeQuery("SysGetLocation") - const { isExperimental, setIsExperimental } = useNodeStore(); return ( diff --git a/packages/interface/src/screens/settings/node/LibrariesSettings.tsx b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx new file mode 100644 index 000000000..3f1b9c3a0 --- /dev/null +++ b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx @@ -0,0 +1,113 @@ +import { CollectionIcon, TrashIcon } from '@heroicons/react/outline'; +import { PlusIcon } from '@heroicons/react/solid'; +import { useBridgeCommand, useBridgeQuery } from '@sd/client'; +import { AppPropsContext } from '@sd/client'; +import { LibraryConfig, LibraryConfigWrapped } from '@sd/core'; +import { Button, Input } from '@sd/ui'; +import React, { useContext, useState } from 'react'; + +import Card from '../../../components/layout/Card'; +import Dialog from '../../../components/layout/Dialog'; +import { Toggle } from '../../../components/primitive'; +import { InputContainer } from '../../../components/primitive/InputContainer'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +// type LibrarySecurity = 'public' | 'password' | 'vault'; + +function LibraryListItem(props: { library: LibraryConfigWrapped }) { + const [openDeleteModal, setOpenDeleteModal] = useState(false); + + const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeCommand('DeleteLibrary', { + onSuccess: () => { + setOpenDeleteModal(false); + } + }); + + return ( + +
+

{props.library.config.name}

+

{props.library.uuid}

+
+
+ { + deleteLib({ id: props.library.uuid }); + }} + loading={libDeletePending} + ctaDanger + ctaLabel="Delete" + trigger={ + + } + /> +
+
+ ); +} + +export default function LibrarySettings() { + const [openCreateModal, setOpenCreateModal] = useState(false); + const [newLibName, setNewLibName] = useState(''); + + const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeCommand('CreateLibrary', { + onSuccess: () => { + setOpenCreateModal(false); + } + }); + + const { data: libraries } = useBridgeQuery('NodeGetLibraries'); + + function createNewLib() { + if (newLibName) { + createLibrary({ name: newLibName }); + } + } + + return ( + + + + Add Library + + } + > + setNewLibName(e.target.value)} + /> + +
+ } + /> + +
+ {libraries?.map((library) => ( + + ))} +
+ + ); +} diff --git a/packages/interface/src/screens/settings/node/NodesSettings.tsx b/packages/interface/src/screens/settings/node/NodesSettings.tsx new file mode 100644 index 000000000..75595f42f --- /dev/null +++ b/packages/interface/src/screens/settings/node/NodesSettings.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +export default function NodesSettings() { + return ( + + + + ); +} diff --git a/packages/interface/src/screens/settings/node/P2PSettings.tsx b/packages/interface/src/screens/settings/node/P2PSettings.tsx new file mode 100644 index 000000000..aee248624 --- /dev/null +++ b/packages/interface/src/screens/settings/node/P2PSettings.tsx @@ -0,0 +1,40 @@ +import { useBridgeQuery } from '@sd/client'; +import { Button, Input } from '@sd/ui'; +import React from 'react'; + +import { Toggle } from '../../../components/primitive'; +import { InputContainer } from '../../../components/primitive/InputContainer'; +import Listbox from '../../../components/primitive/Listbox'; +import { SettingsContainer } from '../../../components/settings/SettingsContainer'; +import { SettingsHeader } from '../../../components/settings/SettingsHeader'; + +export default function P2PSettings() { + return ( + + + + + + + + +
+ +
+ Change +
+
+
+
+ ); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index ad1cc798d..888cf519d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,7 +17,7 @@ "storybook:build": "build-storybook" }, "dependencies": { - "@headlessui/react": "^1.6.4", + "@headlessui/react": "^1.6.6", "@heroicons/react": "^1.0.6", "@radix-ui/react-context-menu": "^0.1.6", "clsx": "^1.1.1", diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx index 5c32fc513..5120351fc 100644 --- a/packages/ui/src/Input.tsx +++ b/packages/ui/src/Input.tsx @@ -39,7 +39,7 @@ export const Input = React.forwardRef(({ ...props ref={ref} {...props} className={clsx( - `px-3 py-1 rounded-md border leading-7 outline-none shadow-xs focus:ring-2 transition-all`, + `px-3 py-1 text-sm rounded-md border leading-7 outline-none shadow-xs focus:ring-2 transition-all`, variants[props.variant || 'default'], props.className )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14a3699c6b966807c5ac9c5ebb79fdfd1032433f..b4f6a891dba6a12003c55a2d11b8b936f9619afb 100644 GIT binary patch delta 1885 zcma)7Yiv_>6wiI%%U~^gF;)r%$K1oUyKUD8fw*V4whq>fZ8#aw$6edCU7v%t;uAjb zK^bsmXBdefDkL~T=lG)rAtu5HCJ!|+8WS-n8b?F|7$ro!v^+$k;mgfE_x#Q|zw`T_ z|G8t=7G8t0gHu#_tspqfyuqn=ngm|wbn1DhS+8>mPN&hOcNuxJ(J1id8jm=?${{{m zYY;Euit!10UV%8tJ|rIJ9+p0`;+eu+@z)Ywyr!B}7!#cCM0IpXh@}>(R_W9_y_z?R zwTi>ytGTmT|6oFh^*i0-4+12?p}0`(5nSP+LAM~~HmPgWym+9p$aA`kAZ8*)N(>(TG$%q}x#|+@S zfYo)iS!{{WfO&;aVBi~q?S{Bb&<{D>-ED~#y}m|oOVp!n zNm(7~bSUm=_bhMjY3a1+b^68zzbQ26>o5$3^aeOcoG8^udN zl_N>BxEEgylQ%FHRKqxfE@<#)^h_4zfw56s0~bf}+zb}I?ZG#b|KSA5xend%{j41I4Ijn$kCDsg=T1f^Kx00{Sg+)uTV(9UaDLBC{@aadmts5(&o=@oINC7>?D|0^d(p0PQ7L-3O|^`;(;L=j)Px*>&ujlsFu6bZH?xDIW^*-xpg3N;KXM`4zoar7DZ3~F_z!H_?(G!j|cVKVpk^Qr!IotA-h z7JG-$>gY{dd;58NZ->JdUFwes4e3-O-DMc?y0qqSH_ZHunFrHvvN`ZgA6EQe<@c1DZsy9cTVgb$xXr!3r0* zemS=xZ{c?C;w?Jgr%~&@TDQ;S)>yonZjaC6^_sOBtw-z8`HXsl(W7&_)oP#UCY4Jn z=4>hyD|xwiflLzh3XO=B$!w>tNM|Y1h_5R?6$h0nN$V6hcc(4YWacoGbg&eBDwz-k zlr6rS%7`5(ap}vacNp=SB4uf(aEug9S;esUHNisYBZ6L9DVnE5SFZbiX<|c*H?+iZ z21a(`%1^+*MCQTe4)QR}Op|g*e4adj*R-Trk2yd2Edpu1h!^|GGcqXA(8(a{Ckx{K z&wEHYmi*zY`=(s@dxFfuo3rGxE6|@s3-Cf8#p5#pYTXH2`$z#Ve?i?K@cJc6!NN>B zBjCby3d_WuTXd7Zsl`|wY;joZ)lJ=l);71Vwq7^jYN(I-o4gu7gp-(TxY0}{z}hMj;N$BQ zi;KUf-b;kQO-crf?=wl5-l8s&aB>_882wEBnh7gc$tQ3%hZgc+^&BPOy()B&z#D3G zN&&MWX{e+1h{Ye&p|`VOH^-;K{vcxDn*e$Pig%$j*bE>Vg0Ju#w7X~lj|7n~2X-Y> z0{n0iG5DENXuB&ue8(TC7CpEGGXF%0_^&%?n!=avp`v&?kWJGx-b>Q&kdXf@D`08= zrUE)6Qu$8~eTcwQPtv_6$fBt?N>Y9FAMX1zcDD;&1yRESin^|S{54q}&jQV`TUfpm@Y`CzzqGd1y z?gb_jcSh*)vv8t<7hp%p(D;|fnU#TfZd=Eht_;{$A_4W<9HZdk>h?Tynt+>!B=y>d z%&LIrKVyD;3o-;&hHb4Z(F3oJF$s8Tja@d!X`H;vR;I#t-K+rrCU6|iOy<5KutLUd z(Bj<}1-vJX%Ovo4I=7vEKVt!=R2+|2_i%GnaDGKX<8qX%dITniB({E=;L>s6W$w;2 zKAz3D<>T}!zLJ0ineg3l%X1A4xw AOaK4?