first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

541
src/redux/fileManagerSlice.ts Executable file
View File

@@ -0,0 +1,541 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { FileResponse, FileThumbResponse, FileType, ListResponse } from "../api/explorer.ts";
import { AppError, Response } from "../api/request.ts";
import { Capacity } from "../api/user.ts";
import { ListViewColumnSetting } from "../component/FileManager/Explorer/ListView/Column.tsx";
import SessionManager, { UserSettings } from "../session";
import { SearchParam } from "../util/uri.ts";
export const ContextMenuTypes = {
empty: "empty",
new: "new",
file: "file",
searchResult: "searchResult",
};
export interface SingleManager {
path?: string;
previous_path?: string;
pure_path?: string;
pure_path_with_category?: string;
path_root?: string;
path_root_with_category?: string;
path_elements?: string[];
search_params?: SearchParam;
showError: boolean;
error?: Response<any>;
loading: boolean;
list?: ListResponse;
tree: {
[key: string]: FmTreeItem;
};
current_fs?: string;
capacity?: Capacity;
selected: {
[key: string]: FileResponse;
};
// View
layout?: string;
showThumb: boolean;
pageSize: number;
sortBy?: string;
sortDirection?: string;
multiSelectHovered: {
[key: string]: boolean;
};
galleryWidth: number;
// Context Menu
contextMenuOpen?: boolean;
contextMenuPos?: { x: number; y: number };
contextMenuType?: string;
contextMenuTargets?: {
[key: string]: FileResponse;
};
contextMenuTargetFm?: number;
// Dialogs
// Delete file dialog
deleteFileModalOpen?: boolean;
deleteFileModalSelected?: FileResponse[];
deleteFileModalPromiseId?: string;
deleteFileModalLoading?: boolean;
// Rename file dialog
renameFileModalOpen?: boolean;
renameFileModalSelected?: FileResponse;
renameFileModalPromiseId?: string;
renameFileModalLoading?: boolean;
renameFileModalError?: string;
// List view
listViewColumns: ListViewColumnSetting[];
}
export const Layouts = {
grid: "grid",
list: "list",
gallery: "gallery",
};
export interface FmTreeItem {
file?: FileResponse;
thumb?: ThumbCache;
children?: string[];
}
export interface ThumbCache {
src: string;
expires?: string;
}
export interface FileManagerArgsBase<T> {
index: number;
value: T;
}
const defaultManagerValue = {
loading: false,
showError: false,
tree: {},
root_meta: {},
selected: {},
pageSize: SessionManager.getWithFallback(UserSettings.PageSize),
layout: SessionManager.getWithFallback(UserSettings.Layout),
showThumb: SessionManager.getWithFallback(UserSettings.ShowThumb),
sortBy: SessionManager.getWithFallback(UserSettings.SortBy),
sortDirection: SessionManager.getWithFallback(UserSettings.SortDirection),
listViewColumns: SessionManager.getWithFallback(UserSettings.ListViewColumns),
multiSelectHovered: {},
galleryWidth: window.matchMedia("(max-width: 600px)")?.matches
? 110
: SessionManager.getWithFallback(UserSettings.GalleryWidth),
};
const initialState: [SingleManager, SingleManager] = [defaultManagerValue, defaultManagerValue];
export const fileManagerSlice = createSlice({
name: "fileManagerSlice",
initialState,
reducers: {
setGalleryWidth: (state, action: PayloadAction<FileManagerArgsBase<number>>) => {
state[action.payload.index].galleryWidth = action.payload.value;
},
setListViewColumns: (state, action: PayloadAction<ListViewColumnSetting[]>) => {
state.forEach((_fm, index) => {
state[index].listViewColumns = action.payload;
});
},
removeThumbCache: (state, action: PayloadAction<FileManagerArgsBase<string[]>>) => {
action.payload.value.forEach((path) => {
state[action.payload.index].tree[path].thumb = undefined;
});
},
resetFileManager: (state, action: PayloadAction<number>) => {
state[action.payload].path = undefined;
state[action.payload].previous_path = undefined;
state[action.payload].pure_path = undefined;
state[action.payload].pure_path_with_category = undefined;
state[action.payload].search_params = undefined;
state[action.payload].path_root = undefined;
state[action.payload].path_root_with_category = undefined;
state[action.payload].path_elements = undefined;
state[action.payload].showError = false;
state[action.payload].error = undefined;
state[action.payload].list = undefined;
state[action.payload].selected = {};
state[action.payload].contextMenuOpen = false;
state[action.payload].deleteFileModalOpen = state[action.payload].deleteFileModalOpen ? false : undefined;
state[action.payload].renameFileModalOpen = state[action.payload].renameFileModalOpen ? false : undefined;
},
setFmError: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const e = action.payload.value;
state[action.payload.index].showError = !!e;
if (e instanceof AppError) {
state[action.payload.index].error = e.ErrorResponse();
} else if (e instanceof Error) {
state[action.payload.index].error = {
msg: e.message,
code: -1,
data: undefined,
};
}
},
setFmLoading: (state, action: PayloadAction<FileManagerArgsBase<boolean>>) => {
state[action.payload.index].loading = action.payload.value;
},
applyListResponse: (state, action: PayloadAction<FileManagerArgsBase<ListResponse>>) => {
state[action.payload.index].list = action.payload.value;
},
appendListResponse: (state, action: PayloadAction<FileManagerArgsBase<ListResponse>>) => {
const newList = [...(state[action.payload.index].list?.files || []), ...action.payload.value.files];
state[action.payload.index].list = {
...action.payload.value,
files: newList,
};
},
setFileList: (state, action: PayloadAction<FileManagerArgsBase<FileResponse[]>>) => {
const fm = state[action.payload.index];
if (fm.list) {
fm.list.files = action.payload.value;
}
},
appendTreeCache: (state, action: PayloadAction<FileManagerArgsBase<[FileResponse[], string | undefined]>>) => {
const path = action.payload.value[1];
const fm = state[action.payload.index];
action.payload.value[0].forEach((file) => {
if (!fm.tree[file.path]) {
fm.tree[file.path] = {};
}
fm.tree[file.path].file = file;
});
if (!path) return;
if (!fm.tree[path]) {
fm.tree[path] = {};
}
fm.tree[path].children = [
...new Set([
...(fm.tree[path].children ?? []),
...action.payload.value[0].filter((t) => t.type == FileType.folder).map((file) => file.path),
]),
];
},
removeTreeCache: (state, action: PayloadAction<FileManagerArgsBase<string[]>>) => {
const path = action.payload.value;
const fm = state[action.payload.index];
// Recursively delete children
const deleteChildren = (p: string) => {
if (fm.tree[p]?.children) {
fm.tree[p].children?.forEach((c) => {
deleteChildren(c);
delete state[action.payload.index].tree[c];
});
}
};
path.forEach((p) => {
deleteChildren(p);
delete state[action.payload.index].tree[p];
// Delete path from parent's child
const parentPath = p.substring(0, p.lastIndexOf("/"));
if (parentPath && state[action.payload.index].tree[parentPath]) {
state[action.payload.index].tree[parentPath].children = state[action.payload.index].tree[
parentPath
].children?.filter((pa) => pa != p);
}
});
},
setPathProps: (
state,
action: PayloadAction<
FileManagerArgsBase<{
path: string;
path_elements: string[];
path_root: string;
current_fs: string;
pure_path: string;
pure_path_with_category: string;
path_root_with_category: string;
search_params?: SearchParam;
}>
>,
) => {
if (state[action.payload.index].path != action.payload.value.path) {
state[action.payload.index].previous_path = state[action.payload.index].path;
}
state[action.payload.index].path = action.payload.value.path;
state[action.payload.index].path_elements = action.payload.value.path_elements;
state[action.payload.index].path_root = action.payload.value.path_root;
state[action.payload.index].current_fs = action.payload.value.current_fs;
state[action.payload.index].pure_path_with_category = action.payload.value.pure_path_with_category;
state[action.payload.index].pure_path = action.payload.value.pure_path;
state[action.payload.index].path_root_with_category = action.payload.value.path_root_with_category;
state[action.payload.index].search_params = action.payload.value.search_params;
},
setLayout: (state, action: PayloadAction<FileManagerArgsBase<string>>) => {
state[action.payload.index].layout = action.payload.value;
},
setShowThumb: (state, action: PayloadAction<FileManagerArgsBase<boolean>>) => {
state[action.payload.index].showThumb = action.payload.value;
},
setPageSize: (state, action: PayloadAction<FileManagerArgsBase<number>>) => {
const index = action.payload.index;
state[index].pageSize = action.payload.value;
if (state[index].list?.pagination.next_token) {
// @ts-ignore
state[index].list.pagination.next_token = undefined;
}
},
setSortOption: (state, action: PayloadAction<FileManagerArgsBase<[string, string]>>) => {
const index = action.payload.index;
state[index].sortBy = action.payload.value[0];
state[index].sortDirection = action.payload.value[1];
if (state[index].list?.pagination.next_token) {
// @ts-ignore
state[index].list.pagination.next_token = undefined;
}
},
setThumbCache: (state, action: PayloadAction<FileManagerArgsBase<[string, FileThumbResponse]>>) => {
const index = action.payload.index;
const path = action.payload.value[0];
const thumb = action.payload.value[1];
if (!state[index].tree[path]) {
state[index].tree[path] = {};
}
state[index].tree[path].thumb = {
src: thumb?.url,
expires: thumb?.expires,
};
},
clearSessionCache: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const index = action.payload.index;
state[index].tree = {};
state[index].capacity = undefined;
},
setCapacity: (state, action: PayloadAction<FileManagerArgsBase<Capacity>>) => {
const index = action.payload.index;
state[index].capacity = action.payload.value;
},
setPage: (state, action: PayloadAction<FileManagerArgsBase<number>>) => {
const s = state[action.payload.index];
if (s.list?.pagination) {
s.list.pagination.page = action.payload.value;
}
},
addSelected: (state, action: PayloadAction<FileManagerArgsBase<FileResponse[]>>) => {
const index = action.payload.index;
action.payload.value.forEach((file) => {
state[index].selected[file.path] = file;
});
},
removeSelected: (state, action: PayloadAction<FileManagerArgsBase<string>>) => {
const index = action.payload.index;
delete state[index].selected[action.payload.value];
},
clearSelected: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const index = action.payload.index;
state[index].selected = {};
},
setSelected: (state, action: PayloadAction<FileManagerArgsBase<FileResponse[]>>) => {
const index = action.payload.index;
state[index].selected = {};
action.payload.value.forEach((file) => {
state[index].selected[file.path] = file;
});
},
setMultiSelectHovered: (state, action: PayloadAction<FileManagerArgsBase<[string, boolean]>>) => {
const index = action.payload.index;
const [path, hovered] = action.payload.value;
if (!hovered) {
delete state[index].multiSelectHovered[path];
} else {
state[index].multiSelectHovered = {};
state[index].multiSelectHovered[path] = hovered;
}
},
clearMultiSelectHovered: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const index = action.payload.index;
state[index].multiSelectHovered = {};
},
setContextMenu: (
state,
action: PayloadAction<
FileManagerArgsBase<{
open: boolean;
pos: { x: number; y: number };
type: string;
targets: { [key: string]: FileResponse } | undefined;
fmIndex: number;
}>
>,
) => {
const index = action.payload.index;
const { open, pos, type, targets, fmIndex } = action.payload.value;
state[index].contextMenuOpen = open;
state[index].contextMenuPos = pos;
state[index].contextMenuType = type;
state[index].contextMenuTargets = targets;
state[index].contextMenuTargetFm = fmIndex;
},
closeContextMenu: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const index = action.payload.index;
state[index].contextMenuOpen = false;
},
setFileDeleteModal: (
state,
action: PayloadAction<FileManagerArgsBase<[boolean, FileResponse[] | undefined, string | undefined, boolean]>>,
) => {
const index = action.payload.index;
const [open, selected, promiseId, loading] = action.payload.value;
state[index].deleteFileModalOpen = open;
state[index].deleteFileModalSelected = selected;
state[index].deleteFileModalPromiseId = promiseId;
state[index].deleteFileModalLoading = loading;
},
setFileDeleteModalLoading: (state, action: PayloadAction<FileManagerArgsBase<boolean>>) => {
const index = action.payload.index;
state[index].deleteFileModalLoading = action.payload.value;
},
setRenameFileModal: (
state,
action: PayloadAction<
FileManagerArgsBase<{
open: boolean;
selected: FileResponse | undefined;
promiseId: string | undefined;
loading: boolean | undefined;
error?: string;
}>
>,
) => {
const index = action.payload.index;
state[index].renameFileModalOpen = action.payload.value.open;
state[index].renameFileModalSelected = action.payload.value.selected;
state[index].renameFileModalPromiseId = action.payload.value.promiseId;
state[index].renameFileModalLoading = action.payload.value.loading;
state[index].renameFileModalError = action.payload.value.error;
},
closeRenameFileModal: (state, action: PayloadAction<FileManagerArgsBase<any>>) => {
const index = action.payload.index;
state[index].renameFileModalOpen = false;
},
setRenameFileModalLoading: (state, action: PayloadAction<FileManagerArgsBase<boolean>>) => {
const index = action.payload.index;
state[index].renameFileModalLoading = action.payload.value;
},
setRenameFileModalError: (state, action: PayloadAction<FileManagerArgsBase<string | undefined>>) => {
const index = action.payload.index;
state[index].renameFileModalError = action.payload.value;
},
fileUpdated: (
state,
action: PayloadAction<
FileManagerArgsBase<
{
oldPath: string;
file: FileResponse;
includeMetadata?: boolean;
}[]
>
>,
) => {
for (const fmIndex of [0, 1]) {
const fm = state[fmIndex];
action.payload.value.forEach((v) => {
const file = v.file;
const oldPath = v.oldPath;
const pathChanged = oldPath != file.path;
let interestFields: {
[key: string]: any;
} = {
name: file.name,
updated_at: file.updated_at,
size: file.size,
created_at: file.created_at,
id: file.id,
path: file.path,
shared: file.shared,
primary_entity: file.primary_entity,
};
if (v.includeMetadata) {
interestFields = { ...interestFields, metadata: file.metadata };
}
if (fm.list) {
const list = fm.list.files;
const idx = list.findIndex((f) => f.path == oldPath);
if (idx >= 0) {
list[idx] = {
...list[idx],
...interestFields,
};
}
}
if (fm.selected[oldPath]) {
fm.selected[file.path] = {
...fm.selected[oldPath],
...interestFields,
};
fm.selected[file.path].path = file.path;
if (pathChanged) delete fm.selected[oldPath];
}
if (fm.contextMenuTargets?.[oldPath]) {
fm.contextMenuTargets[file.path] = {
...fm.contextMenuTargets[oldPath],
...interestFields,
};
fm.contextMenuTargets[file.path].path = file.path;
if (pathChanged) delete fm.contextMenuTargets[oldPath];
}
const treeCache = fm.tree[oldPath];
if (treeCache && treeCache.file) {
if (pathChanged) treeCache.children = [];
treeCache.thumb = undefined;
treeCache.file = {
...treeCache.file,
...interestFields,
};
}
fm.tree[file.path] = treeCache;
if (pathChanged) delete fm.tree[oldPath];
// change path from parent's child
if (pathChanged) {
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
if (parentPath && fm.tree[parentPath]) {
fm.tree[parentPath].children = state[fmIndex].tree[parentPath].children?.map((pa) =>
pa == oldPath ? file.path : pa,
);
}
}
});
}
},
},
});
export default fileManagerSlice.reducer;
export const {
removeThumbCache,
clearMultiSelectHovered,
setGalleryWidth,
setListViewColumns,
resetFileManager,
fileUpdated,
setRenameFileModalError,
setRenameFileModal,
closeRenameFileModal,
setRenameFileModalLoading,
removeTreeCache,
setFileList,
setFileDeleteModalLoading,
setFileDeleteModal,
closeContextMenu,
setContextMenu,
setMultiSelectHovered,
setSelected,
addSelected,
removeSelected,
clearSelected,
setPage,
setCapacity,
clearSessionCache,
setPathProps,
appendTreeCache,
appendListResponse,
applyListResponse,
setFmError,
setFmLoading,
setLayout,
setShowThumb,
setPageSize,
setSortOption,
setThumbCache,
} = fileManagerSlice.actions;

