[ENG-999] Order aware pagination (#1283)

* correct types

* remove optional override

* handle group_directories properly

* throw errors if is_dir is null

* disable size ordering

* usePathsInfiniteQuery

* implement for objects too

* cleanup
This commit is contained in:
Brendan Allan 2023-09-04 20:38:09 +08:00 committed by GitHub
parent ae0f5c744e
commit e7fbdb479c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 610 additions and 333 deletions

View file

@ -15,7 +15,8 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps<
filter: {
locationId: id,
path: path ?? ''
}
},
take: 100
}
]);

View file

@ -25,7 +25,8 @@ const SearchScreen = ({ navigation }: RootStackScreenProps<'Search'>) => {
// ...args,
filter: {
search: deferredSearch
}
},
take: 100
}
],
{

View file

@ -11,7 +11,8 @@ export default function TagScreen({ navigation, route }: SharedScreenProps<'Tag'
{
filter: {
tags: [id]
}
},
take: 100
}
]);

View file

@ -46,15 +46,19 @@ const platform: Platform = {
const queryClient = new QueryClient({
defaultOptions: {
queries: import.meta.env.VITE_SD_DEMO_MODE
? {
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: Infinity,
networkMode: 'offlineFirst',
enabled: false
}
: undefined
queries: {
...(import.meta.env.VITE_SD_DEMO_MODE && {
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: Infinity,
networkMode: 'offlineFirst',
enabled: false
}),
networkMode: 'always'
},
mutations: {
networkMode: 'always'
}
// TODO: Mutations can't be globally disable which is annoying!
}
});

View file

@ -15,13 +15,15 @@ use crate::{
use std::{collections::BTreeSet, path::PathBuf};
use chrono::{DateTime, FixedOffset, Utc};
use prisma_client_rust::{operator, or};
use prisma_client_rust::{operator, or, WhereQuery};
use rspc::{alpha::AlphaRouter, ErrorCode};
use serde::{Deserialize, Serialize};
use specta::Type;
use super::{Ctx, R};
const MAX_TAKE: u8 = 100;
#[derive(Serialize, Type, Debug)]
struct SearchData<T> {
cursor: Option<Vec<u8>>,
@ -53,16 +55,16 @@ impl From<SortOrder> for prisma::SortOrder {
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
pub enum FilePathSearchOrdering {
pub enum FilePathOrder {
Name(SortOrder),
SizeInBytes(SortOrder),
DateCreated(SortOrder),
DateModified(SortOrder),
DateIndexed(SortOrder),
Object(Box<ObjectSearchOrdering>),
Object(Box<ObjectOrder>),
}
impl FilePathSearchOrdering {
impl FilePathOrder {
fn get_sort_order(&self) -> prisma::SortOrder {
(*match self {
Self::Name(v) => v,
@ -184,14 +186,55 @@ impl FilePathFilterArgs {
}
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CursorOrderItem<T> {
order: SortOrder,
data: T,
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum FilePathObjectCursor {
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
Kind(CursorOrderItem<i32>),
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum FilePathCursorVariant {
None(file_path::pub_id::Type),
Name(CursorOrderItem<String>),
// SizeInBytes(CursorOrderItem<Vec<u8>>),
DateCreated(CursorOrderItem<DateTime<FixedOffset>>),
DateModified(CursorOrderItem<DateTime<FixedOffset>>),
DateIndexed(CursorOrderItem<DateTime<FixedOffset>>),
Object(FilePathObjectCursor),
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct FilePathCursor {
is_dir: bool,
variant: FilePathCursorVariant,
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum ObjectCursor {
None(object::pub_id::Type),
DateAccessed(CursorOrderItem<DateTime<FixedOffset>>),
Kind(CursorOrderItem<i32>),
}
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
pub enum ObjectSearchOrdering {
pub enum ObjectOrder {
DateAccessed(SortOrder),
Kind(SortOrder),
}
impl ObjectSearchOrdering {
impl ObjectOrder {
fn get_sort_order(&self) -> prisma::SortOrder {
(*match self {
Self::DateAccessed(v) => v,
@ -211,6 +254,14 @@ impl ObjectSearchOrdering {
}
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub enum OrderAndPagination<TOrder, TCursor> {
OrderOnly(TOrder),
Offset { offset: i32, order: Option<TOrder> },
Cursor(TCursor),
}
#[derive(Deserialize, Type, Debug, Default, Clone, Copy)]
#[serde(rename_all = "camelCase")]
enum ObjectHiddenFilter {
@ -277,7 +328,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
.procedure("ephemeralPaths", {
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "field", content = "value")]
enum NonIndexedPathOrdering {
enum EphemeralPathOrder {
Name(SortOrder),
SizeInBytes(SortOrder),
DateCreated(SortOrder),
@ -286,16 +337,16 @@ pub fn mount() -> AlphaRouter<Ctx> {
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
struct NonIndexedPath {
struct EphemeralPathSearchArgs {
path: PathBuf,
with_hidden_files: bool,
#[specta(optional)]
order: Option<NonIndexedPathOrdering>,
order: Option<EphemeralPathOrder>,
}
R.with2(library()).query(
|(node, library),
NonIndexedPath {
EphemeralPathSearchArgs {
path,
with_hidden_files,
order,
@ -303,53 +354,36 @@ pub fn mount() -> AlphaRouter<Ctx> {
let mut paths =
non_indexed::walk(path, with_hidden_files, node, library).await?;
macro_rules! order_match {
($order:ident, [$(($variant:ident, |$i:ident| $func:expr)),+]) => {{
match $order {
$(EphemeralPathOrder::$variant(order) => {
paths.entries.sort_unstable_by(|path1, path2| {
let func = |$i: &ExplorerItem| $func;
let one = func(path1);
let two = func(path2);
match order {
SortOrder::Desc => two.cmp(&one),
SortOrder::Asc => one.cmp(&two),
}
});
})+
}
}};
}
if let Some(order) = order {
match order {
NonIndexedPathOrdering::Name(order) => {
paths.entries.sort_unstable_by(|path1, path2| {
let one = path1.name().to_lowercase();
let two = path2.name().to_lowercase();
match order {
SortOrder::Desc => two.cmp(&one),
SortOrder::Asc => one.cmp(&two),
}
});
}
NonIndexedPathOrdering::SizeInBytes(order) => {
paths.entries.sort_unstable_by(|path1, path2| {
let one = path1.size_in_bytes();
let two = path2.size_in_bytes();
match order {
SortOrder::Desc => two.cmp(&one),
SortOrder::Asc => one.cmp(&two),
}
});
}
NonIndexedPathOrdering::DateCreated(order) => {
paths.entries.sort_unstable_by(|path1, path2| {
let one = path1.date_created();
let two = path2.date_created();
match order {
SortOrder::Desc => two.cmp(&one),
SortOrder::Asc => one.cmp(&two),
}
});
}
NonIndexedPathOrdering::DateModified(order) => {
paths.entries.sort_unstable_by(|path1, path2| {
let one = path1.date_modified();
let two = path2.date_modified();
match order {
SortOrder::Desc => two.cmp(&one),
SortOrder::Asc => one.cmp(&two),
}
});
}
}
order_match!(
order,
[
(Name, |p| p.name().to_lowercase()),
(SizeInBytes, |p| p.size_in_bytes()),
(DateCreated, |p| p.date_created()),
(DateModified, |p| p.date_modified())
]
)
}
Ok(paths)
@ -357,22 +391,12 @@ pub fn mount() -> AlphaRouter<Ctx> {
)
})
.procedure("paths", {
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
enum FilePathPagination {
Cursor { pub_id: file_path::pub_id::Type },
Offset(i32),
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
struct FilePathSearchArgs {
take: u8,
#[specta(optional)]
take: Option<i32>,
#[specta(optional)]
order: Option<FilePathSearchOrdering>,
#[specta(optional)]
pagination: Option<FilePathPagination>,
order_and_pagination: Option<OrderAndPagination<FilePathOrder, FilePathCursor>>,
#[serde(default)]
filter: FilePathFilterArgs,
#[serde(default = "default_group_directories")]
@ -387,19 +411,18 @@ pub fn mount() -> AlphaRouter<Ctx> {
|(node, library),
FilePathSearchArgs {
take,
order,
pagination,
order_and_pagination,
filter,
group_directories,
}| async move {
let Library { db, .. } = library.as_ref();
let take = take.unwrap_or(100);
let take = take.min(MAX_TAKE);
let mut query = db
.file_path()
.find_many(filter.into_params(db).await?)
.take(take as i64 + 1);
.take(take as i64);
// WARN: this order_by for grouping directories MUST always come before the other order_by
if group_directories {
@ -407,32 +430,97 @@ pub fn mount() -> AlphaRouter<Ctx> {
}
// WARN: this order_by for sorting data MUST always come after the other order_by
if let Some(order) = order {
query = query.order_by(order.into_param());
}
if let Some(pagination) = pagination {
match pagination {
FilePathPagination::Cursor { pub_id } => {
query = query.cursor(file_path::pub_id::equals(pub_id));
if let Some(order_and_pagination) = order_and_pagination {
match order_and_pagination {
OrderAndPagination::OrderOnly(order) => {
query = query.order_by(order.into_param());
}
OrderAndPagination::Offset { offset, order } => {
query = query.skip(offset as i64);
if let Some(order) = order {
query = query.order_by(order.into_param())
}
}
OrderAndPagination::Cursor(cursor) => {
// This may seem dumb but it's vital!
// If we're grouping by directories + all directories have been fetched,
// we don't want to include them in the results.
// It's important to keep in mind that since the `order_by` for
// `group_directories` comes before all other orderings,
// all other orderings will be applied independently to directories and paths.
if group_directories && !cursor.is_dir {
query.add_where(file_path::is_dir::not(Some(true)))
}
macro_rules! arm {
($field:ident, $item:ident) => {{
let item = $item;
query.add_where(match item.order {
SortOrder::Asc => file_path::$field::gt(item.data),
SortOrder::Desc => file_path::$field::lt(item.data),
});
query = query
.order_by(file_path::$field::order(item.order.into()));
}};
}
match cursor.variant {
FilePathCursorVariant::None(item) => {
query = query.cursor(file_path::pub_id::equals(item));
}
FilePathCursorVariant::Name(item) => arm!(name, item),
FilePathCursorVariant::DateCreated(item) => {
arm!(date_created, item)
}
FilePathCursorVariant::DateModified(item) => {
arm!(date_modified, item)
}
FilePathCursorVariant::DateIndexed(item) => {
arm!(date_indexed, item)
}
FilePathCursorVariant::Object(obj) => {
macro_rules! arm {
($field:ident, $item:ident) => {{
let item = $item;
query.add_where(match item.order {
SortOrder::Asc => file_path::object::is(vec![
object::$field::gt(item.data),
]),
SortOrder::Desc => file_path::object::is(vec![
object::$field::lt(item.data),
]),
});
query =
query.order_by(file_path::object::order(vec![
object::$field::order(item.order.into()),
]));
}};
}
match obj {
FilePathObjectCursor::Kind(item) => arm!(kind, item),
FilePathObjectCursor::DateAccessed(item) => {
arm!(date_accessed, item)
}
};
}
};
query = query
.order_by(file_path::pub_id::order(prisma::SortOrder::Asc));
}
FilePathPagination::Offset(offset) => query = query.skip(offset as i64),
}
}
let (file_paths, cursor) = {
let mut paths = query
.include(file_path_with_object::include())
.exec()
.await?;
let cursor = (paths.len() as i32 > take)
.then(|| paths.pop())
.flatten()
.map(|r| r.pub_id);
(paths, cursor)
};
let file_paths = query
.include(file_path_with_object::include())
.exec()
.await?;
let mut items = Vec::with_capacity(file_paths.len());
@ -453,7 +541,10 @@ pub fn mount() -> AlphaRouter<Ctx> {
})
}
Ok(SearchData { items, cursor })
Ok(SearchData {
items,
cursor: None,
})
},
)
})
@ -478,22 +569,12 @@ pub fn mount() -> AlphaRouter<Ctx> {
})
})
.procedure("objects", {
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
enum ObjectPagination {
Cursor { pub_id: object::pub_id::Type },
Offset(i32),
}
#[derive(Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
struct ObjectSearchArgs {
take: u8,
#[specta(optional)]
take: Option<i32>,
#[specta(optional)]
order: Option<ObjectSearchOrdering>,
#[specta(optional)]
pagination: Option<ObjectPagination>,
order_and_pagination: Option<OrderAndPagination<ObjectOrder, ObjectCursor>>,
#[serde(default)]
filter: ObjectFilterArgs,
}
@ -502,30 +583,55 @@ pub fn mount() -> AlphaRouter<Ctx> {
|(node, library),
ObjectSearchArgs {
take,
order,
pagination,
order_and_pagination,
filter,
}| async move {
let Library { db, .. } = library.as_ref();
let take = take.unwrap_or(100);
let take = take.max(MAX_TAKE);
let mut query = db
.object()
.find_many(filter.into_params())
.take(take as i64 + 1);
.take(take as i64);
if let Some(order) = order {
query = query.order_by(order.into_param());
}
if let Some(pagination) = pagination {
match pagination {
ObjectPagination::Cursor { pub_id } => {
query = query.cursor(object::pub_id::equals(pub_id));
if let Some(order_and_pagination) = order_and_pagination {
match order_and_pagination {
OrderAndPagination::OrderOnly(order) => {
query = query.order_by(order.into_param());
}
ObjectPagination::Offset(offset) => {
OrderAndPagination::Offset { offset, order } => {
query = query.skip(offset as i64);
if let Some(order) = order {
query = query.order_by(order.into_param())
}
}
OrderAndPagination::Cursor(cursor) => {
macro_rules! arm {
($field:ident, $item:ident) => {{
let item = $item;
query.add_where(match item.order {
SortOrder::Asc => object::$field::gt(item.data),
SortOrder::Desc => object::$field::lt(item.data),
});
query = query
.order_by(object::$field::order(item.order.into()));
}};
}
match cursor {
ObjectCursor::None(item) => {
query = query.cursor(object::pub_id::equals(item));
}
ObjectCursor::Kind(item) => arm!(kind, item),
ObjectCursor::DateAccessed(item) => arm!(date_accessed, item),
}
query =
query.order_by(object::pub_id::order(prisma::SortOrder::Asc))
}
}
}
@ -536,7 +642,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
.exec()
.await?;
let cursor = (objects.len() as i32 > take)
let cursor = (objects.len() as u8 > take)
.then(|| objects.pop())
.flatten()
.map(|r| r.pub_id);

View file

@ -52,7 +52,7 @@ impl LibraryPreferences {
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LocationSettings {
explorer: ExplorerSettings<search::FilePathSearchOrdering>,
explorer: ExplorerSettings<search::FilePathOrder>,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]

View file

@ -16,9 +16,9 @@ export const useExplorerContext = () => {
return ctx;
};
export const ExplorerContextProvider = <TOrdering extends Ordering>({
export const ExplorerContextProvider = <TExplorer extends UseExplorer<any>>({
explorer,
children
}: PropsWithChildren<{
explorer: UseExplorer<TOrdering>;
explorer: TExplorer;
}>) => <ExplorerContext.Provider value={explorer as any}>{children}</ExplorerContext.Provider>;

View file

@ -25,7 +25,6 @@ const INSPECTOR_WIDTH = 260;
export default function Explorer(props: PropsWithChildren<Props>) {
const explorerStore = useExplorerStore();
const explorer = useExplorerContext();
const [{ path }] = useExplorerSearchParams();
// Can we put this somewhere else -_-
useLibrarySubscription(['jobs.newThumbnail'], {

View file

@ -65,13 +65,11 @@ export function getOrderingDirection(ordering: Ordering): SortOrder {
else return ordering.value;
}
export const createDefaultExplorerSettings = <TOrder extends Ordering>({
order
}: {
order: TOrder | null;
export const createDefaultExplorerSettings = <TOrder extends Ordering>(args?: {
order?: TOrder | null;
}) =>
({
order,
order: args?.order ?? null,
layoutMode: 'grid' as ExplorerLayout,
gridItemSize: 110 as number,
showBytesInGridView: true as boolean,
@ -109,7 +107,6 @@ const state = {
newThumbnails: proxySet() as Set<string>,
cutCopyState: { type: 'Idle' } as CutCopyState,
quickViewObject: null as ExplorerItem | null,
groupBy: 'none',
isDragging: false,
gridGap: 8
};
@ -148,7 +145,7 @@ export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep<CutCopyStat
export const filePathOrderingKeysSchema = z.union([
z.literal('name').describe('Name'),
z.literal('sizeInBytes').describe('Size'),
// z.literal('sizeInBytes').describe('Size'),
z.literal('dateModified').describe('Date Modified'),
z.literal('dateIndexed').describe('Date Indexed'),
z.literal('dateCreated').describe('Date Created'),
@ -162,7 +159,7 @@ export const objectOrderingKeysSchema = z.union([
export const nonIndexedPathOrderingSchema = z.union([
z.literal('name').describe('Name'),
z.literal('sizeInBytes').describe('Size'),
// z.literal('sizeInBytes').describe('Size'),
z.literal('dateCreated').describe('Date Created'),
z.literal('dateModified').describe('Date Modified')
]);

View file

@ -0,0 +1,77 @@
import { UseInfiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
import {
ExplorerItem,
LibraryConfigWrapped,
ObjectCursor,
ObjectOrder,
ObjectSearchArgs,
OrderAndPagination,
SearchData,
useRspcLibraryContext
} from '@sd/client';
import { getExplorerStore } from './store';
import { UseExplorerSettings } from './useExplorer';
export function useObjectsInfiniteQuery({
library,
arg,
settings,
...args
}: {
library: LibraryConfigWrapped;
arg: ObjectSearchArgs;
settings: UseExplorerSettings<ObjectOrder>;
} & Pick<UseInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled'>) {
const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot();
if (explorerSettings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order };
}
return useInfiniteQuery({
queryKey: ['search.objects', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Object' }> = pageParam;
const { order } = explorerSettings;
let orderAndPagination: OrderAndPagination<ObjectOrder, ObjectCursor> | undefined;
if (!cItem) {
if (order) orderAndPagination = { orderOnly: order };
} else {
let cursor: ObjectCursor | undefined;
if (!order) cursor = { none: [] };
else if (cItem) {
const direction = order.value;
switch (order.field) {
case 'kind': {
const data = cItem.item.kind;
if (data !== null) cursor = { kind: { order: direction, data } };
break;
}
case 'dateAccessed': {
const data = cItem.item.date_accessed;
if (data !== null)
cursor = { dateAccessed: { order: direction, data } };
break;
}
}
}
if (cursor) orderAndPagination = { cursor };
}
arg.orderAndPagination = orderAndPagination;
return ctx.client.query(['search.objects', arg]);
},
getNextPageParam: (lastPage) => {
if (lastPage.items.length < arg.take) return undefined;
else return lastPage.items[arg.take];
},
...args
});
}

View file

@ -0,0 +1,154 @@
import { UseInfiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
import {
ExplorerItem,
FilePathCursor,
FilePathCursorVariant,
FilePathObjectCursor,
FilePathOrder,
FilePathSearchArgs,
LibraryConfigWrapped,
OrderAndPagination,
SearchData,
useRspcLibraryContext
} from '@sd/client';
import { getExplorerStore } from './store';
import { UseExplorerSettings } from './useExplorer';
export function usePathsInfiniteQuery({
library,
arg,
settings,
...args
}: {
library: LibraryConfigWrapped;
arg: FilePathSearchArgs;
settings: UseExplorerSettings<FilePathOrder>;
} & Pick<UseInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled'>) {
const ctx = useRspcLibraryContext();
const explorerSettings = settings.useSettingsSnapshot();
if (explorerSettings.order) {
arg.orderAndPagination = { orderOnly: explorerSettings.order };
}
return useInfiniteQuery({
queryKey: ['search.paths', { library_id: library.uuid, arg }] as const,
queryFn: ({ pageParam, queryKey: [_, { arg }] }) => {
const cItem: Extract<ExplorerItem, { type: 'Path' }> = pageParam;
const { order } = explorerSettings;
let orderAndPagination: OrderAndPagination<FilePathOrder, FilePathCursor> | undefined;
if (!cItem) {
if (order) orderAndPagination = { orderOnly: order };
} else {
let variant: FilePathCursorVariant | undefined;
if (!order) variant = { none: [] };
else if (cItem) {
switch (order.field) {
case 'name': {
const data = cItem.item.name;
if (data !== null)
variant = {
name: {
order: order.value,
data
}
};
break;
}
case 'dateCreated': {
const data = cItem.item.date_created;
if (data !== null)
variant = {
dateCreated: {
order: order.value,
data
}
};
break;
}
case 'dateModified': {
const data = cItem.item.date_modified;
if (data !== null)
variant = {
dateModified: {
order: order.value,
data
}
};
break;
}
case 'dateIndexed': {
const data = cItem.item.date_indexed;
if (data !== null)
variant = {
dateIndexed: {
order: order.value,
data
}
};
break;
}
case 'object': {
const object = cItem.item.object;
if (!object) break;
let objectCursor: FilePathObjectCursor | undefined;
switch (order.value.field) {
case 'dateAccessed': {
const data = object.date_accessed;
if (data !== null)
objectCursor = {
dateAccessed: {
order: order.value.value,
data
}
};
break;
}
case 'kind': {
const data = object.kind;
if (data !== null)
objectCursor = {
kind: {
order: order.value.value,
data
}
};
break;
}
}
if (objectCursor)
variant = {
object: objectCursor
};
break;
}
}
}
if (cItem.item.is_dir === null) throw new Error();
if (variant)
orderAndPagination = {
cursor: { variant, isDir: cItem.item.is_dir }
};
}
arg.orderAndPagination = orderAndPagination;
return ctx.client.query(['search.paths', arg]);
},
getNextPageParam: (lastPage) => {
if (lastPage.items.length < arg.take) return undefined;
else return lastPage.items[arg.take];
},
onSuccess: () => getExplorerStore().resetNewThumbnails(),
...args
});
}

View file

@ -15,7 +15,6 @@ export default () => {
const navigate = useNavigate();
const location = useLocation();
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
const keybind = keybindForOs(os);

View file

@ -1,5 +1,5 @@
import { Suspense, memo, useDeferredValue, useMemo } from 'react';
import { type NonIndexedPathOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client';
import { type EphemeralPathOrder, getExplorerItemData, useLibraryQuery } from '@sd/client';
import { Tooltip } from '@sd/ui';
import { type PathParams, PathParamsSchema } from '~/app/route-schemas';
import { useOperatingSystem, useZodSearchParams } from '~/hooks';
@ -22,7 +22,7 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<NonIndexedPathOrdering>({
createDefaultExplorerSettings<EphemeralPathOrder>({
order: {
field: 'name',
value: 'Asc'

View file

@ -1,16 +1,16 @@
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { stringify } from 'uuid';
import {
ExplorerSettings,
FilePathFilterArgs,
FilePathSearchOrdering,
FilePathOrder,
ObjectKindEnum,
useLibraryContext,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
useRspcLibraryContext
useLibrarySubscription
} from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Folder } from '~/components';
@ -18,12 +18,9 @@ import { useKeyDeleteFile, useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContextProvider } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import {
createDefaultExplorerSettings,
filePathOrderingKeysSchema,
getExplorerStore
} from '../Explorer/store';
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
import { UseExplorerSettings, useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { usePathsInfiniteQuery } from '../Explorer/usePathsInfiniteQuery';
import { useExplorerSearchParams } from '../Explorer/util';
import { TopBarPortal } from '../TopBar/Portal';
import LocationOptions from './LocationOptions';
@ -38,11 +35,8 @@ export const Component = () => {
const updatePreferences = useLibraryMutation('preferences.update');
const settings = useMemo(() => {
const defaults = createDefaultExplorerSettings<FilePathSearchOrdering>({
order: {
field: 'name',
value: 'Asc'
}
const defaults = createDefaultExplorerSettings<FilePathOrder>({
order: { field: 'name', value: 'Asc' }
});
if (!location.data) return defaults;
@ -61,16 +55,12 @@ export const Component = () => {
}, [location.data, preferences.data?.location]);
const onSettingsChanged = useDebouncedCallback(
async (settings: ExplorerSettings<FilePathSearchOrdering>) => {
async (settings: ExplorerSettings<FilePathOrder>) => {
if (!location.data) return;
const pubId = stringify(location.data.pub_id);
try {
await updatePreferences.mutateAsync({
location: {
[pubId]: {
explorer: settings
}
}
location: { [pubId]: { explorer: settings } }
});
queryClient.invalidateQueries(['preferences.get']);
} catch (e) {
@ -80,7 +70,7 @@ export const Component = () => {
500
);
const explorerSettings = useExplorerSettings<FilePathSearchOrdering>({
const explorerSettings = useExplorerSettings({
settings,
onSettingsChanged,
orderingKeys: filePathOrderingKeysSchema
@ -92,23 +82,14 @@ export const Component = () => {
items,
count,
loadMore,
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined,
settings: explorerSettings
settings: explorerSettings,
...(location.data && {
parent: { type: 'Location', location: location.data }
})
});
useLibrarySubscription(
[
'locations.quickRescan',
{
sub_path: path ?? '',
location_id: locationId
}
],
['locations.quickRescan', { sub_path: path ?? '', location_id: locationId }],
{ onData() {} }
);
@ -151,11 +132,10 @@ const useItems = ({
settings
}: {
locationId: number;
settings: UseExplorerSettings<FilePathSearchOrdering>;
settings: UseExplorerSettings<FilePathOrder>;
}) => {
const [{ path, take }] = useExplorerSearchParams();
const ctx = useRspcLibraryContext();
const { library } = useLibraryContext();
const explorerSettings = settings.useSettingsSnapshot();
@ -163,35 +143,16 @@ const useItems = ({
const filter: FilePathFilterArgs = {
locationId,
...(explorerSettings.layoutMode === 'media'
? { object: { kind: [5, 7] } }
? { object: { kind: [ObjectKindEnum.Image, ObjectKindEnum.Video] } }
: { path: path ?? '' })
};
const count = useLibraryQuery(['search.pathsCount', { filter }]);
const query = useInfiniteQuery({
queryKey: [
'search.paths',
{
library_id: library.uuid,
arg: {
order: explorerSettings.order,
filter,
take
}
}
] as const,
queryFn: ({ pageParam: cursor, queryKey }) =>
ctx.client.query([
'search.paths',
{
...queryKey[1].arg,
pagination: cursor ? { cursor: { pub_id: cursor } } : undefined
}
]),
getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
keepPreviousData: true,
onSuccess: () => getExplorerStore().resetNewThumbnails()
const query = usePathsInfiniteQuery({
arg: { filter, take },
library,
settings
});
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) || null, [query.data]);
@ -202,12 +163,7 @@ const useItems = ({
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
return {
query,
items,
loadMore,
count: count.data
};
return { query, items, loadMore, count: count.data };
};
function getLastSectionOfPath(path: string): string | undefined {

View file

@ -1,18 +1,25 @@
import { iconNames } from '@sd/assets/util';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
Category,
FilePathFilterArgs,
FilePathOrder,
ObjectFilterArgs,
ObjectSearchOrdering,
ObjectKindEnum,
ObjectOrder,
useLibraryContext,
useLibraryQuery,
useRspcLibraryContext
} from '@sd/client';
import { useExplorerContext } from '../Explorer/Context';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
import { UseExplorerSettings } from '../Explorer/useExplorer';
import {
createDefaultExplorerSettings,
filePathOrderingKeysSchema,
objectOrderingKeysSchema
} from '../Explorer/store';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useObjectsInfiniteQuery } from '../Explorer/useObjectsInfiniteQuery';
import { usePathsInfiniteQuery } from '../Explorer/usePathsInfiniteQuery';
import { usePageLayoutContext } from '../PageLayout/Context';
export const IconForCategory: Partial<Record<Category, string>> = {
Recents: iconNames.Collection,
@ -54,43 +61,45 @@ export const IconToDescription = {
Trash: 'View all files in your trash'
};
const OBJECT_CATEGORIES: Category[] = ['Recents', 'Favorites'];
export const OBJECT_CATEGORIES: Category[] = ['Recents', 'Favorites'];
// this is a gross function so it's in a separate hook :)
export function useItems(
category: Category,
explorerSettings: UseExplorerSettings<ObjectSearchOrdering>
) {
const settings = explorerSettings.useSettingsSnapshot();
export function useCategoryExplorer(category: Category) {
const rspc = useRspcLibraryContext();
const { library } = useLibraryContext();
const kind = settings.layoutMode === 'media' ? [5, 7] : undefined;
const page = usePageLayoutContext();
const isObjectQuery = OBJECT_CATEGORIES.includes(category);
const objectFilter: ObjectFilterArgs = { category, kind };
const pathsExplorerSettings = useExplorerSettings({
settings: useMemo(() => createDefaultExplorerSettings<FilePathOrder>(), []),
orderingKeys: filePathOrderingKeysSchema
});
const objectsExplorerSettings = useExplorerSettings({
settings: useMemo(() => createDefaultExplorerSettings<ObjectOrder>(), []),
orderingKeys: objectOrderingKeysSchema
});
const explorerSettings = isObjectQuery ? objectsExplorerSettings : pathsExplorerSettings;
const settings = explorerSettings.useSettingsSnapshot();
const take = 10;
const objectFilter: ObjectFilterArgs = {
category,
...(settings.layoutMode === 'media' && {
kind: [ObjectKindEnum.Image, ObjectKindEnum.Video]
})
};
const objectsCount = useLibraryQuery(['search.objectsCount', { filter: objectFilter }]);
const objectsQuery = useInfiniteQuery({
const objectsQuery = useObjectsInfiniteQuery({
enabled: isObjectQuery,
queryKey: [
'search.objects',
{
library_id: library.uuid,
arg: { take: 50, filter: objectFilter }
}
] as const,
queryFn: ({ pageParam: cursor, queryKey }) =>
rspc.client.query([
'search.objects',
{
...queryKey[1].arg,
pagination: cursor ? { cursor: { pub_id: cursor } } : undefined
}
]),
getNextPageParam: (lastPage) => lastPage.cursor ?? undefined
library,
arg: { take, filter: objectFilter },
settings: objectsExplorerSettings
});
const objectsItems = useMemo(
@ -104,25 +113,11 @@ export function useItems(
// TODO: Make a custom double click handler for directories to take users to the location explorer.
// For now it's not needed because folders shouldn't show.
const pathsQuery = useInfiniteQuery({
const pathsQuery = usePathsInfiniteQuery({
enabled: !isObjectQuery,
queryKey: [
'search.paths',
{
library_id: library.uuid,
arg: { take: 50, filter: pathsFilter }
}
] as const,
queryFn: ({ pageParam: cursor, queryKey }) =>
rspc.client.query([
'search.paths',
{
...queryKey[1].arg,
pagination: cursor ? { cursor: { pub_id: cursor } } : undefined
}
]),
getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
onSuccess: () => getExplorerStore().resetNewThumbnails()
library,
arg: { take, filter: pathsFilter },
settings: pathsExplorerSettings
});
const pathsItems = useMemo(
@ -135,17 +130,24 @@ export function useItems(
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
};
const shared = {
loadMore,
scrollRef: page.ref
};
return isObjectQuery
? {
? // eslint-disable-next-line
useExplorer({
items: objectsItems ?? null,
count: objectsCount.data,
query: objectsQuery,
loadMore
}
: {
settings: objectsExplorerSettings,
...shared
})
: // eslint-disable-next-line
useExplorer({
items: pathsItems ?? null,
count: pathsCount.data,
query: pathsQuery,
loadMore
};
settings: pathsExplorerSettings,
...shared
});
}

View file

@ -1,8 +1,8 @@
import { getIcon } from '@sd/assets/util';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import 'react-loading-skeleton/dist/skeleton.css';
import { useSnapshot } from 'valtio';
import { Category, ObjectSearchOrdering } from '@sd/client';
import { Category } from '@sd/client';
import { useIsDark } from '../../../hooks';
import { ExplorerContextProvider } from '../Explorer/Context';
import ContextMenu, { ObjectItems } from '../Explorer/ContextMenu';
@ -10,46 +10,21 @@ import { Conditional } from '../Explorer/ContextMenu/ConditionalItem';
import { Inspector } from '../Explorer/Inspector';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import View from '../Explorer/View';
import {
createDefaultExplorerSettings,
objectOrderingKeysSchema,
useExplorerStore
} from '../Explorer/store';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerStore } from '../Explorer/store';
import { usePageLayoutContext } from '../PageLayout/Context';
import { TopBarPortal } from '../TopBar/Portal';
import Statistics from '../overview/Statistics';
import { Categories } from './Categories';
import { IconForCategory, IconToDescription, useItems } from './data';
import { IconForCategory, IconToDescription, useCategoryExplorer } from './data';
export const Component = () => {
const explorerStore = useExplorerStore();
const isDark = useIsDark();
const page = usePageLayoutContext();
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<ObjectSearchOrdering>({
order: null
}),
[]
),
onSettingsChanged: () => {},
orderingKeys: objectOrderingKeysSchema
});
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
const { items, count, loadMore } = useItems(selectedCategory, explorerSettings);
const explorer = useExplorer({
items,
count,
loadMore,
scrollRef: page.ref,
settings: explorerSettings
});
const explorer = useCategoryExplorer(selectedCategory);
useEffect(() => {
if (!page.ref.current) return;

View file

@ -1,6 +1,6 @@
import { MagnifyingGlass } from 'phosphor-react';
import { Suspense, memo, useDeferredValue, useMemo } from 'react';
import { type FilePathSearchOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client';
import { FilePathOrder, getExplorerItemData, useLibraryQuery } from '@sd/client';
import { type SearchParams, SearchParamsSchema } from '~/app/route-schemas';
import { useZodSearchParams } from '~/hooks';
import Explorer from './Explorer';
@ -27,7 +27,7 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<FilePathSearchOrdering>({
createDefaultExplorerSettings<FilePathOrder>({
order: {
field: 'name',
value: 'Asc'

View file

@ -1,6 +1,6 @@
import { getIcon, iconNames } from '@sd/assets/util';
import { useMemo } from 'react';
import { ObjectSearchOrdering, useLibraryQuery } from '@sd/client';
import { ObjectOrder, useLibraryQuery } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
@ -17,9 +17,8 @@ export const Component = () => {
const explorerData = useLibraryQuery([
'search.objects',
{
filter: {
tags: [tagId]
}
filter: { tags: [tagId] },
take: 100
}
]);
@ -28,7 +27,7 @@ export const Component = () => {
const explorerSettings = useExplorerSettings({
settings: useMemo(
() =>
createDefaultExplorerSettings<ObjectSearchOrdering>({
createDefaultExplorerSettings<ObjectOrder>({
order: null
}),
[]
@ -39,13 +38,10 @@ export const Component = () => {
const explorer = useExplorer({
items: explorerData.data?.items || null,
parent: tag.data
? {
type: 'Tag',
tag: tag.data
}
: undefined,
settings: explorerSettings
settings: explorerSettings,
...(tag.data && {
parent: { type: 'Tag', tag: tag.data }
})
});
return (

View file

@ -21,11 +21,12 @@ export const PathParamsSchema = z.object({ path: z.string().optional() });
export type PathParams = z.infer<typeof PathParamsSchema>;
export const SearchParamsSchema = PathParamsSchema.extend({
take: z.coerce.number().optional(),
take: z.coerce.number().default(100),
order: z
.union([
z.object({ field: z.literal('name'), value: SortOrderSchema }),
z.object({ field: z.literal('sizeInBytes'), value: SortOrderSchema })
z.object({ field: z.literal('dateCreated'), value: SortOrderSchema })
// z.object({ field: z.literal('sizeInBytes'), value: SortOrderSchema })
])
.optional(),
search: z.string().optional()

View file

@ -17,7 +17,7 @@ export function useZodSearchParams<Z extends z.AnyZodObject>(schema: Z) {
typedSearchParams.data as z.infer<Z>,
useCallback(
(
data: z.input<Z> | ((data: z.input<Z>) => z.infer<Z>),
data: z.input<Z> | ((data: z.input<Z>) => z.input<Z>),
navigateOpts?: NavigateOptions
) => {
if (typeof data === 'function') {
@ -26,7 +26,7 @@ export function useZodSearchParams<Z extends z.AnyZodObject>(schema: Z) {
if (!typedPrevParams.success) throw typedPrevParams.errors;
return data(typedPrevParams.data);
return schema.parse(data(typedPrevParams.data));
}, navigateOpts);
} else {
setSearchParams(data as any, navigateOpts);

View file

@ -28,7 +28,7 @@ export type Procedures = {
{ key: "notifications.get", input: never, result: Notification[] } |
{ key: "p2p.nlmState", input: never, result: { [key: string]: LibraryData } } |
{ key: "preferences.get", input: LibraryArgs<null>, result: LibraryPreferences } |
{ key: "search.ephemeralPaths", input: LibraryArgs<NonIndexedPath>, result: NonIndexedFileSystemEntries } |
{ key: "search.ephemeralPaths", input: LibraryArgs<EphemeralPathSearchArgs>, result: NonIndexedFileSystemEntries } |
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.objectsCount", input: LibraryArgs<{ filter?: ObjectFilterArgs }>, result: number } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
@ -129,6 +129,8 @@ export type Composite = "Unknown" | "False" | "General" | "Live"
export type CreateLibraryArgs = { name: LibraryName }
export type CursorOrderItem<T> = { order: SortOrder; data: T }
export type Dimensions = { width: number; height: number }
export type DiskType = "SSD" | "HDD" | "Removable"
@ -137,6 +139,10 @@ export type DoubleClickAction = "openFile" | "quickPreview"
export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined<string> }
export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder }
export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null }
export type Error = { code: ErrorCode; message: string }
/**
@ -160,13 +166,17 @@ export type FileEraserJobInit = { location_id: number; file_path_ids: number[];
export type FilePath = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; device: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null }
export type FilePathCursor = { isDir: boolean; variant: FilePathCursorVariant }
export type FilePathCursorVariant = { none: number[] } | { name: CursorOrderItem<string> } | { dateCreated: CursorOrderItem<string> } | { dateModified: CursorOrderItem<string> } | { dateIndexed: CursorOrderItem<string> } | { object: FilePathObjectCursor }
export type FilePathFilterArgs = { locationId?: number | null; search?: string | null; extension?: string | null; createdAt?: OptionalRange<string>; path?: string | null; object?: ObjectFilterArgs | null }
export type FilePathPagination = { cursor: { pub_id: number[] } } | { offset: number }
export type FilePathObjectCursor = { dateAccessed: CursorOrderItem<string> } | { kind: CursorOrderItem<number> }
export type FilePathSearchArgs = { take?: number | null; order?: FilePathSearchOrdering | null; pagination?: FilePathPagination | null; filter?: FilePathFilterArgs; groupDirectories?: boolean }
export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectOrder }
export type FilePathSearchOrdering = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectSearchOrdering }
export type FilePathSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<FilePathOrder, FilePathCursor> | null; filter?: FilePathFilterArgs; groupDirectories?: boolean }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; device: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null }
@ -247,7 +257,7 @@ export type Location = { id: number; pub_id: number[]; name: string | null; path
*/
export type LocationCreateArgs = { path: string; dry_run: boolean; indexer_rules_ids: number[] }
export type LocationSettings = { explorer: ExplorerSettings<FilePathSearchOrdering> }
export type LocationSettings = { explorer: ExplorerSettings<FilePathOrder> }
/**
* `LocationUpdateArgs` is the argument received from the client using `rspc` to update a location.
@ -280,12 +290,8 @@ export type NodeState = ({ id: string; name: string; p2p_port: number | null; fe
export type NonIndexedFileSystemEntries = { entries: ExplorerItem[]; errors: Error[] }
export type NonIndexedPath = { path: string; withHiddenFiles: boolean; order?: NonIndexedPathOrdering | null }
export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[] }
export type NonIndexedPathOrdering = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder }
/**
* Represents a single notification.
*/
@ -301,15 +307,15 @@ export type NotificationId = { type: "library"; id: [string, number] } | { type:
export type Object = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null }
export type ObjectCursor = { none: number[] } | { dateAccessed: CursorOrderItem<string> } | { kind: CursorOrderItem<number> }
export type ObjectFilterArgs = { favorite?: boolean | null; hidden?: ObjectHiddenFilter; dateAccessed?: MaybeNot<string | null> | null; kind?: number[]; tags?: number[]; category?: Category | null }
export type ObjectHiddenFilter = "exclude" | "include"
export type ObjectPagination = { cursor: { pub_id: number[] } } | { offset: number }
export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder }
export type ObjectSearchArgs = { take?: number | null; order?: ObjectSearchOrdering | null; pagination?: ObjectPagination | null; filter?: ObjectFilterArgs }
export type ObjectSearchOrdering = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder }
export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<ObjectOrder, ObjectCursor> | null; filter?: ObjectFilterArgs }
export type ObjectValidatorArgs = { id: number; path: string }
@ -323,6 +329,8 @@ export type OperatingSystem = "Windows" | "Linux" | "MacOS" | "Ios" | "Android"
export type OptionalRange<T> = { from: T | null; to: T | null }
export type OrderAndPagination<TOrder, TCursor> = { orderOnly: TOrder } | { offset: { offset: number; order: TOrder | null } } | { cursor: TCursor }
export type Orientation = "Normal" | "MirroredHorizontal" | "CW90" | "MirroredVertical" | "MirroredHorizontalAnd270CW" | "MirroredHorizontalAnd90CW" | "CW180" | "CW270"
/**