868
src/redux/globalStateSlice.ts Executable file
View File

@@ -0,0 +1,868 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {
ConflictDetail,
DirectLink,
FileResponse,
Share,
StoragePolicy,
Viewer,
ViewerSession,
} from "../api/explorer.ts";
import { Response } from "../api/request.ts";
import { User } from "../api/user.ts";
import { SelectType } from "../component/Uploader/core";
import SessionManager, { UserSettings } from "../session";
export interface DndState {
dragging?: boolean;
draggingWithSelected?: boolean;
}
export interface ImageViewerState extends GeneralViewerState {
index?: number;
exts: string[];
}
export interface ImageEditorState extends GeneralViewerState {}
export interface GeneralViewerState {
open: boolean;
file: FileResponse;
version?: string;
}
export interface ViewerSelectorState extends GeneralViewerState {
viewers: Viewer[];
entitySize: number;
}
export interface WopiViewerState extends GeneralViewerState {
src: string;
session: ViewerSession;
}
export interface DrawIOViewerState extends GeneralViewerState {
host?: string;
}
export interface CustomViewerState extends GeneralViewerState {
url: string;
}
export interface MusicPlayerState {
files: FileResponse[];
startIndex: number;
version?: string;
}
export const CreateNewDialogType = {
folder: "folder",
file: "file",
};
export interface UploadProgressTotal {
processedSize: number;
totalSize: number;
}
export interface DialogSelectOption {
name: string;
description: string;
value: any;
}
export interface GlobalStateSlice {
loading: {
headlessFrame: boolean;
};
preferredTheme?: string;
drawerOpen: boolean;
mobileDrawerOpen?: boolean;
darkMode?: boolean;
drawerWidth: number;
userInfoCache: {
[key: string]: User;
};
// Others
pinedGeneration: number;
// Dialogs
// Aggregated error dialog
aggregatedErrorDialogOpen?: boolean;
aggregatedError?: Response<any>;
aggregatedErrorFile?: {
[key: string]: FileResponse;
};
// Lock conflict dialog
lockConflictDialogOpen?: boolean;
lockConflictError?: Response<ConflictDetail[]>;
lockConflictFile?: {
[key: string]: FileResponse;
};
lockConflictPromiseId?: string;
// Confirmation dialog
confirmDialogOpen?: boolean;
confirmDialogMessage?: string;
confirmPromiseId?: string;
// Pin file dialog
pinFileDialogOpen?: boolean;
pinFileUri?: string;
// path selection dialog
pathSelectDialogOpen?: boolean;
pathSelectDialogVariant?: string;
pathSelectAllowedFs?: string[];
pathSelectPromiseId?: string;
pathSelectInitialPath?: string;
// Tags dialog
tagsDialogOpen?: boolean;
tagsDialogFile?: FileResponse[];
// Change icon dialog
changeIconDialogOpen?: boolean;
changeIconDialogFile?: FileResponse[];
// Share link dialog
shareLinkDialogOpen?: boolean;
shareLinkDialogFile?: FileResponse;
shareLinkDialogShare?: Share;
// Version control dialog
versionControlDialogOpen?: boolean;
versionControlDialogFile?: FileResponse;
versionControlHighlight?: string;
// Manage share link dialog
manageShareDialogOpen?: boolean;
manageShareDialogFile?: FileResponse;
// Stale version action dialog
staleVersionDialogOpen?: boolean;
staleVersionUri?: string;
staleVersionPromiseId?: string;
// Save as dialog
saveAsDialogOpen?: boolean;
saveAsInitialName?: string;
saveAsPromiseId?: string;
// Create new dialog
createNewDialogOpen?: boolean;
createNewDialogType?: string;
createNewDialogDefault?: string;
createNewPromiseId?: string;
createNewDialogFmIndex?: number;
// Select option dialog
selectOptionDialogOpen?: boolean;
selectOptionDialogOptions?: DialogSelectOption[];
selectOptionPromiseId?: string;
selectOptionTitle?: string;
// Batch download log dialog
batchDownloadLogDialogOpen?: boolean;
batchDownloadLogDialogId?: string;
batchDownloadLogDialogLogs?: {
[key: string]: string;
};
// Create archive dialog
createArchiveDialogOpen?: boolean;
createArchiveDialogFiles?: FileResponse[];
// Extract archive dialog
extractArchiveDialogOpen?: boolean;
extractArchiveDialogFile?: FileResponse;
extractArchiveDialogMask?: string[];
extractArchiveDialogEncoding?: string;
// Remote download dialog
remoteDownloadDialogOpen?: boolean;
remoteDownloadDialogFile?: FileResponse;
// List view column settings dialog
listViewColumnSettingDialogOpen?: boolean;
// Direct Link result dialog
directLinkDialogOpen?: boolean;
directLinkRes?: DirectLink[];
// Direct Link management dialog
directLinkManagementDialogOpen?: boolean;
directLinkManagementDialogFile?: FileResponse;
directLinkHighlight?: string;
// DnD
dndState: DndState;
// Share info cache
shareInfo: {
[key: string]: Share;
};
// Sidebar
sidebarOpen?: boolean;
sidebarTarget?: FileResponse | string;
// Viewers
imageViewer?: ImageViewerState;
imageEditor?: ImageEditorState;
photopeaViewer?: GeneralViewerState;
wopiViewer?: WopiViewerState;
codeViewer?: GeneralViewerState;
drawIOViewer?: DrawIOViewerState;
markdownViewer?: GeneralViewerState;
videoViewer?: GeneralViewerState;
pdfViewer?: GeneralViewerState;
customViewer?: CustomViewerState;
epubViewer?: GeneralViewerState;
musicPlayer?: MusicPlayerState;
excalidrawViewer?: GeneralViewerState;
archiveViewer?: GeneralViewerState;
// Viewer selector
viewerSelector?: ViewerSelectorState;
// Uploader
uploadFileSignal?: number;
uploadFolderSignal?: number;
uploadProgress?: UploadProgressTotal;
uploadTaskCount?: number;
uploadTaskListOpen?: boolean;
uploadFromClipboardDialogOpen?: boolean;
uploadRawFiles?: File[];
uploadRawPromiseId?: string[];
policyOptionCache?: StoragePolicy[];
// Search popup
searchPopupOpen?: boolean;
// Advance search
advanceSearchOpen?: boolean;
advanceSearchBasePath?: string;
advanceSearchInitialNameCondition?: string[];
// Share README
shareReadmeDetect?: number;
shareReadmeOpen?: boolean;
shareReadmeTarget?: FileResponse;
}
let preferred_theme: string | undefined = undefined;
try {
preferred_theme = SessionManager.currentLogin().user.preferred_theme;
} catch (e) {}
const initialState: GlobalStateSlice = {
loading: {
headlessFrame: false,
},
pinedGeneration: 0,
darkMode: SessionManager.get(UserSettings.PreferredDarkMode),
preferredTheme: preferred_theme,
drawerOpen: true,
drawerWidth: SessionManager.getWithFallback(UserSettings.DrawerWidth),
userInfoCache: {},
dndState: {},
shareInfo: {},
};
export const globalStateSlice = createSlice({
name: "globalState",
initialState,
reducers: {
setUploadRawFiles: (
state,
action: PayloadAction<{
files: File[];
promiseId: string[];
}>,
) => {
state.uploadRawFiles = action.payload.files ?? [];
state.uploadRawPromiseId = action.payload.promiseId ?? [];
},
setShareReadmeDetect: (state, action: PayloadAction<boolean>) => {
state.shareReadmeDetect = action.payload ? (state.shareReadmeDetect ?? 0) + 1 : 0;
},
setShareReadmeOpen: (state, action: PayloadAction<{ open: boolean; target?: FileResponse }>) => {
state.shareReadmeOpen = action.payload.open;
state.shareReadmeTarget = action.payload.target;
},
closeShareReadme: (state) => {
state.shareReadmeOpen = false;
},
setDirectLinkManagementDialog: (
state,
action: PayloadAction<{ open: boolean; file?: FileResponse; highlight?: string }>,
) => {
state.directLinkManagementDialogOpen = action.payload.open;
state.directLinkManagementDialogFile = action.payload.file;
state.directLinkHighlight = action.payload.highlight;
},
closeDirectLinkManagementDialog: (state) => {
state.directLinkManagementDialogOpen = false;
state.directLinkManagementDialogFile = undefined;
state.directLinkHighlight = undefined;
},
setMobileDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.mobileDrawerOpen = action.payload;
},
setDirectLinkDialog: (state, action: PayloadAction<{ open: boolean; res?: DirectLink[] }>) => {
state.directLinkDialogOpen = action.payload.open;
state.directLinkRes = action.payload.res;
},
closeDirectLinkDialog: (state) => {
state.directLinkDialogOpen = false;
},
setListViewColumnSettingDialog: (state, action: PayloadAction<boolean>) => {
state.listViewColumnSettingDialogOpen = action.payload;
},
setAdvanceSearch: (
state,
action: PayloadAction<{
open: boolean;
basePath?: string;
nameCondition?: string[];
}>,
) => {
state.advanceSearchOpen = action.payload.open;
state.advanceSearchBasePath = action.payload.basePath;
state.advanceSearchInitialNameCondition = action.payload.nameCondition;
},
closeAdvanceSearch: (state) => {
state.advanceSearchOpen = false;
},
setSearchPopup: (state, action: PayloadAction<boolean>) => {
state.searchPopupOpen = action.payload;
},
setRemoteDownloadDialog: (state, action: PayloadAction<{ open: boolean; file?: FileResponse }>) => {
state.remoteDownloadDialogOpen = action.payload.open;
state.remoteDownloadDialogFile = action.payload.file;
},
closeRemoteDownloadDialog: (state) => {
state.remoteDownloadDialogOpen = false;
},
setPolicyOptionCache: (state, action: PayloadAction<StoragePolicy[] | undefined>) => {
state.policyOptionCache = action.payload;
},
resetDialogs: (state) => {
state.aggregatedErrorDialogOpen = state.aggregatedErrorDialogOpen ? false : undefined;
state.pathSelectDialogOpen = state.pathSelectDialogOpen ? false : undefined;
state.tagsDialogOpen = state.tagsDialogOpen ? false : undefined;
state.changeIconDialogOpen = state.changeIconDialogOpen ? false : undefined;
state.shareLinkDialogOpen = state.shareLinkDialogOpen ? false : undefined;
state.versionControlDialogOpen = state.versionControlDialogOpen ? false : undefined;
state.manageShareDialogOpen = state.manageShareDialogOpen ? false : undefined;
state.createNewDialogOpen = state.createNewDialogOpen ? false : undefined;
state.selectOptionDialogOpen = state.selectOptionDialogOpen ? false : undefined;
state.batchDownloadLogDialogOpen = state.batchDownloadLogDialogOpen ? false : undefined;
state.createArchiveDialogOpen = state.createArchiveDialogOpen ? false : undefined;
state.extractArchiveDialogOpen = state.extractArchiveDialogOpen ? false : undefined;
// reset all viewers
state.imageViewer = undefined;
state.imageEditor = undefined;
state.photopeaViewer = undefined;
state.wopiViewer = undefined;
state.codeViewer = undefined;
state.drawIOViewer = undefined;
state.markdownViewer = undefined;
state.videoViewer = undefined;
state.pdfViewer = undefined;
state.customViewer = undefined;
state.epubViewer = undefined;
state.excalidrawViewer = undefined;
state.archiveViewer = undefined;
},
setExtractArchiveDialog: (
state,
action: PayloadAction<{ open: boolean; file?: FileResponse; mask?: string[]; encoding?: string }>,
) => {
state.extractArchiveDialogOpen = action.payload.open;
state.extractArchiveDialogFile = action.payload.file;
state.extractArchiveDialogMask = action.payload.mask;
state.extractArchiveDialogEncoding = action.payload.encoding;
},
closeExtractArchiveDialog: (state) => {
state.extractArchiveDialogOpen = false;
state.extractArchiveDialogMask = undefined;
state.extractArchiveDialogEncoding = undefined;
},
setCreateArchiveDialog: (
state,
action: PayloadAction<{
open: boolean;
files: FileResponse[];
}>,
) => {
state.createArchiveDialogOpen = action.payload.open;
state.createArchiveDialogFiles = action.payload.files;
},
closeCreateArchiveDialog: (state) => {
state.createArchiveDialogOpen = false;
},
setBatchDownloadLogDialog: (
state,
action: PayloadAction<{
open: boolean;
id: string;
}>,
) => {
state.batchDownloadLogDialogOpen = action.payload.open;
state.batchDownloadLogDialogId = action.payload.id;
},
closeBatchDownloadLogDialog: (state) => {
state.batchDownloadLogDialogOpen = false;
},
setBatchDownloadLog: (
state,
action: PayloadAction<{
id: string;
logs: string;
}>,
) => {
if (!state.batchDownloadLogDialogLogs) {
state.batchDownloadLogDialogLogs = {};
}
state.batchDownloadLogDialogLogs[action.payload.id] = action.payload.logs;
},
setSelectOptionDialog: (
state,
action: PayloadAction<{
open: boolean;
options?: DialogSelectOption[];
promiseId: string;
title?: string;
}>,
) => {
state.selectOptionDialogOpen = action.payload.open;
state.selectOptionDialogOptions = action.payload.options;
state.selectOptionPromiseId = action.payload.promiseId;
state.selectOptionTitle = action.payload.title;
},
closeSelectOptionDialog: (state) => {
state.selectOptionDialogOpen = false;
},
setUploadFromClipboardDialog: (state, action: PayloadAction<boolean>) => {
state.uploadFromClipboardDialogOpen = action.payload;
},
openUploadTaskList: (state) => {
state.uploadTaskListOpen = true;
},
closeUploadTaskList: (state) => {
state.uploadTaskListOpen = false;
},
setUploadProgress: (state, action: PayloadAction<{ progress: UploadProgressTotal; count: number }>) => {
state.uploadProgress = action.payload.progress;
state.uploadTaskCount = action.payload.count;
},
selectForUpload(state, action: PayloadAction<{ type: SelectType }>) {
if (action.payload.type === SelectType.File) {
state.uploadFileSignal = (state.uploadFileSignal ?? 0) + 1;
} else {
state.uploadFolderSignal = (state.uploadFolderSignal ?? 0) + 1;
}
},
setCreateNewDialog: (
state,
action: PayloadAction<{
open: boolean;
type?: string;
default?: string;
promiseId?: string;
fmIndex: number;
}>,
) => {
state.createNewDialogOpen = action.payload.open;
state.createNewDialogType = action.payload.type;
state.createNewDialogDefault = action.payload.default;
state.createNewPromiseId = action.payload.promiseId;
state.createNewDialogFmIndex = action.payload.fmIndex;
},
closeCreateNewDialog: (state) => {
state.createNewDialogOpen = false;
},
setMusicPlayer: (state, action: PayloadAction<MusicPlayerState>) => {
state.musicPlayer = action.payload;
},
closeMusicPlayer: (state) => {
state.musicPlayer = undefined;
},
setEpubViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.epubViewer = action.payload;
},
closeEpubViewer: (state) => {
state.epubViewer && (state.epubViewer.open = false);
},
setCustomViewer: (state, action: PayloadAction<CustomViewerState>) => {
state.customViewer = action.payload;
},
closeCustomViewer: (state) => {
state.customViewer && (state.customViewer.open = false);
},
setPdfViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.pdfViewer = action.payload;
},
closePdfViewer: (state) => {
state.pdfViewer && (state.pdfViewer.open = false);
},
setVideoViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.videoViewer = action.payload;
},
closeVideoViewer: (state) => {
state.videoViewer && (state.videoViewer.open = false);
},
setMarkdownViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.markdownViewer = action.payload;
},
closeMarkdownViewer: (state) => {
state.markdownViewer && (state.markdownViewer.open = false);
},
setExcalidrawViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.excalidrawViewer = action.payload;
},
closeExcalidrawViewer: (state) => {
state.excalidrawViewer && (state.excalidrawViewer.open = false);
},
setArchiveViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.archiveViewer = action.payload;
},
closeArchiveViewer: (state) => {
state.archiveViewer && (state.archiveViewer.open = false);
},
addShareInfo: (state, action: PayloadAction<{ info: Share; id: string }>) => {
state.shareInfo[action.payload.id] = action.payload.info;
},
setHeadlessFrameLoading: (state, action: PayloadAction<boolean>) => {
state.loading.headlessFrame = action.payload;
},
setPreferredTheme: (state, action: PayloadAction<string>) => {
state.preferredTheme = action.payload;
},
setDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.drawerOpen = action.payload;
},
setDrawerWidth: (state, action: PayloadAction<number>) => {
state.drawerWidth = action.payload;
},
setDarkMode: (state, action: PayloadAction<boolean | undefined>) => {
state.darkMode = action.payload;
},
setUserInfoCache: (state, action: PayloadAction<[string, User]>) => {
state.userInfoCache[action.payload[0]] = action.payload[1];
},
setAggregatedErrorDialog: (
state,
action: PayloadAction<{
open: boolean;
error?: Response<any>;
files?: { [key: string]: FileResponse };
}>,
) => {
state.aggregatedErrorDialogOpen = action.payload.open;
state.aggregatedError = action.payload.error;
state.aggregatedErrorFile = action.payload.files;
},
closeAggregatedErrorDialog: (state) => {
state.aggregatedErrorDialogOpen = false;
},
setLockConflictDialog: (
state,
action: PayloadAction<{
open: boolean;
error?: Response<ConflictDetail[]>;
files?: { [key: string]: FileResponse };
promiseId?: string;
}>,
) => {
state.lockConflictDialogOpen = action.payload.open;
state.lockConflictError = action.payload.error;
state.lockConflictFile = action.payload.files;
state.lockConflictPromiseId = action.payload.promiseId;
},
closeLockConflictDialog: (state) => {
state.lockConflictDialogOpen = false;
},
updateLockConflicts: (state, action: PayloadAction<Response<ConflictDetail[]>>) => {
state.lockConflictError = action.payload;
},
setConfirmDialog: (
state,
action: PayloadAction<{
open: boolean;
message?: string;
promiseId?: string;
}>,
) => {
state.confirmDialogOpen = action.payload.open;
state.confirmDialogMessage = action.payload.message;
state.confirmPromiseId = action.payload.promiseId;
},
closeConfirmDialog: (state) => {
state.confirmDialogOpen = false;
},
increasePinedGeneration: (state) => {
state.pinedGeneration += 1;
},
setPinFileDialog: (state, action: PayloadAction<{ open: boolean; uri?: string }>) => {
state.pinFileDialogOpen = action.payload.open;
state.pinFileUri = action.payload.uri;
},
closePinFileDialog: (state) => {
state.pinFileDialogOpen = false;
},
setDragging: (state, action: PayloadAction<DndState>) => {
state.dndState.dragging = action.payload.dragging;
state.dndState.draggingWithSelected = action.payload.draggingWithSelected;
},
setPathSelectionDialog: (
state,
action: PayloadAction<{
open: boolean;
variant: string;
promiseId: string;
initialPath?: string;
}>,
) => {
state.pathSelectDialogOpen = action.payload.open;
state.pathSelectDialogVariant = action.payload.variant;
state.pathSelectPromiseId = action.payload.promiseId;
state.pathSelectInitialPath = action.payload.initialPath;
},
closePathSelectionDialog: (state) => {
state.pathSelectDialogOpen = false;
},
setTagsDialog: (state, action: PayloadAction<{ open: boolean; file?: FileResponse[] }>) => {
state.tagsDialogOpen = action.payload.open;
state.tagsDialogFile = action.payload.file;
},
closeTagsDialog: (state) => {
state.tagsDialogOpen = false;
},
setChangeIconDialog: (state, action: PayloadAction<{ open: boolean; file?: FileResponse[] }>) => {
state.changeIconDialogOpen = action.payload.open;
state.changeIconDialogFile = action.payload.file;
},
closeChangeIconDialog: (state) => {
state.changeIconDialogOpen = false;
},
setShareLinkDialog: (
state,
action: PayloadAction<{
open: boolean;
file?: FileResponse;
share?: Share;
}>,
) => {
state.shareLinkDialogOpen = action.payload.open;
state.shareLinkDialogFile = action.payload.file;
state.shareLinkDialogShare = action.payload.share;
},
closeShareLinkDialog: (state) => {
state.shareLinkDialogOpen = false;
},
setSidebar: (state, action: PayloadAction<{ open: boolean; target?: FileResponse | string }>) => {
state.sidebarOpen = action.payload.open;
state.sidebarTarget = action.payload.target;
},
closeSidebar: (state) => {
state.sidebarOpen = false;
},
setVersionControlDialog: (
state,
action: PayloadAction<{
open: boolean;
file?: FileResponse;
highlight?: string;
}>,
) => {
state.versionControlDialogOpen = action.payload.open;
state.versionControlDialogFile = action.payload.file;
state.versionControlHighlight = action.payload.highlight;
},
closeVersionControlDialog: (state) => {
state.versionControlDialogOpen = false;
},
setManageShareDialog: (state, action: PayloadAction<{ open: boolean; file: FileResponse }>) => {
state.manageShareDialogOpen = action.payload.open;
state.manageShareDialogFile = action.payload.file;
},
closeManageShareDialog: (state) => {
state.manageShareDialogOpen = false;
},
setImageViewer: (state, action: PayloadAction<ImageViewerState>) => {
state.imageViewer = action.payload;
},
closeImageViewer: (state) => {
state.imageViewer = undefined;
},
setImageEditor: (state, action: PayloadAction<ImageEditorState>) => {
state.imageEditor = action.payload;
},
closeImageEditor: (state) => {
state.imageEditor = undefined;
},
setStaleVersionDialog: (state, action: PayloadAction<{ open: boolean; promiseId: string; uri: string }>) => {
state.staleVersionDialogOpen = action.payload.open;
state.staleVersionPromiseId = action.payload.promiseId;
state.staleVersionUri = action.payload.uri;
},
closeStaleVersionDialog: (state) => {
state.staleVersionDialogOpen = false;
},
setSaveAsDialog: (
state,
action: PayloadAction<{
open: boolean;
name?: string;
promiseId: string;
}>,
) => {
state.saveAsDialogOpen = action.payload.open;
state.saveAsInitialName = action.payload.name;
state.saveAsPromiseId = action.payload.promiseId;
},
closeSaveAsDialog: (state) => {
state.saveAsDialogOpen = false;
},
setPhotopeaViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.photopeaViewer = action.payload;
},
closePhotopeaViewer: (state) => {
state.photopeaViewer && (state.photopeaViewer.open = false);
},
setViewerSelector: (state, action: PayloadAction<ViewerSelectorState>) => {
state.viewerSelector = action.payload;
},
closeViewerSelector: (state) => {
state.viewerSelector && (state.viewerSelector.open = false);
},
setWopiViewer: (state, action: PayloadAction<WopiViewerState>) => {
state.wopiViewer = action.payload;
},
closeWopiViewer: (state) => {
state.wopiViewer && (state.wopiViewer.open = false);
},
setCodeViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.codeViewer = action.payload;
},
closeCodeViewer: (state) => {
state.codeViewer && (state.codeViewer.open = false);
},
setDrawIOViewer: (state, action: PayloadAction<DrawIOViewerState>) => {
state.drawIOViewer = action.payload;
},
closeDrawIOViewer: (state) => {
state.drawIOViewer && (state.drawIOViewer.open = false);
},
},
});
export default globalStateSlice.reducer;
export const {
setArchiveViewer,
closeArchiveViewer,
setUploadRawFiles,
setMobileDrawerOpen,
setDirectLinkDialog,
closeDirectLinkDialog,
setListViewColumnSettingDialog,
closeAdvanceSearch,
setAdvanceSearch,
setRemoteDownloadDialog,
closeRemoteDownloadDialog,
setExtractArchiveDialog,
closeExtractArchiveDialog,
setCreateArchiveDialog,
closeCreateArchiveDialog,
setBatchDownloadLogDialog,
closeBatchDownloadLogDialog,
setBatchDownloadLog,
setSelectOptionDialog,
closeSelectOptionDialog,
setUploadFromClipboardDialog,
openUploadTaskList,
closeUploadTaskList,
setUploadProgress,
selectForUpload,
setCreateNewDialog,
closeCreateNewDialog,
setMusicPlayer,
closeMusicPlayer,
setEpubViewer,
closeEpubViewer,
setCustomViewer,
closeCustomViewer,
setPdfViewer,
closePdfViewer,
setVideoViewer,
closeVideoViewer,
setMarkdownViewer,
closeMarkdownViewer,
setDrawIOViewer,
closeDrawIOViewer,
setCodeViewer,
closeCodeViewer,
setWopiViewer,
closeWopiViewer,
setViewerSelector,
closeViewerSelector,
setPhotopeaViewer,
closePhotopeaViewer,
setStaleVersionDialog,
closeStaleVersionDialog,
setImageViewer,
closeImageViewer,
setManageShareDialog,
closeManageShareDialog,
setVersionControlDialog,
closeVersionControlDialog,
closeSidebar,
setSidebar,
addShareInfo,
setShareLinkDialog,
closeShareLinkDialog,
setChangeIconDialog,
closeChangeIconDialog,
setTagsDialog,
closeTagsDialog,
setPathSelectionDialog,
closePathSelectionDialog,
setDragging,
setPinFileDialog,
closePinFileDialog,
increasePinedGeneration,
setConfirmDialog,
closeConfirmDialog,
updateLockConflicts,
closeLockConflictDialog,
setLockConflictDialog,
closeAggregatedErrorDialog,
setAggregatedErrorDialog,
setDarkMode,
setDrawerOpen,
setDrawerWidth,
setHeadlessFrameLoading,
setPreferredTheme,
setUserInfoCache,
setImageEditor,
closeImageEditor,
setSaveAsDialog,
closeSaveAsDialog,
resetDialogs,
setPolicyOptionCache,
setSearchPopup,
setExcalidrawViewer,
closeExcalidrawViewer,
setDirectLinkManagementDialog,
closeDirectLinkManagementDialog,
setShareReadmeDetect,
closeShareReadme,
setShareReadmeOpen,
} = globalStateSlice.actions;

5
src/redux/hooks.ts Executable file
View File

@@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "./store.ts";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

182
src/redux/siteConfigSlice.ts Executable file
View File

@@ -0,0 +1,182 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Viewer, ViewerPlatform } from "../api/explorer.ts";
import { SiteConfig } from "../api/site.ts";
import { ExpandedIconSettings, FileTypeIconSetting } from "../component/FileManager/Explorer/FileTypeIcon.tsx";
import SessionManager from "../session/index.ts";
import Boolset from "../util/boolset.ts";
import { ExpandedViewerSetting } from "./thunks/viewer.ts";
declare global {
interface Window {
subTitle: string;
}
}
export enum ConfigLoadState {
NotLoaded,
CacheHit,
Loaded,
}
export interface SiteConfigSlice {
[key: string]: {
loaded: ConfigLoadState;
config: SiteConfig;
typed?: any;
};
}
const initialState: SiteConfigSlice = {
login: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
basic: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
explorer: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
emojis: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
app: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
thumb: {
loaded: ConfigLoadState.NotLoaded,
config: {},
},
};
export let Viewers: ExpandedViewerSetting = {};
export let ViewersByID: { [key: string]: Viewer } = {};
const preProcessors: {
[key: string]: (config: SiteConfig) => any;
} = {
explorer: (config: SiteConfig) => {
let icons: ExpandedIconSettings = {};
try {
const iconSettings = <FileTypeIconSetting[]>JSON.parse(config.icons ?? "[]");
iconSettings.forEach((item) => {
item.exts.forEach((ext) => {
icons[ext] = { ...item, exts: [] };
});
});
} catch (e) {
console.warn("Failed to parse icons config", e);
}
Viewers = {};
ViewersByID = {};
const isMobile = window.matchMedia("(max-width: 768px)").matches;
config.file_viewers?.forEach((group) => {
group.viewers.forEach((viewer) => {
if (viewer.disabled) {
return;
}
if (viewer.required_group_permission) {
const group = SessionManager.currentUserGroup();
if (!group) {
return;
}
const groupBs = new Boolset(group.permission);
if (viewer.required_group_permission.some((p) => !groupBs.enabled(p))) {
return;
}
}
const platform = viewer.platform || ViewerPlatform.all;
if (platform !== ViewerPlatform.all && platform !== (isMobile ? ViewerPlatform.mobile : ViewerPlatform.pc)) {
return;
}
ViewersByID[viewer.id] = viewer;
const simplified: Viewer = viewer;
viewer.exts.forEach((ext) => {
if (Viewers[ext] === undefined) {
Viewers[ext] = [];
}
Viewers[ext].push(simplified);
});
});
});
return { icons };
},
};
const loadSiteConfigCache = (initial: SiteConfigSlice): SiteConfigSlice => {
Object.entries(initial).forEach(([key, _value]) => {
const cacheContent = localStorage.getItem(`siteConfigCache_${key}`);
if (cacheContent == null) {
return;
}
try {
const configCache = JSON.parse(cacheContent) as SiteConfig;
initial[key].loaded = ConfigLoadState.CacheHit;
initial[key].config = configCache;
if (preProcessors[key]) {
initial[key].typed = preProcessors[key](configCache);
}
} catch (e) {}
// // 检查是否有path参数
// const url = new URL(window.location.href);
// const c = url.searchParams.get("path");
// rawStore.navigator.path = c === null ? "/" : c;
// // 初始化用户个性配置
// rawStore.siteConfig = initUserConfig(rawStore.siteConfig);
});
// 更改站点标题
document.title = initial["basic"].config.title ?? "";
return initial;
};
export const siteConfigSlice = createSlice({
name: "siteConfig",
initialState: loadSiteConfigCache(initialState),
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
applySetting: (
state,
action: PayloadAction<{
section: string;
config: SiteConfig;
}>,
) => {
state[action.payload.section].loaded = ConfigLoadState.Loaded;
state[action.payload.section].config = action.payload.config;
if (preProcessors[action.payload.section]) {
state[action.payload.section].typed = preProcessors[action.payload.section](action.payload.config);
}
},
},
// // The `extraReducers` field lets the slice handle actions defined elsewhere,
// // including actions generated by createAsyncThunk or in other slices.
// extraReducers: (builder) => {
// builder
// .addCase(incrementAsync.pending, (state) => {
// state.status = "loading";
// })
// .addCase(incrementAsync.fulfilled, (state, action) => {
// state.status = "idle";
// state.value += action.payload;
// })
// .addCase(incrementAsync.rejected, (state) => {
// state.status = "failed";
// });
// },
});
export default siteConfigSlice.reducer;
export const { applySetting } = siteConfigSlice.actions;

21
src/redux/store.ts Executable file
View File

@@ -0,0 +1,21 @@
import { Action, configureStore } from "@reduxjs/toolkit";
import { ThunkAction } from "redux-thunk";
import { updateSiteConfig } from "./thunks/site.ts";
import siteConfigSliceReducer from "./siteConfigSlice";
import globalStateSliceReducer from "./globalStateSlice";
import fileManagerSliceReducer from "./fileManagerSlice.ts";
export const store = configureStore({
reducer: {
siteConfig: siteConfigSliceReducer,
globalState: globalStateSliceReducer,
fileManager: fileManagerSliceReducer,
},
devTools: process.env.NODE_ENV !== "production",
});
store.dispatch(updateSiteConfig());
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, Action<string>>;

267
src/redux/thunks/dialog.ts Executable file
View File

@@ -0,0 +1,267 @@
import { ConflictDetail, FileResponse } from "../../api/explorer.ts";
import { Response } from "../../api/request.ts";
import { DeleteOption } from "../../component/FileManager/Dialogs/DeleteConfirmation.tsx";
import { setFileDeleteModal, setRenameFileModal } from "../fileManagerSlice.ts";
import {
DialogSelectOption,
setAggregatedErrorDialog,
setConfirmDialog,
setCreateNewDialog,
setLockConflictDialog,
setPathSelectionDialog,
setSaveAsDialog,
setSelectOptionDialog,
setStaleVersionDialog,
} from "../globalStateSlice.ts";
import { AppThunk } from "../store.ts";
export const promiseId = () => new Date().getTime().toString();
export const deleteDialogPromisePool: {
[key: string]: {
resolve: (value: DeleteOption | PromiseLike<DeleteOption>) => void;
reject: (reason?: any) => void;
};
} = {};
export const generalDialogPromisePool: {
[key: string]: {
resolve: (value: void | PromiseLike<void>) => void;
reject: (reason?: any) => void;
};
} = {};
export const renameDialogPromisePool: {
[key: string]: {
resolve: (value: string | PromiseLike<string>) => void;
reject: (reason?: any) => void;
};
} = {};
export const pathSelectionDialogPromisePool: {
[key: string]: {
resolve: (value: string | PromiseLike<string>) => void;
reject: (reason?: any) => void;
};
} = {};
export const createNewDialogPromisePool: {
[key: string]: {
resolve: (value: FileResponse | PromiseLike<FileResponse>) => void;
reject: (reason?: any) => void;
};
} = {};
export const selectOptionDialogPromisePool: {
[key: string]: {
resolve: (value: any | PromiseLike<any>) => void;
reject: (reason?: any) => void;
};
} = {};
export interface StaleVersionAction {
overwrite?: boolean;
saveAs?: string;
}
export const staleVersionDialogPromisePool: {
[key: string]: {
resolve: (value: StaleVersionAction | PromiseLike<StaleVersionAction>) => void;
reject: (reason?: any) => void;
};
} = {};
export interface SaveAsAction {
uri: string;
name: string;
}
export const saveAsDialogPromisePool: {
[key: string]: {
resolve: (value: SaveAsAction | PromiseLike<SaveAsAction>) => void;
reject: (reason?: any) => void;
};
} = {};
export function deleteConfirmation(index: number, files: FileResponse[]): AppThunk<Promise<DeleteOption>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<DeleteOption>((resolve, reject) => {
deleteDialogPromisePool[id] = { resolve, reject };
dispatch(setFileDeleteModal({ index, value: [true, files, id, false] }));
});
};
}
export function renameForm(index: number, file: FileResponse): AppThunk<Promise<string>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<string>((resolve, reject) => {
renameDialogPromisePool[id] = { resolve, reject };
dispatch(
setRenameFileModal({
index,
value: {
open: true,
selected: file,
promiseId: id,
loading: false,
},
}),
);
});
};
}
export function showAggregatedErrorDialog(error: Response<any>): AppThunk {
return (dispatch, getState) => {
const tree = getState().fileManager[0].tree;
const files: {
[key: string]: FileResponse;
} = {};
Object.keys(error.aggregated_error ?? {}).forEach((path) => {
const cached = tree[path]?.file;
if (cached) {
files[path] = cached;
}
});
dispatch(
setAggregatedErrorDialog({
open: true,
error: error,
files: files,
}),
);
};
}
export function openLockConflictDialog(error: Response<ConflictDetail[]>): AppThunk<Promise<void>> {
return (dispatch, getState) => {
const tree = getState().fileManager[0].tree;
const files: {
[key: string]: FileResponse;
} = {};
error.data.forEach((conflict) => {
if (!conflict.path) {
return;
}
const cached = tree[conflict.path]?.file;
if (cached) {
files[conflict.path] = cached;
}
});
const id = promiseId();
return new Promise<void>((resolve, reject) => {
generalDialogPromisePool[id] = { resolve, reject };
dispatch(
setLockConflictDialog({
open: true,
error: error,
files: files,
promiseId: id,
}),
);
});
};
}
export function confirmOperation(message: string): AppThunk<Promise<void>> {
return (dispatch) => {
const id = promiseId();
return new Promise<void>((resolve, reject) => {
generalDialogPromisePool[id] = { resolve, reject };
dispatch(
setConfirmDialog({
open: true,
message: message,
promiseId: id,
}),
);
});
};
}
export function selectPath(variant: string, initialPath?: string): AppThunk<Promise<string>> {
return (dispatch) => {
const id = promiseId();
return new Promise<string>((resolve, reject) => {
pathSelectionDialogPromisePool[id] = { resolve, reject };
dispatch(
setPathSelectionDialog({
open: true,
variant: variant,
promiseId: id,
initialPath: initialPath,
}),
);
});
};
}
export function askStaleVersionAction(uri: string): AppThunk<Promise<StaleVersionAction>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<StaleVersionAction>((resolve, reject) => {
staleVersionDialogPromisePool[id] = { resolve, reject };
dispatch(
setStaleVersionDialog({
open: true,
uri,
promiseId: id,
}),
);
});
};
}
export function askSaveAs(name: string): AppThunk<Promise<SaveAsAction>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<SaveAsAction>((resolve, reject) => {
saveAsDialogPromisePool[id] = { resolve, reject };
dispatch(
setSaveAsDialog({
open: true,
name,
promiseId: id,
}),
);
});
};
}
export function requestCreateNew(fmIndex: number, type: string, defaultName?: string): AppThunk<Promise<FileResponse>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<FileResponse>((resolve, reject) => {
createNewDialogPromisePool[id] = { resolve, reject };
dispatch(
setCreateNewDialog({
open: true,
type,
default: defaultName,
promiseId: id,
fmIndex,
}),
);
});
};
}
export function selectOption(options: DialogSelectOption[], title: string): AppThunk<Promise<any> | Promise<void>> {
return async (dispatch) => {
const id = promiseId();
return new Promise<any>((resolve, reject) => {
selectOptionDialogPromisePool[id] = { resolve, reject };
dispatch(
setSelectOptionDialog({
open: true,
title,
options,
promiseId: id,
}),
);
});
};
}

449
src/redux/thunks/download.ts Executable file
View File

@@ -0,0 +1,449 @@
import dayjs from "dayjs";
import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar } from "notistack";
import streamSaver from "streamsaver";
import { getFileEntityUrl } from "../../api/api.ts";
import { FileResponse, FileType, Metadata } from "../../api/explorer.ts";
import { GroupPermission } from "../../api/user.ts";
import { ViewDownloadLogAction } from "../../component/Common/Snackbar/snackbar.tsx";
import SessionManager from "../../session";
import { getFileLinkedUri } from "../../util";
import Boolset from "../../util/boolset.ts";
import { formatLocalTime } from "../../util/datetime.ts";
import {
getFileSystemDirectoryPaths,
saveFileToFileSystemDirectory,
verifyFileSystemRWPermission,
} from "../../util/filesystem.ts";
import "../../util/zip.js";
import { closeContextMenu } from "../fileManagerSlice.ts";
import { DialogSelectOption, setBatchDownloadLog } from "../globalStateSlice.ts";
import { AppThunk } from "../store.ts";
import { promiseId, selectOption } from "./dialog.ts";
import { longRunningTaskWithSnackbar, refreshSingleFileSymbolicLinks, walk, walkAll } from "./file.ts";
enum MultipleDownloadOption {
Browser,
StreamSaver,
Backend,
}
enum DownloadOverwriteOption {
Skip,
Overwrite,
OverwriteAll,
SkipAll,
}
export function downloadFiles(index: number, files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
if (files.length == 1 && files[0].type == FileType.file) {
await dispatch(downloadSingleFile(files[0]));
} else {
await dispatch(downloadMultipleFiles(files));
}
};
}
export function downloadMultipleFiles(files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
// Prepare download options
const options: MultipleDownloadOption[] = [MultipleDownloadOption.StreamSaver];
// @ts-ignore
if (window.isSecureContext && window.showDirectoryPicker) {
options.push(MultipleDownloadOption.Browser);
}
const groupPermission = new Boolset(SessionManager.currentUser()?.group?.permission);
if (
groupPermission.enabled(GroupPermission.archive_download) &&
(files.length > 1 || !files[0].metadata?.[Metadata.share_redirect])
) {
options.push(MultipleDownloadOption.Backend);
}
let finalOption = options[0];
if (options.length > 1) {
try {
finalOption = (await dispatch(
selectOption(getDownloadSelectOption(options), "fileManager.selectArchiveMethod"),
)) as MultipleDownloadOption;
} catch (e) {
// User cancel selection
return;
}
}
if (finalOption == MultipleDownloadOption.Backend) {
await dispatch(backendBatchDownload(files));
} else if (finalOption == MultipleDownloadOption.Browser) {
await dispatch(browserBatchDownload(files));
} else {
await dispatch(streamSaverDownload(files));
}
};
}
export function backendBatchDownload(files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
const downloadUrl = await longRunningTaskWithSnackbar(
dispatch(
getFileEntityUrl({
uris: files.map((f) => getFileLinkedUri(f)),
archive: true,
}),
),
"application:fileManager.preparingBathDownload",
);
window.location.assign(downloadUrl.urls[0].url);
};
}
export const cancelSignals: {
[key: string]: AbortController;
} = {};
export function browserBatchDownload(files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
const downloadId = promiseId();
cancelSignals[downloadId] = new AbortController();
// Select download folder
let handle: FileSystemDirectoryHandle;
if (!window.showDirectoryPicker || !window.isSecureContext) {
return;
}
try {
// can't use suggestedName for showDirectoryPicker (only available showSaveFilePicker)
handle = await window.showDirectoryPicker({
startIn: "downloads",
mode: "readwrite",
});
// we should obtain the readwrite permission for the directory at first
if (!(await verifyFileSystemRWPermission(handle))) {
enqueueSnackbar({
message: i18next.t("application:fileManager.directoryDownloadPermissionError"),
variant: "error",
});
throw new Error(i18next.t("application:fileManager.directoryDownloadPermissionError"));
}
} catch (e) {
return;
}
await longRunningTaskWithSnackbar(
dispatch(startBrowserBatchDownloadTo(handle, downloadId, files)),
"fileManager.batchDownloadStarted",
ViewDownloadLogAction(downloadId),
);
};
}
function startBrowserBatchDownloadTo(
handle: FileSystemDirectoryHandle,
downloadId: string,
files: FileResponse[],
): AppThunk<Promise<void>> {
return async (dispatch, _getState): Promise<void> => {
let log = "";
let failed = 0;
let skipAll = false;
let overwriteAll = false;
const appendLog = (msg: string) => {
log = log + msg + "\n";
dispatch(setBatchDownloadLog({ id: downloadId, logs: log }));
};
// get the files in the directory to compare with queue files
// parent: ""
const fsPaths = await getFileSystemDirectoryPaths(handle, "");
await dispatch(
walk(files, async (children, relativePath) => {
const childFiles = children.filter((f) => f.type == FileType.file);
try {
const entityUrls = await dispatch(
getFileEntityUrl({
uris: childFiles.map((f) => getFileLinkedUri(f)),
download: true,
skip_error: true,
}),
);
for (let i = 0; i < entityUrls.urls.length; i++) {
if (!entityUrls.urls[i]) {
appendLog(
i18next.t("modals.directoryDownloadErrorNotification", {
name: childFiles[i].name,
msg: "failed to get download url",
}),
);
failed++;
continue;
}
const name = (relativePath == "" ? "" : relativePath + "/") + childFiles[i].name;
if (fsPaths.has(name)) {
if (skipAll) {
appendLog(
i18next.t("modals.directoryDownloadSkipNotifiction", {
name,
}),
);
continue;
}
if (overwriteAll) {
appendLog(
i18next.t("modals.directoryDownloadReplaceNotifiction", {
name,
}),
);
} else {
// No overwrite options, ask for one
let overwriteOption = DownloadOverwriteOption.Skip;
try {
overwriteOption = (await dispatch(
selectOption(getDownloadOverwriteOption(name), "fileManager.selectDirectoryDuplicationMethod"),
)) as DownloadOverwriteOption;
} catch (e) {
// User cancel, use skip option
overwriteOption = DownloadOverwriteOption.Skip;
}
if (overwriteOption == DownloadOverwriteOption.Skip) {
appendLog(
i18next.t("modals.directoryDownloadSkipNotifiction", {
name,
}),
);
continue;
} else if (overwriteOption == DownloadOverwriteOption.SkipAll) {
appendLog(
i18next.t("modals.directoryDownloadSkipNotifiction", {
name,
}),
);
skipAll = true;
continue;
} else if (overwriteOption == DownloadOverwriteOption.OverwriteAll) {
appendLog(
i18next.t("modals.directoryDownloadReplaceNotifiction", {
name,
}),
);
overwriteAll = true;
} else {
appendLog(
i18next.t("modals.directoryDownloadReplaceNotifiction", {
name,
}),
);
}
}
}
appendLog(i18next.t("modals.directoryDownloadStarted", { name }));
try {
const res = await fetch(entityUrls.urls[i].url, {
signal: cancelSignals[downloadId].signal,
});
await saveFileToFileSystemDirectory(handle, await res.blob(), name);
appendLog(i18next.t("modals.directoryDownloadFinished", { name }));
} catch (e) {
// User cancel download
if (e instanceof Error && e.name == "AbortError") {
appendLog(i18next.t("modals.directoryDownloadCancelled"));
throw e;
}
failed++;
appendLog(
i18next.t("modals.directoryDownloadErrorNotification", {
name: name,
msg: (e as Error).message,
}),
);
}
}
} catch (e) {
if (e instanceof Error && e.name == "AbortError") {
throw e;
}
failed += childFiles.length;
appendLog(
i18next.t("modals.directoryDownloadError", {
msg: (e as Error).message,
}),
);
}
}),
);
if (failed === 0) {
appendLog(i18next.t("fileManager.directoryDownloadFinished"));
} else {
appendLog(i18next.t("fileManager.directoryDownloadFinishedWithError", { failed }));
}
};
}
export function streamSaverDownload(files: FileResponse[]): AppThunk {
return async (dispatch, getState) => {
const allFiles = (
await longRunningTaskWithSnackbar(dispatch(walkAll(files)), "application:fileManager.preparingBathDownload")
).filter((f) => f.type == FileType.file);
const fileStream = streamSaver.createWriteStream(formatLocalTime(dayjs()) + ".zip");
const {
siteConfig: {
explorer: {
config: { max_batch_size },
},
},
} = getState();
const maxBatch = Math.min(10, max_batch_size ?? 1);
let current = 0;
const readableZipStream = new (window as any).ZIP({
start(_ctrl: any) {
// ctrl.close()
},
async pull(ctrl: any) {
const batch = allFiles.slice(current, current + maxBatch);
current += batch.length;
if (batch.length == 0) {
ctrl.close();
return;
}
try {
const entityUrls = await dispatch(
getFileEntityUrl({
uris: batch.map((f) => getFileLinkedUri(f)),
skip_error: true,
download: true,
}),
);
for (let i = 0; i < entityUrls.urls.length; i++) {
const url = entityUrls.urls[i];
if (!url) {
continue;
}
const res = await fetch(url.url);
const stream = () => res.body;
ctrl.enqueue({ name: batch[i].relativePath, stream });
}
} catch (e) {
console.warn("Failed to get entity urls", e);
}
},
});
if (window.WritableStream && readableZipStream.pipeTo) {
try {
await longRunningTaskWithSnackbar(readableZipStream.pipeTo(fileStream), "fileManager.batchDownloadStarted");
} catch (e) {
console.log(e);
}
}
};
}
export function downloadSingleFile(file: FileResponse, preferredEntity?: string): AppThunk {
return async (dispatch, _getState) => {
const isSharedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (isSharedFile) {
file = await dispatch(refreshSingleFileSymbolicLinks(file));
}
const urlRes = await longRunningTaskWithSnackbar(
dispatch(
getFileEntityUrl({
uris: [getFileLinkedUri(file)],
entity: preferredEntity,
download: true,
}),
),
"application:fileManager.preparingDownload",
);
const streamSaverName = urlRes.urls[0].stream_saver_display_name;
if (streamSaverName) {
// remove streamSaverParam from query
const fileStream = streamSaver.createWriteStream(streamSaverName);
const res = await fetch(urlRes.urls[0].url);
const readableStream = res.body;
if (!readableStream) {
return;
}
// more optimized
if (window.WritableStream && readableStream.pipeTo) {
const downloadingSnackbar = enqueueSnackbar({
message: i18next.t("fileManager.downloadingFile", {
name: streamSaverName,
}),
variant: "loading",
persist: true,
});
return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar));
}
} else {
window.location.assign(urlRes.urls[0].url);
}
};
}
const getDownloadSelectOption = (options: MultipleDownloadOption[]): DialogSelectOption[] => {
return options.map((option): DialogSelectOption => {
switch (option) {
case MultipleDownloadOption.Backend:
return {
value: MultipleDownloadOption.Backend,
name: i18next.t("fileManager.serverBatchDownload"),
description: i18next.t("fileManager.serverBatchDownloadDescription"),
};
case MultipleDownloadOption.Browser:
return {
value: MultipleDownloadOption.Browser,
name: i18next.t("fileManager.browserDownload"),
description: i18next.t("fileManager.browserDownloadDescription"),
};
default:
return {
value: MultipleDownloadOption.StreamSaver,
name: i18next.t("fileManager.browserBatchDownload"),
description: i18next.t("fileManager.browserBatchDownloadDescription"),
};
}
});
};
const getDownloadOverwriteOption = (name: string): DialogSelectOption[] => {
return [
{
name: i18next.t("fileManager.directoryDownloadReplace"),
description: i18next.t("fileManager.directoryDownloadReplaceDescription", { name }),
value: DownloadOverwriteOption.Overwrite,
},
{
name: i18next.t("fileManager.directoryDownloadSkip"),
description: i18next.t("fileManager.directoryDownloadSkipDescription", {
name,
}),
value: DownloadOverwriteOption.Skip,
},
{
name: i18next.t("fileManager.directoryDownloadReplaceAll"),
description: i18next.t("fileManager.directoryDownloadReplaceAllDescription", { name }),
value: DownloadOverwriteOption.OverwriteAll,
},
{
name: i18next.t("fileManager.directoryDownloadSkipAll"),
description: i18next.t("fileManager.directoryDownloadSkipAllDescription", { name }),
value: DownloadOverwriteOption.SkipAll,
},
];
};

1308
src/redux/thunks/file.ts Executable file

File diff suppressed because it is too large Load Diff

916
src/redux/thunks/filemanager.ts Executable file
View File

@@ -0,0 +1,916 @@
import dayjs from "dayjs";
import { getFileInfo, getFileList, getUserCapacity, sendPatchViewSync } from "../../api/api.ts";
import { ExplorerView, FileResponse, FileType, ListResponse, Metadata } from "../../api/explorer.ts";
import { getActionOpt } from "../../component/FileManager/ContextMenu/useActionDisplayOpt.ts";
import { ListViewColumnSetting } from "../../component/FileManager/Explorer/ListView/Column.tsx";
import { FileManagerIndex } from "../../component/FileManager/FileManager.tsx";
import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.tsx";
import { Condition, ConditionType } from "../../component/FileManager/Search/AdvanceSearch/ConditionBox.tsx";
import { MinPageSize } from "../../component/FileManager/TopBar/ViewOptionPopover.tsx";
import { SelectType } from "../../component/Uploader/core";
import { Task } from "../../component/Uploader/core/types.ts";
import { uploadPromisePool } from "../../component/Uploader/core/uploader/base.ts";
import { defaultPath } from "../../hooks/useNavigation.tsx";
import { router } from "../../router";
import SessionManager, { UserSettings } from "../../session";
import { getFileLinkedUri, sleep } from "../../util";
import CrUri, { Filesystem, SearchParam, UriQuery } from "../../util/uri.ts";
import {
appendListResponse,
appendTreeCache,
applyListResponse,
clearMultiSelectHovered,
clearSelected,
closeContextMenu,
ContextMenuTypes,
Layouts,
resetFileManager,
setCapacity,
setContextMenu,
setFmError,
setFmLoading,
setGalleryWidth,
setLayout,
setListViewColumns,
setMultiSelectHovered,
setPage,
setPageSize,
setPathProps,
setSelected,
setShowThumb,
setSortOption,
SingleManager,
} from "../fileManagerSlice.ts";
import {
closeAdvanceSearch,
closeImageViewer,
resetDialogs,
selectForUpload,
setAdvanceSearch,
setPinFileDialog,
setSearchPopup,
setShareReadmeDetect,
setUploadFromClipboardDialog,
setUploadRawFiles,
} from "../globalStateSlice.ts";
import { Viewers, ViewersByID } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
import { promiseId } from "./dialog.ts";
import { deleteFile, openFileContextMenu } from "./file.ts";
import { queueLoadShareInfo } from "./share.ts";
import { openViewer } from "./viewer.ts";
export function setTargetPath(index: number, path: string): AppThunk {
return async (dispatch, _getState) => {
try {
const crUri = new CrUri(path);
const pathElements = crUri.elements();
const pure = crUri.pure_uri(UriQuery.category);
dispatch(
setPathProps({
index,
value: {
path,
path_elements: pathElements,
path_root: crUri.base(),
current_fs: crUri.fs(),
pure_path_with_category: pure.toString().replace(/\/$/, ""),
pure_path: crUri.pure_uri().toString(),
path_root_with_category: pure.base(false),
search_params: crUri.searchParams(),
},
}),
);
} catch (e) {
dispatch(
setFmError({
index,
value: e,
}),
);
return;
}
};
}
let generation = 0;
export interface NavigateReconcileOptions {
next_page?: boolean;
sync_view?: boolean;
}
const pageSize = (fm: SingleManager) => {
let pageSize = Math.max(fm.pageSize, MinPageSize);
return Math.min(fm.pageSize, fm.list?.props.max_page_size ?? pageSize);
};
export function beforePathChange(index: number): AppThunk {
return async (dispatch, getState) => {
const {
globalState: { imageViewer },
} = getState();
if (imageViewer?.open && index == FileManagerIndex.main) {
dispatch(closeImageViewer());
}
dispatch(clearSelected({ index, value: undefined }));
dispatch(clearMultiSelectHovered({ index, value: undefined }));
};
}
export function checkReadMeEnabled(index: number): AppThunk {
return async (dispatch, getState) => {
const { path, current_fs } = getState().fileManager[index];
if (path && current_fs == Filesystem.share) {
try {
const info = await dispatch(queueLoadShareInfo(new CrUri(path), false));
dispatch(setShareReadmeDetect(info?.show_readme && info.source_type == FileType.folder));
} catch (e) {
dispatch(setShareReadmeDetect(false));
}
} else {
dispatch(setShareReadmeDetect(false));
}
};
}
export function checkOpenViewerQuery(index: number): AppThunk {
return async (dispatch, getState) => {
const currentUrl = new URL(window.location.href);
const viewer = currentUrl.searchParams.get("viewer");
const fileId = currentUrl.searchParams.get("open");
const version = currentUrl.searchParams.get("version");
const size = currentUrl.searchParams.get("size");
// Clear viewer-related query parameters
currentUrl.searchParams.delete("viewer");
currentUrl.searchParams.delete("open");
currentUrl.searchParams.delete("version");
currentUrl.searchParams.delete("size");
window.history.replaceState({}, "", currentUrl.toString());
if (!fileId || !viewer || !ViewersByID[viewer]) {
return;
}
const { files: list, pagination } = getState().fileManager[index]?.list ?? {};
if (list) {
// Find readme file from highest to lowest priority
const found = list.find((file) => file.id === fileId);
if (found) {
dispatch(openViewer(found, ViewersByID[viewer], parseInt(size ?? "0"), version ?? undefined, true));
return;
}
}
alert("openViewer");
};
}
export function navigateReconcile(index: number, opt?: NavigateReconcileOptions): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const timeNow = dayjs().valueOf();
const {
fileManager,
globalState: { sidebarOpen, imageViewer },
} = getState();
const { path, list, pure_path } = fileManager[index];
if (!path) {
return;
}
const currentGeneration = ++generation;
if (!opt?.next_page) {
dispatch(setFmLoading({ index, value: true }));
}
dispatch(setFmError({ index, value: undefined }));
if (opt?.sync_view) {
try {
await dispatch(syncViewSettings(index));
} catch (e) {}
}
const currentLogin = SessionManager.currentLoginOrNull();
const currentView = localCustomView[pure_path ?? ""];
let useCustomView = currentLogin?.user.disable_view_sync || currentView;
let listRes: ListResponse | null = null;
try {
listRes = await dispatch(
getFileList({
next_page_token: opt && opt.next_page ? list?.pagination.next_token : undefined,
uri: path,
page: list?.pagination.page ?? undefined,
...(useCustomView
? {
page_size: currentView?.page_size ?? pageSize(fileManager[index]),
order_by: currentView?.order ?? fileManager[index].sortBy,
order_direction: currentView?.order_direction ?? fileManager[index].sortDirection,
}
: {}),
}),
);
// DB sorting has limit on string comparison, so we need to
// sort by localCompare, if all files in current page is loaded, and sortBy is name.
const sortBy = listRes.view ? listRes.view.order : currentView?.order;
const orderDirection = listRes.view ? listRes.view.order_direction : currentView?.order_direction;
if (sortBy == "name" && !getPaginationState(list?.pagination).moreItems) {
listRes.files = sortByLocalCompare(listRes.files, listRes.mixed_type, orderDirection == "desc");
}
} catch (e) {
if (currentGeneration == generation) {
dispatch(
setFmError({
index,
value: e,
}),
);
return;
}
} finally {
if (currentGeneration == generation) {
dispatch(setFmLoading({ index, value: false }));
}
}
// Check if current request is stale
if (currentGeneration !== generation) {
return;
}
if (listRes) {
const fsUri = new CrUri(path);
dispatch(
appendTreeCache({
index,
value: [listRes.files, fsUri.is_search() ? undefined : path],
}),
);
if (listRes.view) {
// Apply view setting from cloud
dispatch(setPageSize({ index, value: listRes.view.page_size }));
dispatch(
setSortOption({ index, value: [listRes.view.order ?? "created_at", listRes.view.order_direction ?? "asc"] }),
);
if (!currentView) {
dispatch(setShowThumb({ index, value: !!listRes.view.thumbnail }));
dispatch(setLayout({ index, value: listRes.view.view ?? Layouts.grid }));
dispatch(
setListViewColumns(listRes.view.columns ?? SessionManager.getWithFallback(UserSettings.ListViewColumns)),
);
dispatch(
setGalleryWidth({
index,
value: listRes.view.gallery_width ?? SessionManager.getWithFallback(UserSettings.GalleryWidth),
}),
);
}
}
if (currentView) {
// Apply view setting from local cache
dispatch(setShowThumb({ index, value: !!currentView.thumbnail }));
dispatch(setLayout({ index, value: currentView.view ?? Layouts.grid }));
dispatch(
setListViewColumns(currentView.columns ?? SessionManager.getWithFallback(UserSettings.ListViewColumns)),
);
dispatch(
setGalleryWidth({
index,
value: currentView.gallery_width ?? SessionManager.getWithFallback(UserSettings.GalleryWidth),
}),
);
}
if (opt && opt.next_page) {
dispatch(appendListResponse({ index, value: listRes }));
} else {
if (listRes.pagination.total_items) {
// check if page is overflow
const totalPages = Math.ceil(listRes.pagination.total_items / listRes.pagination.page_size) - 1;
if (listRes.pagination.page > totalPages) {
dispatch(changePage(index, totalPages));
return;
}
}
// Fill in minimum 150ms for transition animation
const timeDiff = dayjs().valueOf() - timeNow;
if (timeDiff > 0 && timeDiff < 140) {
await sleep(140 - timeDiff);
}
dispatch(applyListResponse({ index, value: listRes }));
}
}
};
}
export function loadMorePages(index: number): AppThunk {
return async (dispatch, getState) => {
const fm = getState().fileManager[index];
if (fm.list?.pagination.next_token) {
dispatch(navigateReconcile(index, { next_page: true }));
} else {
dispatch(changePage(index, (fm.list?.pagination.page ?? 0) + 1));
}
};
}
export function loadChild(index: number, path: string, beforeLoad?: () => void): AppThunk {
return async (dispatch, getState) => {
let listRes: ListResponse | null = null;
const { fileManager } = getState();
const current = fileManager[index].tree[path];
if (current && current.children) {
return;
}
try {
if (beforeLoad) {
beforeLoad();
}
listRes = await dispatch(
getFileList({
page_size: pageSize(fileManager[index]),
uri: path,
order_by: fileManager[index].sortBy,
order_direction: fileManager[index].sortDirection,
}),
);
dispatch(appendTreeCache({ index, value: [listRes.files, path] }));
} catch (e) {
console.log(e);
}
};
}
export function changePageSize(index: number, pageSize: number): AppThunk {
return async (dispatch, _getState) => {
SessionManager.set(UserSettings.PageSize, pageSize);
dispatch(setPageSize({ index, value: pageSize }));
dispatch(navigateReconcile(index, { sync_view: true }));
};
}
export function changePage(index: number, page: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(setPage({ index, value: page }));
dispatch(navigateReconcile(index));
};
}
export function changeSortOption(index: number, sortBy: string, sortDirection: string): AppThunk {
return async (dispatch, _getState) => {
dispatch(setSortOption({ index, value: [sortBy, sortDirection] }));
SessionManager.set(UserSettings.SortBy, sortBy);
SessionManager.set(UserSettings.SortDirection, sortDirection);
dispatch(navigateReconcile(index, { sync_view: true }));
};
}
export function updateUserCapacity(index: number): AppThunk {
return async (dispatch, _getState) => {
if (!SessionManager.currentLoginOrNull()) {
return;
}
try {
const capacity = await dispatch(getUserCapacity());
dispatch(setCapacity({ index, value: capacity }));
} catch (e) {
console.warn("Failed to load user capacity", e);
}
};
}
export function fileHovered(index: number, file: FileResponse, hovered: boolean): AppThunk {
return async (dispatch, getState) => {
const fileManager = getState().fileManager[index];
const hasFileSelected = Object.keys(fileManager.selected).length > 0;
if (!hasFileSelected && hovered) {
return;
}
dispatch(setMultiSelectHovered({ index, value: [file.path, hovered] }));
};
}
export function refreshFileList(index: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
await dispatch(navigateReconcile(index));
dispatch(clearSelected({ index, value: undefined }));
};
}
export function openEmptyContextMenu(index: number, e: React.MouseEvent<HTMLElement>): AppThunk {
return async (dispatch, _getState) => {
e.preventDefault();
const { x, y } = { x: e.clientX, y: e.clientY };
dispatch(clearSelected({ index, value: undefined }));
dispatch(
setContextMenu({
index,
value: {
open: true,
pos: { x, y },
type: ContextMenuTypes.empty,
fmIndex: index,
targets: undefined,
},
}),
);
};
}
export function openNewContextMenu(index: number, e: React.MouseEvent<HTMLElement>): AppThunk {
return async (dispatch, _getState) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const { x, y } = { x: rect.x, y: rect.bottom };
dispatch(
setContextMenu({
index,
value: {
open: true,
pos: { x, y },
type: ContextMenuTypes.new,
fmIndex: index,
targets: undefined,
},
}),
);
};
}
export function pinCurrentView(index: number): AppThunk {
return async (dispatch, getState) => {
const path = getState().fileManager[index].path;
dispatch(setPinFileDialog({ open: true, uri: path }));
};
}
export function navigateToPath(
index: number,
path: string,
file: FileResponse | undefined = undefined,
newTab: boolean = false,
): AppThunk {
return async (dispatch, _getState) => {
if (file) {
path = getFileLinkedUri(file);
}
try {
const crUri = new CrUri(path);
const currentUser = SessionManager.currentLoginOrNull();
if (crUri.id() && crUri.fs() == Filesystem.my && currentUser && currentUser.user.id == crUri.id()) {
crUri.setUsername("");
path = crUri.toString();
}
} catch (e) {}
if (index == FileManagerIndex.selector) {
return dispatch(setTargetPath(index, path));
}
const s = new URLSearchParams("?path=" + encodeURIComponent(path));
const uri = "/home?" + s.toString();
if (newTab) {
// Open in new tab
window.open(uri, "_blank");
return;
}
router.navigate(uri);
};
}
export function retrySharePassword(index: number, password: string): AppThunk {
return async (dispatch, getState) => {
const { fileManager } = getState();
const fm = fileManager[index];
if (!fm.path) {
return;
}
const crUri = new CrUri(fm.path);
crUri.setPassword(password);
console.log(crUri.toString());
dispatch(navigateToPath(index, crUri.toString()));
};
}
export function searchMetadata(
index: number,
metaKey: string,
metaValue?: string,
newTab?: boolean,
strongMatch?: boolean,
): AppThunk {
return async (dispatch, getState) => {
const { fileManager } = getState();
const fm = fileManager[index];
const root = fm.path_root;
if (!root) {
return;
}
const rootUri = new CrUri(root);
rootUri.addQuery(
(strongMatch ? UriQuery.metadata_strong_match : UriQuery.metadata_prefix) + metaKey,
metaValue ?? "",
);
dispatch(navigateToPath(index, rootUri.toString(), undefined, newTab));
};
}
export function uploadClicked(index: number, type: SelectType): AppThunk {
return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
dispatch(selectForUpload({ type }));
};
}
export function uploadFromClipboard(index: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
dispatch(setUploadFromClipboardDialog(true));
};
}
export function resetFm(index: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(resetFileManager(index));
dispatch(resetDialogs());
};
}
// Regex to split by spaces, but keep anything in quotes together
const SPACE_RE = / +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/;
export function quickSearch(index: number, base: string, keyword: string): AppThunk {
return async (dispatch, _getState) => {
const crUri = new CrUri(base);
crUri.setSearchParam({
name: keyword.split(SPACE_RE).filter((x) => x),
case_folding: true,
});
dispatch(navigateToPath(index, crUri.toString()));
dispatch(setSearchPopup(false));
};
}
export function advancedSearch(index: number, conditions: Condition[]): AppThunk {
return async (dispatch, _getState) => {
const params: SearchParam = {};
let base = "";
conditions.forEach((condition) => {
switch (condition.type) {
case ConditionType.base:
base = condition.base_uri ?? defaultPath;
break;
case ConditionType.name:
if (condition.names?.length ?? 0 > 0) {
params.name = condition.names;
params.name_op_or = condition.name_op_or;
params.case_folding = condition.case_folding;
}
break;
case ConditionType.type:
params.type = condition.file_type;
break;
case ConditionType.tag:
if (!params.metadata) {
params.metadata = {};
}
if (condition.tags) {
condition.tags.forEach((tag) => {
if (params.metadata) {
params.metadata[Metadata.tag_prefix + tag] = "";
}
});
}
break;
case ConditionType.metadata:
if (condition.metadata_key && !condition.metadata_strong_match) {
if (!params.metadata) {
params.metadata = {};
}
params.metadata[condition.metadata_key] = condition.metadata_value ?? "";
}
if (condition.metadata_key && condition.metadata_strong_match) {
if (!params.metadata_strong_match) {
params.metadata_strong_match = {};
}
params.metadata_strong_match[condition.metadata_key] = condition.metadata_value ?? "";
}
break;
case ConditionType.size:
if (condition.size_gte != undefined || condition.size_lte != undefined) {
params.size_gte = condition.size_gte;
params.size_lte = condition.size_lte;
}
break;
case ConditionType.created:
if (condition.created_gte != undefined || condition.created_lte != undefined) {
params.created_at_gte = condition.created_gte;
params.created_at_lte = condition.created_lte;
}
break;
case ConditionType.modified:
if (condition.updated_gte != undefined || condition.updated_lte != undefined) {
params.updated_at_gte = condition.updated_gte;
params.updated_at_lte = condition.updated_lte;
}
break;
}
});
const crUri = new CrUri(base);
crUri.setSearchParam(params);
dispatch(navigateToPath(index, crUri.toString()));
dispatch(closeAdvanceSearch());
};
}
export function openAdvancedSearch(index: number, initialKeywords?: string): AppThunk {
return async (dispatch, getState) => {
const current_base = getState().fileManager[index].pure_path;
dispatch(setSearchPopup(false));
dispatch(
setAdvanceSearch({
open: true,
basePath: current_base ?? defaultPath,
nameCondition: initialKeywords != undefined ? initialKeywords.split(SPACE_RE).filter((x) => x) : undefined,
}),
);
};
}
export function clearSearch(index: number): AppThunk {
return async (dispatch, getState) => {
dispatch(navigateToPath(index, getState().fileManager[index]?.pure_path ?? defaultPath));
};
}
export function selectAll(index: number): AppThunk {
return async (dispatch, getState) => {
const fm = getState().fileManager[index];
const files = fm.list?.files;
if (!files) {
return;
}
dispatch(setSelected({ index, value: files }));
};
}
export function shortCutDelete(index: number): AppThunk {
return async (dispatch, getState) => {
const selected = Object.values(getState().fileManager[index].selected);
const actionOpt = getActionOpt(selected, Viewers);
if (actionOpt.showDelete) {
dispatch(deleteFile(index, selected));
}
};
}
export function inverseSelection(index: number): AppThunk {
return async (dispatch, getState) => {
const fm = getState().fileManager[index];
const files = fm.list?.files;
if (!files) {
return;
}
const selected = Object.values(fm.selected);
const newSelected = files.filter((file) => !selected.includes(file));
dispatch(setSelected({ index, value: newSelected }));
};
}
export function openContextUrlFromUri(index: number, uri: string, e: React.MouseEvent<HTMLElement>): AppThunk {
return async (dispatch, getState) => {
// try get file from tree cache
let file = getState().fileManager[index].tree[uri]?.file;
if (!file) {
try {
file = await dispatch(getFileInfo({ uri }));
} catch (e) {
return;
}
}
dispatch(openFileContextMenu(index, file, true, e, ContextMenuTypes.file, false));
};
}
export function setThumbToggle(index: number, value: boolean): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
await dispatch(syncViewSettings(index, undefined, undefined, value));
dispatch(setShowThumb({ index: index, value: value }));
SessionManager.set(UserSettings.ShowThumb, value);
dispatch(setFmLoading({ index, value: false }));
};
}
export function setLayoutSetting(index: number, value: string): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
dispatch(setLayout({ index: index, value: value }));
SessionManager.set(UserSettings.Layout, value);
await dispatch(syncViewSettings(index));
dispatch(setFmLoading({ index, value: false }));
};
}
let localCustomView: Record<string, ExplorerView> = {};
export const clearLocalCustomView = () => {
localCustomView = {};
};
export function syncViewSettings(
index: number,
columns?: ListViewColumnSetting[],
galleryWidth?: number,
thumbOff?: boolean,
): AppThunk {
return async (dispatch, getState) => {
const fm = getState().fileManager[index];
const currentLogin = SessionManager.currentLoginOrNull();
if (!fm.list || !fm.pure_path) {
return;
}
const parent = fm.list.parent;
const crUri = new CrUri(fm.pure_path);
const shouldUpdatedView =
currentLogin &&
!currentLogin.user.disable_view_sync &&
(parent?.owned || crUri.fs() == Filesystem.trash || crUri.fs() == Filesystem.shared_with_me);
const currentView: ExplorerView = {
page_size: Math.max(MinPageSize, pageSize(fm)),
order: fm.sortBy ?? "created_at",
order_direction: fm.sortDirection ?? "asc",
view: fm.layout ?? Layouts.grid,
thumbnail: thumbOff ?? fm.showThumb,
columns: columns ?? fm.listViewColumns,
gallery_width: galleryWidth ?? fm.galleryWidth ?? 110,
};
if (shouldUpdatedView) {
await dispatch(
sendPatchViewSync({
uri: fm.pure_path,
view: currentView,
}),
);
} else {
localCustomView[fm.pure_path] = currentView;
}
};
}
export function applyListColumns(index: number, columns: ListViewColumnSetting[]): AppThunk {
return async (dispatch, _getState) => {
dispatch(setListViewColumns(columns));
SessionManager.set(UserSettings.ListViewColumns, columns);
dispatch(syncViewSettings(index, columns));
};
}
export function applyGalleryWidth(index: number, width: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
await dispatch(syncViewSettings(index, undefined, width));
dispatch(setGalleryWidth({ index, value: width }));
SessionManager.set(UserSettings.GalleryWidth, width);
dispatch(setFmLoading({ index, value: false }));
};
}
export function uploadRawFile(files: File): AppThunk<Promise<Task>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<Task>((resolve, reject) => {
uploadPromisePool[id] = { resolve, reject };
dispatch(
setUploadRawFiles({
files: [files],
promiseId: [id],
}),
);
});
};
}
function sortByLocalCompare(files: FileResponse[], mixed?: boolean, isDesc?: boolean): FileResponse[] {
if (files.length === 0) {
return files;
}
const descending = isDesc ?? false;
// If mixed is true, sort all files together
if (mixed) {
return files.slice().sort((a, b) => {
const result = a.name.localeCompare(b.name);
return descending ? -result : result;
});
}
// If mixed is false, separate folders and files, then sort each part
const sortedFiles = files.slice();
// Binary search to find the division between folders and files
let left = 0;
let right = sortedFiles.length - 1;
let divisionIndex = -1;
// First, we need to find if there's a division at all
let hasFolder = false;
let hasFile = false;
for (const file of sortedFiles) {
if (file.type === FileType.folder) hasFolder = true;
if (file.type === FileType.file) hasFile = true;
}
if (!hasFolder || !hasFile) {
// All items are the same type, just sort normally
return sortedFiles.sort((a, b) => {
const result = a.name.localeCompare(b.name, navigator.languages[0] || navigator.language, {
numeric: true,
ignorePunctuation: true,
});
return descending ? -result : result;
});
}
// Find the division using binary search
// We're looking for the first file (type 0) after folders (type 1)
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (sortedFiles[mid].type === FileType.folder) {
// Check if next item is a file
if (mid + 1 < sortedFiles.length && sortedFiles[mid + 1].type === FileType.file) {
divisionIndex = mid + 1;
break;
}
left = mid + 1;
} else {
// This is a file, look left for the division
if (mid === 0 || sortedFiles[mid - 1].type === FileType.folder) {
divisionIndex = mid;
break;
}
right = mid - 1;
}
}
// If no clear division found, fallback to linear search
if (divisionIndex === -1) {
for (let i = 0; i < sortedFiles.length; i++) {
if (sortedFiles[i].type === FileType.file) {
divisionIndex = i;
break;
}
}
}
let folders: FileResponse[] = [];
let filesOnly: FileResponse[] = [];
if (divisionIndex === -1) {
// All are folders
folders = sortedFiles;
} else if (divisionIndex === 0) {
// All are files
filesOnly = sortedFiles;
} else {
// Split into folders and files
folders = sortedFiles.slice(0, divisionIndex);
filesOnly = sortedFiles.slice(divisionIndex);
}
// Sort folders by name
folders.sort((a, b) => {
const result = a.name.localeCompare(b.name, navigator.languages[0] || navigator.language, {
numeric: true,
ignorePunctuation: true,
});
return descending ? -result : result;
});
// Sort files by name
filesOnly.sort((a, b) => {
const result = a.name.localeCompare(b.name, navigator.languages[0] || navigator.language, {
numeric: true,
ignorePunctuation: true,
});
return descending ? -result : result;
});
// Return folders first, then files
return [...folders, ...filesOnly];
}

78
src/redux/thunks/session.ts Executable file
View File

@@ -0,0 +1,78 @@
import i18next from "i18next";
import { getUserInfo, sendSignout } from "../../api/api.ts";
import { LoginResponse, User } from "../../api/user.ts";
import { router } from "../../router";
import SessionManager, { UserSettings } from "../../session";
import { refreshTimeZone } from "../../util/datetime.ts";
import { clearSessionCache } from "../fileManagerSlice.ts";
import {
closeMusicPlayer,
setDarkMode,
setDrawerWidth,
setPolicyOptionCache,
setPreferredTheme,
setUserInfoCache,
} from "../globalStateSlice.ts";
import { AppThunk } from "../store.ts";
import { longRunningTaskWithSnackbar } from "./file.ts";
import { updateSiteConfig } from "./site.ts";
export function refreshUserSession(session: LoginResponse, redirect: string | null): AppThunk {
return async (dispatch, _getState) => {
dispatch(setTargetSession(session));
dispatch(updateSiteConfig());
if (redirect) {
router.navigate(redirect);
} else {
router.navigate("/home");
}
};
}
export function setTargetSession(session: LoginResponse): AppThunk {
return async (dispatch, _getState) => {
SessionManager.upsert(session);
dispatch(setPreferredTheme(session.user.preferred_theme ?? ""));
if (session.user.language) {
i18next.changeLanguage(session.user.language);
}
dispatch(setDrawerWidth(SessionManager.getWithFallback(UserSettings.DrawerWidth)));
dispatch(setDarkMode(SessionManager.get(UserSettings.PreferredDarkMode)));
// TODO: clear fm cache
dispatch(setPolicyOptionCache());
dispatch(clearSessionCache({ index: 0, value: undefined }));
refreshTimeZone();
};
}
export function loadUserInfo(uid: string): AppThunk<Promise<User>> {
return async (dispatch, getState) => {
const userInfoCache = getState().globalState.userInfoCache;
if (userInfoCache[uid]) {
return userInfoCache[uid];
}
const user = await dispatch(getUserInfo(uid));
dispatch(setUserInfoCache([uid, user]));
return user;
};
}
export function signout(): AppThunk<void> {
return async (dispatch, _getState) => {
const current = SessionManager.currentLoginOrNull();
if (!current) {
return;
}
await longRunningTaskWithSnackbar(
dispatch(sendSignout({ refresh_token: current.token.refresh_token })),
"application:login.signingOut",
);
router.navigate("/session");
dispatch(closeMusicPlayer());
SessionManager.signOutCurrent();
};
}

41
src/redux/thunks/settings.ts Executable file
View File

@@ -0,0 +1,41 @@
import { AppThunk } from "../store.ts";
import { sendPinFile, sendUnpinFile, sendUpdateUserSetting } from "../../api/api.ts";
import { updateSiteConfig } from "./site.ts";
import { increasePinedGeneration } from "../globalStateSlice.ts";
import i18next from "i18next";
export function pinToSidebar(uri: string, name?: string): AppThunk<Promise<void>> {
return async (dispatch, _getState) => {
await dispatch(
sendPinFile({
uri,
name,
}),
);
await dispatch(updateSiteConfig());
dispatch(increasePinedGeneration());
};
}
export function unPinFromSidebar(uri: string): AppThunk<Promise<void>> {
return async (dispatch, _getState) => {
await dispatch(
sendUnpinFile({
uri,
}),
);
await dispatch(updateSiteConfig());
dispatch(increasePinedGeneration());
};
}
export function selectLanguage(lng: string): AppThunk<Promise<void>> {
return async (dispatch, _getState) => {
await i18next.changeLanguage(lng);
await dispatch(
sendUpdateUserSetting({
language: lng,
}),
);
};
}

227
src/redux/thunks/share.ts Executable file
View File

@@ -0,0 +1,227 @@
import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar, SnackbarKey } from "notistack";
import { getFileInfo, getFileList, getShareInfo, sendCreateShare, sendUpdateShare } from "../../api/api.ts";
import { FileResponse, Share, ShareCreateService } from "../../api/explorer.ts";
import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx";
import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.tsx";
import CrUri from "../../util/uri.ts";
import { fileUpdated } from "../fileManagerSlice.ts";
import {
addShareInfo,
closeShareReadme,
setManageShareDialog,
setShareLinkDialog,
setShareReadmeOpen,
} from "../globalStateSlice.ts";
import { AppThunk } from "../store.ts";
import { longRunningTaskWithSnackbar } from "./file.ts";
export function createOrUpdateShareLink(
index: number,
file: FileResponse,
setting: ShareSetting,
existed?: string,
): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
const req: ShareCreateService = {
uri: file.path,
is_private: setting.is_private,
password: setting.password,
share_view: setting.share_view,
show_readme: setting.show_readme,
downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined,
expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
};
const res = await dispatch(existed ? sendUpdateShare(req, existed) : sendCreateShare(req));
dispatch(
fileUpdated({
index,
value: [
{
file: { ...file, shared: true },
oldPath: file.path,
},
],
}),
);
if (existed) {
const {
globalState: { manageShareDialogOpen, manageShareDialogFile },
} = getState();
if (manageShareDialogOpen && manageShareDialogFile?.path === file.path) {
dispatch(
setManageShareDialog({
open: true,
file: {
...manageShareDialogFile,
extended_info: undefined,
},
}),
);
}
}
return res;
};
}
interface shareInfoQueueItem {
resolve: (value: Share | PromiseLike<Share>) => void;
reject: (reason?: any) => void;
}
const shareInfoLoadQueue: {
[key: string]: shareInfoQueueItem[];
} = {};
export function queueLoadShareInfo(uri: CrUri, countViews: boolean = false): AppThunk<Promise<Share>> {
return async (dispatch, getState) => {
const id = `${uri.id()}/${uri.password()}/${countViews}`;
const cached = getState().globalState.shareInfo[id];
if (cached) {
return cached;
}
if (!shareInfoLoadQueue[id]) {
shareInfoLoadQueue[id] = [];
}
const p = new Promise<Share>((resolve, reject) => {
shareInfoLoadQueue[id].push({ resolve, reject });
});
if (shareInfoLoadQueue[id].length === 1) {
dispatch(getShareInfo(uri.id(), uri.password(), countViews))
.then((res) => {
shareInfoLoadQueue[id].forEach((item) => {
item.resolve(res);
});
dispatch(addShareInfo({ id, info: res }));
})
.catch((e) => {
shareInfoLoadQueue[id].forEach((item) => {
item.reject(e);
});
})
.finally(() => {
delete shareInfoLoadQueue[id];
});
}
return p;
};
}
export function openShareEditByID(shareId: string, password?: string, singleFile?: boolean): AppThunk {
return async (dispatch) => {
try {
const { share, file } = await longRunningTaskWithSnackbar(
dispatch(getFileAndShareById(shareId, password, singleFile)),
"application:uploader.processing",
);
dispatch(
setShareLinkDialog({
open: true,
file: file,
share: share,
}),
);
} catch (e) {
console.log(e);
return;
}
};
}
// Priority from high to low
const supportedReadMeFiles = ["README.md", "README.txt"];
export function detectReadMe(index: number, isTablet: boolean): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const { files: list, pagination } = getState().fileManager[index]?.list ?? {};
if (list) {
// Find readme file from highest to lowest priority
for (const readmeFile of supportedReadMeFiles) {
const found = list.find((file) => file.name === readmeFile);
if (found) {
dispatch(tryOpenReadMe(found, isTablet));
return;
}
}
}
// Not found in current file list, try to get file directly
const path = getState().fileManager[index]?.pure_path;
const hasMorePages = getPaginationState(pagination).moreItems;
if (path && hasMorePages) {
const uri = new CrUri(path);
for (const readmeFile of supportedReadMeFiles) {
try {
const file = await dispatch(getFileInfo({ uri: uri.join(readmeFile).toString() }, true));
if (file) {
dispatch(tryOpenReadMe(file, isTablet));
return;
}
} catch (e) {}
}
}
dispatch(closeShareReadme());
};
}
let snackbarId: SnackbarKey | undefined = undefined;
function tryOpenReadMe(file: FileResponse, askForConfirmation?: boolean): AppThunk<Promise<void>> {
return async (dispatch) => {
if (askForConfirmation) {
dispatch(setShareReadmeOpen({ open: false, target: file }));
if (snackbarId) {
closeSnackbar(snackbarId);
}
snackbarId = enqueueSnackbar({
message: "README.md",
variant: "file",
file,
action: OpenReadMeAction(file),
});
} else {
dispatch(setShareReadmeOpen({ open: true, target: file }));
}
};
}
function getFileAndShareById(
shareId: string,
password?: string,
singleFile?: boolean,
): AppThunk<
Promise<{
share: Share;
file: FileResponse;
}>
> {
return async (dispatch) => {
let share: Share | undefined;
try {
share = await dispatch(getShareInfo(shareId, password, false, true));
} catch (e) {
enqueueSnackbar({
message: i18next.t("application:share.shareNotExist"),
variant: "error",
action: DefaultCloseAction,
});
throw e;
}
let file: FileResponse | undefined = undefined;
if (singleFile) {
const root = new CrUri(share.source_uri ?? "");
file = await dispatch(getFileInfo({ uri: root.join(share.name ?? "").toString() }));
} else {
file = await dispatch(getFileInfo({ uri: share.source_uri ?? "" }));
}
return { share, file };
};
}

29
src/redux/thunks/site.ts Executable file
View File

@@ -0,0 +1,29 @@
import { getSiteConfig } from "../../api/api.ts";
import SessionManager from "../../session";
import { applySetting } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
export function loadSiteConfig(section: string): AppThunk {
return async (dispatch, _getState) => {
const siteConfig = await dispatch(getSiteConfig(section));
dispatch(
applySetting({
section: section,
config: siteConfig,
}),
);
localStorage.setItem(`siteConfigCache_${section}`, JSON.stringify(siteConfig));
};
}
export function updateSiteConfig(): AppThunk {
return async (dispatch, getState) => {
await dispatch(loadSiteConfig("basic"));
const {
siteConfig: { basic },
} = getState();
if (basic.config.user) {
SessionManager.updateUserIfExist(basic.config.user);
}
};
}

763
src/redux/thunks/viewer.ts Executable file
View File

@@ -0,0 +1,763 @@
import i18next from "i18next";
import { enqueueSnackbar } from "notistack";
import { getFileEntityUrl, getFileInfo, sendCreateViewerSession, sendUpdateFile } from "../../api/api.ts";
import { FileResponse, Metadata, Viewer, ViewerAction, ViewerType } from "../../api/explorer.ts";
import { AppError, Code } from "../../api/request.ts";
import { DefaultCloseAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { canUpdate, getActionOpt } from "../../component/FileManager/ContextMenu/useActionDisplayOpt.ts";
import { FileManagerIndex } from "../../component/FileManager/FileManager.tsx";
import SessionManager, { UserSettings } from "../../session";
import { isTrueVal } from "../../session/utils.ts";
import { dataUrlToBytes, fileExtension, fileNameNoExt, getFileLinkedUri, sizeToString } from "../../util";
import { base64Encode } from "../../util/base64.ts";
import CrUri, { CrUriPrefix } from "../../util/uri.ts";
import { closeContextMenu, ContextMenuTypes, fileUpdated } from "../fileManagerSlice.ts";
import {
closeImageEditor,
setArchiveViewer,
setCodeViewer,
setCustomViewer,
setDrawIOViewer,
setEpubViewer,
setExcalidrawViewer,
setImageEditor,
setImageViewer,
setMarkdownViewer,
setMusicPlayer,
setPdfViewer,
setPhotopeaViewer,
setSearchPopup,
setSidebar,
setVideoViewer,
setViewerSelector,
setWopiViewer,
} from "../globalStateSlice.ts";
import { Viewers, ViewersByID } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
import { askSaveAs, askStaleVersionAction } from "./dialog.ts";
import { longRunningTaskWithSnackbar, refreshSingleFileSymbolicLinks } from "./file.ts";
import { uploadRawFile } from "./filemanager.ts";
export interface ExpandedViewerSetting {
[key: string]: Viewer[];
}
export const builtInViewers = {
image: "image",
photopea: "photopea",
monaco: "monaco",
drawio: "drawio",
markdown: "markdown",
video: "video",
pdf: "pdf",
epub: "epub",
music: "music",
excalidraw: "excalidraw",
archive: "archive",
};
export function openViewers(
index: number,
file: FileResponse,
size?: number,
preferredVersion?: string,
ignorePreference?: boolean,
): AppThunk {
return async (dispatch, getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
const {
siteConfig: {
explorer: { typed },
},
} = getState();
const ext = fileExtension(file.name) ?? "";
const entitySize = size ?? file.size;
// Try user preference
const userPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext);
if (!ignorePreference && userPreference && ViewersByID[userPreference]) {
dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion));
return;
}
const viewerOptions = Viewers[ext];
if (!ignorePreference && viewerOptions.length == 1) {
dispatch(openViewer(file, viewerOptions[0], entitySize, preferredVersion));
return;
}
// open viewer selection dialog
dispatch(
setViewerSelector({
open: true,
file,
entitySize,
viewers: viewerOptions,
version: preferredVersion,
}),
);
};
}
export function openViewer(
file: FileResponse,
viewer: Viewer,
size: number,
preferredVersion?: string,
forceNotOpenInNew?: boolean,
): AppThunk {
return async (dispatch, getState) => {
if (!forceNotOpenInNew && viewer.type != ViewerType.custom && isTrueVal(viewer.props?.openInNew ?? "")) {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set("viewer", viewer.id ?? "");
currentUrl.searchParams.set("version", preferredVersion ?? "");
currentUrl.searchParams.set("open", file.id ?? "");
currentUrl.searchParams.set("size", size.toString());
window.open(currentUrl.toString(), "_blank");
return;
}
// Warning for large file
if (viewer.max_size && size > viewer.max_size) {
enqueueSnackbar({
message: i18next.t("fileManager.viewerFileSizeWarning", {
file_size: sizeToString(size),
max: sizeToString(viewer.max_size),
app: i18next.t(viewer.display_name),
}),
variant: "warning",
action: DefaultCloseAction,
});
}
const isSharedFile = file.metadata?.[Metadata.share_redirect] ?? false;
const originalFileId = file.id;
if (isSharedFile) {
file = await dispatch(refreshSingleFileSymbolicLinks(file));
}
if (viewer.type == ViewerType.builtin) {
let primaryEntity = file.primary_entity;
if (isSharedFile) {
const fileInfo = await dispatch(getFileInfo({ uri: getFileLinkedUri(file) }));
primaryEntity = fileInfo.primary_entity;
}
switch (viewer.id) {
case builtInViewers.image: {
// open image viewer
const fm = getState().fileManager[FileManagerIndex.main];
const fileIndex = fm.list?.files?.findIndex((f) => f.id == originalFileId);
dispatch(setSearchPopup(false));
dispatch(
setImageViewer({
open: true,
index: fileIndex,
file,
exts: viewer.exts,
version: preferredVersion,
}),
);
break;
}
case builtInViewers.photopea:
dispatch(
setPhotopeaViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.monaco:
dispatch(
setCodeViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.drawio:
dispatch(
setDrawIOViewer({
open: true,
file,
version: preferredVersion,
host: viewer.props?.host,
}),
);
break;
case builtInViewers.markdown:
dispatch(
setMarkdownViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.excalidraw:
dispatch(
setExcalidrawViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.video:
dispatch(
setVideoViewer({
open: true,
file,
version: preferredVersion,
}),
);
break;
case builtInViewers.pdf:
dispatch(
setPdfViewer({
open: true,
file,
version: preferredVersion,
}),
);
break;
case builtInViewers.epub:
dispatch(
setEpubViewer({
open: true,
file,
version: preferredVersion,
}),
);
break;
case builtInViewers.archive:
dispatch(
setArchiveViewer({
open: true,
file,
version: preferredVersion,
}),
);
break;
case builtInViewers.music: {
// open image viewer
const fm = getState().fileManager[FileManagerIndex.main];
let fileIndex = -1;
let files: FileResponse[] = [];
if (preferredVersion) {
fileIndex = 0;
files = [file];
} else {
fm.list?.files?.forEach((f) => {
if (f.id == originalFileId) {
fileIndex = files.length;
f = { ...file };
}
if (viewer.exts.indexOf(fileExtension(f.name) ?? "") > -1) {
files.push(f);
}
});
}
if (fileIndex >= 0) {
dispatch(
setMusicPlayer({
files: files,
startIndex: fileIndex,
version: preferredVersion,
}),
);
}
break;
}
}
} else if (viewer.type == ViewerType.wopi) {
return dispatch(openWopiViewer(file, viewer, preferredVersion));
} else if (viewer.type == ViewerType.custom) {
return dispatch(openCustomViewer(file, viewer, preferredVersion));
}
};
}
export function openCustomViewer(file: FileResponse, viewer: Viewer, preferredVersion?: string): AppThunk {
return async (dispatch, _getState) => {
const entityUrl = await longRunningTaskWithSnackbar(
dispatch(
getFileEntityUrl({
uris: [getFileLinkedUri(file)],
entity: preferredVersion,
use_primary_site_url: true,
}),
),
"fileManager.preparingOpenFile",
);
const currentUser = SessionManager.currentUser();
const vars: { [key: string]: string } = {
src: encodeURIComponent(entityUrl.urls[0].url),
src_raw: entityUrl.urls[0].url,
src_raw_base64: base64Encode(entityUrl.urls[0].url),
name: encodeURIComponent(file.name),
version: preferredVersion ? preferredVersion : "",
id: file.id,
user_id: currentUser?.id ?? "",
user_display_name: encodeURIComponent(currentUser?.nickname ?? ""),
};
// replace variables in viewer.url
let url = viewer.url;
if (!url) {
console.error("Viewer URL not set");
return;
}
for (const key in vars) {
url = url.replace(`{$${key}}`, vars[key]);
}
// if url matches custom scheme pattern like nplayer://xxx, use window.location.assign
if (/^(?!https?:\/\/)[a-zA-Z0-9]+:\/\//.test(url)) {
window.location.assign(url);
return;
}
if (isTrueVal(viewer.props?.openInNew ?? "")) {
window.window.open(url);
return;
}
// open viewer
dispatch(setCustomViewer({ open: true, url, file, version: preferredVersion }));
};
}
export function openWopiViewer(file: FileResponse, viewer: Viewer, preferredVersion?: string): AppThunk {
return async (dispatch, _getState) => {
const displayOpt = getActionOpt([file], Viewers, ContextMenuTypes.file);
const action = !preferredVersion && canUpdate(displayOpt) ? ViewerAction.edit : ViewerAction.view;
const viewerSession = await longRunningTaskWithSnackbar(
dispatch(
sendCreateViewerSession({
uri: getFileLinkedUri(file),
viewer_id: viewer.id,
preferred_action: action,
version: preferredVersion,
}),
),
"fileManager.preparingOpenFile",
);
if (!viewerSession.wopi_src) {
return;
}
dispatch(
setWopiViewer({
open: true,
src: viewerSession.wopi_src,
session: viewerSession.session,
file: file,
version: preferredVersion,
}),
);
};
}
export function onImageViewerIndexChange(file: FileResponse): AppThunk {
return async (dispatch, getState) => {
const {
globalState: { sidebarOpen },
} = getState();
if (sidebarOpen) {
dispatch(
setSidebar({
open: true,
target: file,
}),
);
}
};
}
export function switchToImageEditor(file: FileResponse, version?: string): AppThunk {
return async (dispatch, getState) => {
const {
globalState: { imageViewer },
} = getState();
if (!imageViewer) {
return;
}
const isSharedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (isSharedFile) {
const fileInfo = await dispatch(getFileInfo({ uri: getFileLinkedUri(file) }));
version = fileInfo.primary_entity;
}
dispatch(
setImageViewer({
...imageViewer,
open: false,
}),
);
dispatch(
setImageEditor({
open: true,
file,
version: version ?? file.primary_entity,
}),
);
};
}
export function switchToImageViewer(): AppThunk {
return async (dispatch, getState) => {
const {
globalState: { imageViewer },
} = getState();
dispatch(closeImageEditor());
if (imageViewer) {
dispatch(setImageViewer({ ...imageViewer, open: true }));
}
};
}
export function saveImage(name: string, data: string, file: FileResponse, version?: string): AppThunk {
return async (dispatch, getState) => {
if (!version) {
version = file.primary_entity;
}
const isSharedFile = file.metadata?.[Metadata.share_redirect] ?? false;
let originFileUri = new CrUri(getFileLinkedUri(file));
if (name != file.name) {
if (isSharedFile) {
// For symbolic link, we need to save to the same folder as the link
originFileUri = new CrUri(file.path);
}
originFileUri = originFileUri.parent().join(name);
}
const savedImageFile = await dispatch(saveFile(originFileUri.toString(), await dataUrlToBytes(data), version));
if (savedImageFile) {
const {
globalState: { imageViewer },
} = getState();
if (imageViewer) {
dispatch(
setImageViewer({
...imageViewer,
file: name != file.name ? file : savedImageFile,
}),
);
}
}
};
}
export function saveFile(
uri: string,
data: any,
version?: string,
saveAsNew?: boolean,
ignoreSnackbar?: boolean,
): AppThunk<Promise<FileResponse | undefined>> {
return async (dispatch, _getState): Promise<FileResponse | undefined> => {
let savedFile: FileResponse | undefined;
if (saveAsNew) {
try {
const fileName = new CrUri(uri).elements().pop();
if (fileName) {
const saveAsDst = await dispatch(askSaveAs(fileName));
const dst = new CrUri(saveAsDst.uri).join(saveAsDst.name);
uri = dst.toString();
}
} catch (e) {
return;
}
}
try {
savedFile = await dispatch(
sendUpdateFile(
{
uri: uri,
previous: version,
},
data,
),
);
} catch (e) {
if (e instanceof AppError && e.code == Code.StaleVersion) {
// Handle version conflict
try {
const opt = await dispatch(askStaleVersionAction(uri));
if (opt.overwrite) {
return await dispatch(saveFile(uri, data));
} else if (opt.saveAs) {
return await dispatch(saveFile(opt.saveAs, data, version));
}
} catch (e) {
// Cancel save action
throw e;
}
}
}
if (!savedFile) {
return;
}
if (!ignoreSnackbar) {
enqueueSnackbar({
message: i18next.t("fileManager.fileSaved"),
variant: "success",
action: DefaultCloseAction,
});
}
dispatch(
fileUpdated({
index: FileManagerIndex.main,
value: [
{
oldPath: uri,
file: savedFile,
},
],
}),
);
return savedFile;
};
}
export function savePhotopea(data: ArrayBuffer, file: FileResponse, version?: string, saveAsNew?: boolean): AppThunk {
return async (dispatch, getState) => {
if (!version) {
version = file.primary_entity;
}
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, version, saveAsNew));
if (savedFile) {
const {
globalState: { photopeaViewer },
} = getState();
if (photopeaViewer) {
dispatch(
setPhotopeaViewer({
...photopeaViewer,
file: savedFile,
version: savedFile.primary_entity,
}),
);
}
}
};
}
export function saveCode(
data: string,
file: FileResponse,
version?: string,
saveAsNew?: boolean,
): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const isLinkedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (!version && !isLinkedFile) {
version = file.primary_entity;
}
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, version, saveAsNew));
if (savedFile) {
const {
globalState: { codeViewer },
} = getState();
if (codeViewer) {
dispatch(
setCodeViewer({
...codeViewer,
file: savedFile,
version: savedFile.primary_entity,
}),
);
}
}
};
}
export function saveDrawIO(
data: string,
file: FileResponse,
saveAsNew?: boolean,
): AppThunk<Promise<FileResponse | undefined>> {
return async (dispatch, getState): Promise<FileResponse | undefined> => {
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, undefined, saveAsNew, true));
if (savedFile) {
const {
globalState: { drawIOViewer },
} = getState();
if (drawIOViewer) {
dispatch(
setDrawIOViewer({
...drawIOViewer,
file: savedFile,
}),
);
}
}
return savedFile;
};
}
export function saveExcalidraw(
data: string,
file: FileResponse,
version?: string,
saveAsNew?: boolean,
): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const isLinkedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (!version && !isLinkedFile) {
version = file.primary_entity;
}
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, version, saveAsNew));
if (savedFile) {
const {
globalState: { excalidrawViewer },
} = getState();
if (excalidrawViewer) {
dispatch(
setExcalidrawViewer({
...excalidrawViewer,
file: savedFile,
version: savedFile.primary_entity,
}),
);
}
}
};
}
export function saveMarkdown(
data: string,
file: FileResponse,
version?: string,
saveAsNew?: boolean,
): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const isLinkedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (!version && !isLinkedFile) {
version = file.primary_entity;
}
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, version, saveAsNew));
if (savedFile) {
const {
globalState: { markdownViewer },
} = getState();
if (markdownViewer) {
dispatch(
setMarkdownViewer({
...markdownViewer,
file: savedFile,
version: savedFile.primary_entity,
}),
);
}
}
};
}
const subtitleSuffix = ["ass", "srt", "vrr"];
export function findSubtitleOptions(): AppThunk<FileResponse[]> {
return (_dispatch, getState): FileResponse[] => {
const {
globalState: { videoViewer },
fileManager,
} = getState();
if (!videoViewer || !videoViewer.file) {
return [];
}
const fm = fileManager[FileManagerIndex.main];
const fileNameMatch = fileNameNoExt(videoViewer.file.name) + ".";
const options = fm.list?.files
.filter((f) => {
return subtitleSuffix.indexOf(fileExtension(f.name) ?? "") !== -1;
})
.sort((a, b) => {
return a.name.startsWith(fileNameMatch) && !b.name.startsWith(fileNameMatch) ? -1 : 0;
});
return options ?? [];
};
}
const BROKEN_IMG_URI =
"data:image/svg+xml;charset=utf-8," +
encodeURIComponent(/* xml */ `
<svg id="imgLoadError" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="red" stroke-width="4" stroke-dasharray="4" />
<text x="50" y="55" text-anchor="middle" font-size="20" fill="red">⚠️</text>
</svg>
`);
export function markdownImagePreviewHandler(imageSource: string, mdFileUri: string): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
// For URl, return the image source
if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) {
return imageSource;
}
let uri = new CrUri(mdFileUri)?.parent();
if (imageSource.startsWith(CrUriPrefix)) {
uri = new CrUri(imageSource);
} else if (uri) {
uri = uri.join_raw(imageSource);
} else {
return imageSource;
}
try {
const file = await dispatch(getFileInfo({ uri: uri.toString() }, true));
const fileUrl = await dispatch(getFileEntityUrl({ uris: [getFileLinkedUri(file)] }));
return fileUrl.urls[0].url;
} catch (e) {
return BROKEN_IMG_URI;
}
};
}
export function markdownImageAutocompleteSuggestions(): AppThunk<string[] | null> {
return (_dispatch, getState) => {
const files = getState().fileManager[FileManagerIndex.main]?.list?.files;
if (!files) {
return null;
}
const suggestions = files.filter((f) => {
const ext = fileExtension(f.name);
return ViewersByID[builtInViewers.image]?.exts.indexOf(ext ?? "") !== -1;
});
return suggestions.map((f) => f.name);
};
}
export function uploadMarkdownImage(file: File): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
const task = await dispatch(uploadRawFile(file));
return task.name;
};
}