Compare commits

...

9 Commits

Author SHA1 Message Date
ammarahm-ed
8f3d7e9954 mobile: check if file exists based on file size 2023-09-21 13:42:34 +05:00
Abdullah Atta
45f705081b core: increment progress even if file exists 2023-09-21 13:42:12 +05:00
Abdullah Atta
777fae3cab web: improve attachment progress events & ui 2023-09-21 13:42:12 +05:00
Abdullah Atta
06e76a4d19 web: check file size when checking for file existence 2023-09-21 13:42:12 +05:00
Abdullah Atta
eca9ddc5a7 core: check for file existence before calling downloadFile 2023-09-21 13:42:12 +05:00
ammarahm-ed
2ad9855cf2 mobile: add support for progress 2023-09-21 13:42:12 +05:00
ammarahm-ed
3893bf1553 core: add download start & canceled events 2023-09-21 13:40:36 +05:00
Abdullah Atta
5b568e55a0 web: use new file upload/download events 2023-09-21 13:40:36 +05:00
Abdullah Atta
ab0e226668 core: fix download/upload cancellation 2023-09-21 13:40:36 +05:00
23 changed files with 565 additions and 297 deletions

View File

@@ -24,7 +24,7 @@ import { ToastEvent } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { db } from "../database";
import { cacheDir, fileCheck } from "./utils";
import { createCacheDir } from "./io";
import { createCacheDir, exists } from "./io";
export async function downloadFile(filename, data, cancelToken) {
if (!data) return false;
@@ -32,11 +32,10 @@ export async function downloadFile(filename, data, cancelToken) {
await createCacheDir();
let { url, headers } = data;
let path = `${cacheDir}/${filename}`;
try {
let exists = await RNFetchBlob.fs.exists(path);
if (exists) {
if (await exists(filename)) {
return true;
}
@@ -74,22 +73,24 @@ export async function downloadFile(filename, data, cancelToken) {
useAttachmentStore.getState().remove(filename);
return status >= 200 && status < 300;
} catch (e) {
ToastEvent.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "global"
});
ToastEvent.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "local"
});
if (e.message !== "canceled") {
ToastEvent.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "global"
});
ToastEvent.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "local"
});
}
useAttachmentStore.getState().remove(filename);
RNFetchBlob.fs.unlink(path).catch(console.log);
console.log("download file error: ", e, url, headers);
console.log("Download file error:", e, url, headers);
return false;
}
}

View File

@@ -29,18 +29,9 @@ export async function readEncrypted(filename, key, cipherData) {
await migrateFilesFromCache();
console.log("read encrypted file...");
let path = `${cacheDir}/${filename}`;
try {
const iosAppGroup =
Platform.OS === "ios"
? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
let exists =
(await RNFetchBlob.fs.exists(path)) ||
(Platform.OS === "ios" && (await RNFetchBlob.fs.exists(appGroupPath)));
if (!exists) {
if (!(await exists(filename))) {
return false;
} else {
RNFetchBlob.fs.stat(path).then((r) => {
@@ -181,7 +172,42 @@ export async function migrateFilesFromCache() {
}
}
const ABYTES = 17;
export async function exists(filename) {
let exists = await RNFetchBlob.fs.exists(`${cacheDir}/${filename}`);
let path = `${cacheDir}/${filename}`;
const iosAppGroup =
Platform.OS === "ios"
? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
let exists = await RNFetchBlob.fs.exists(path);
// Check if file is present in app group path.
let existsInAppGroup = false;
if (!exists && Platform.OS === "ios") {
existsInAppGroup = await RNFetchBlob.fs.exists(appGroupPath);
}
if (exists || existsInAppGroup) {
const attachment = db.attachments.attachment(filename);
const totalChunks = Math.ceil(attachment.length / attachment.chunkSize);
const totalAbytes = totalChunks * ABYTES;
const expectedFileSize = attachment.length + totalAbytes;
const stat = await RNFetchBlob.fs.stat(
existsInAppGroup ? appGroupPath : path
);
if (stat.size !== expectedFileSize) {
RNFetchBlob.fs
.unlink(existsInAppGroup ? appGroupPath : path)
.catch(console.log);
return false;
}
exists = true;
}
return exists;
}

View File

@@ -132,7 +132,7 @@ export const AttachmentItem = ({
activeOpacity={0.9}
onPress={() => {
if (encryption || !pressable) return;
db.fs.cancel(attachment.metadata.hash);
db.fs.cancel(attachment.metadata.hash, currentProgress.type);
setCurrentProgress(null);
}}
style={{

View File

@@ -40,7 +40,9 @@ import { requestInAppReview } from "../../../services/app-review";
const PublishNoteSheet = ({ note: item, update }) => {
const { colors } = useThemeColors();
const actionSheetRef = useRef();
const loading = useAttachmentStore((state) => state.loading);
const attachmentDownloads = useAttachmentStore((state) => state.downloading);
const downloading = attachmentDownloads[`monograph-${item.id}`];
const [selfDestruct, setSelfDestruct] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [note, setNote] = useState(item);
@@ -137,9 +139,9 @@ const PublishNoteSheet = ({ note: item, update }) => {
}}
>
Please wait...
{loading && loading.current && loading.total
{downloading && downloading.current && downloading.total
? `\nDownloading attachments (${
loading?.current / loading?.total
downloading?.current / downloading?.total
})`
: ""}
</Paragraph>

View File

@@ -83,7 +83,6 @@ import {
import { getGithubVersion } from "../utils/github-version";
import { tabBarRef } from "../utils/global-refs";
import { sleep } from "../utils/time";
import { useThemeColors } from "@notesnook/theme";
const onCheckSyncStatus = async (type) => {
const { disableSync, disableAutoSync } = SettingsService.get();
@@ -107,10 +106,20 @@ const onFileEncryptionProgress = ({ total, progress }) => {
.setEncryptionProgress((progress / total).toFixed(2));
};
const onLoadingAttachmentProgress = (data) => {
useAttachmentStore
.getState()
.setLoading(data.total === data.current ? null : data);
const onDownloadingAttachmentProgress = (data) => {
useAttachmentStore.getState().setDownloading(data);
};
const onUploadingAttachmentProgress = (data) => {
useAttachmentStore.getState().setUploading(data);
};
const onDownloadedAttachmentProgress = (data) => {
useAttachmentStore.getState().setDownloading(data);
};
const onUploadedAttachmentProgress = (data) => {
useAttachmentStore.getState().setUploading(data);
};
const onUserSessionExpired = async () => {
@@ -301,7 +310,16 @@ export const useAppEvents = () => {
EVENTS.userSubscriptionUpdated,
onUserSubscriptionStatusChanged
),
EV.subscribe(EVENTS.attachmentsLoading, onLoadingAttachmentProgress),
EV.subscribe(EVENTS.fileDownload, onDownloadingAttachmentProgress),
EV.subscribe(EVENTS.fileUpload, onUploadingAttachmentProgress),
EV.subscribe(EVENTS.fileDownloaded, onDownloadedAttachmentProgress),
EV.subscribe(EVENTS.fileUploaded, onUploadedAttachmentProgress),
EV.subscribe(EVENTS.downloadCanceled, (data) => {
useAttachmentStore.getState().setDownloading(data);
}),
EV.subscribe(EVENTS.uploadCanceled, (data) => {
useAttachmentStore.getState().setUploading(data);
}),
eSubscribeEvent(eUserLoggedIn, onUserUpdated)
];

View File

@@ -1,81 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { ProgressBarComponent } from "../../components/ui/svg/lazy";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { useThemeColors } from "@notesnook/theme";
import { SIZE } from "../../utils/size";
export const ProgressBar = () => {
const { colors } = useThemeColors();
const loading = useAttachmentStore((state) => state.loading);
const [prog, setProg] = useState(0);
const [visible, setVisible] = useState(false);
const timer = useRef();
const insets = useGlobalSafeAreaInsets();
const [width, setWidth] = useState(false);
useEffect(() => {
if (loading) {
if (loading.current !== loading.total) {
setVisible(true);
setProg(loading.current / loading.total);
} else {
clear();
}
} else {
clear();
}
}, [loading]);
const clear = () => {
clearTimeout(timer.current);
timer.current = null;
timer.current = setTimeout(() => {
setProg(1);
setTimeout(() => {
setVisible(false);
}, 1000);
}, 100);
};
return visible ? (
<View
style={{
justifyContent: "center",
position: "absolute",
zIndex: 1,
marginTop: insets.top + 45,
width: "100%"
}}
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
>
<ProgressBarComponent
size={SIZE.xxl}
progress={prog}
color={colors.primary.accent}
borderWidth={0}
height={1}
width={width || 400}
/>
</View>
) : null;
};

View File

@@ -0,0 +1,136 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { ProgressBarComponent } from "../../components/ui/svg/lazy";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { useEditorStore } from "../../stores/use-editor-store";
export const ProgressBar = () => {
const { colors } = useThemeColors();
const currentlyEditingNote = useEditorStore(
(state) => state.currentEditingNote
);
const downloading = useAttachmentStore((state) => state.downloading);
const loading = currentlyEditingNote
? downloading?.[currentlyEditingNote]
: undefined;
const attachmentProgress = useAttachmentStore((state) => state.progress);
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const timer = useRef<NodeJS.Timeout>();
const insets = useGlobalSafeAreaInsets();
const [width, setWidth] = useState(400);
const groupProgressInfo = useRef<{
[name: string]: {
total: number;
current: number;
};
}>({});
const currentItemProgress = loading?.filename
? attachmentProgress?.[loading?.filename]
: undefined;
useEffect(() => {
if (loading) {
console.log(loading);
if (
loading.current === loading.total &&
typeof loading.success === "boolean"
) {
clear();
return;
}
setVisible(true);
if (!loading.filename) return;
if (!groupProgressInfo.current[loading.filename]) {
groupProgressInfo.current[loading.filename] = {
total: 1,
current: 0
};
}
const itemProgressCurrent = groupProgressInfo.current[loading.filename];
const itemTotalSize =
currentItemProgress?.total || itemProgressCurrent.total;
const itemCurrentProgress =
currentItemProgress?.recieved ||
currentItemProgress?.sent ||
itemProgressCurrent.current;
groupProgressInfo.current[loading.filename] = {
current: itemCurrentProgress,
total: itemTotalSize
};
const itemProgressPercent = itemCurrentProgress / itemTotalSize;
setProgress(
((loading.current || 0) + itemProgressPercent) / (loading.total || 1)
);
} else {
clear();
}
}, [currentItemProgress, loading]);
const clear = () => {
clearTimeout(timer.current);
timer.current = undefined;
timer.current = setTimeout(() => {
setProgress(1);
setTimeout(() => {
setVisible(false);
setProgress(0);
groupProgressInfo.current = {};
}, 1000);
}, 0);
};
return (
<View
style={{
justifyContent: "center",
position: "absolute",
zIndex: visible ? 1 : -1,
marginTop: insets.top + 45,
width: "100%"
}}
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
>
<ProgressBarComponent
progress={progress}
color={colors.primary.accent}
borderWidth={0}
borderRadius={0}
height={2}
width={width || 400}
/>
</View>
);
};

View File

@@ -134,7 +134,8 @@ export const useEditor = (
const reset = useCallback(
async (resetState = true, resetContent = true) => {
currentNote.current?.id && db.fs?.cancel(currentNote.current.id, null);
currentNote.current?.id &&
db.fs?.cancel(currentNote.current.id, "download");
currentNote.current = null;
loadedImages.current = {};
currentContent.current = null;

View File

@@ -20,6 +20,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import create from "zustand";
import { editorController } from "../screens/editor/tiptap/utils";
export type AttachmentGroupProgress = {
total: number;
current: number;
groupId: string;
filename: string;
canceled?: boolean;
success?: boolean;
error?: any;
};
interface AttachmentStore {
progress?: {
[name: string]: {
@@ -30,6 +40,7 @@ interface AttachmentStore {
type: "upload" | "download";
} | null;
};
encryptionProgress: number;
setEncryptionProgress: (encryptionProgress: number) => void;
remove: (hash: string) => void;
@@ -40,8 +51,14 @@ interface AttachmentStore {
recieved: number,
type: "upload" | "download"
) => void;
loading: { total: number; current: number };
setLoading: (data: { total: number; current: number }) => void;
downloading?: {
[groupId: string]: AttachmentGroupProgress | undefined;
};
setDownloading: (data: AttachmentGroupProgress) => void;
uploading?: {
[groupId: string]: AttachmentGroupProgress | undefined;
};
setUploading: (data: AttachmentGroupProgress) => void;
}
export const useAttachmentStore = create<AttachmentStore>((set, get) => ({
@@ -73,6 +90,21 @@ export const useAttachmentStore = create<AttachmentStore>((set, get) => ({
encryptionProgress: 0,
setEncryptionProgress: (encryptionProgress) =>
set({ encryptionProgress: encryptionProgress }),
loading: { total: 0, current: 0 },
setLoading: (data) => set({ loading: data ? { ...data } : data })
downloading: {},
setDownloading: (data) =>
set({
downloading: {
...get().downloading,
[data.groupId]: data?.canceled ? undefined : data
}
}),
uploading: {},
setUploading: (data) =>
set({
uploading: {
...get().uploading,
[data.groupId]: data?.canceled ? undefined : data
}
})
}));

View File

@@ -122,28 +122,47 @@ export default function AppEffects({ setShow }: AppEffectsProps) {
// }
// );
const attachmentsLoadingEvent = EV.subscribe(
EVENTS.attachmentsLoading,
({
type,
total,
current
}: {
type: ProcessingType;
total: number;
current: number;
}) => {
const [key, status] = getProcessingStatusFromType(type);
function handleDownloadUploadProgress(
type: ProcessingType,
total: number,
current: number
) {
const [key, status] = getProcessingStatusFromType(type);
if (current === total) {
removeStatus(key);
} else {
updateStatus({
key,
status: `${status} attachments (${current}/${total})`,
progress: 0
});
}
console.log("handleDownloadUploadProgresss", key, status, current, total);
if (current === total) {
removeStatus(key);
} else {
updateStatus({
key,
status: `${status} attachments`,
current,
total,
progress: 0
});
}
}
const fileDownloadEvents = EV.subscribeMulti(
[EVENTS.fileDownloaded, EVENTS.fileDownload],
({ total, current }: { total: number; current: number }) => {
handleDownloadUploadProgress("download", total, current);
},
null
);
const fileUploadEvents = EV.subscribeMulti(
[EVENTS.fileUploaded, EVENTS.fileUpload],
({ total, current }: { total: number; current: number }) => {
handleDownloadUploadProgress("upload", total, current);
},
null
);
const fileEncrypted = AppEventManager.subscribe(
AppEvents.fileEncrypted,
({ total, current }: { total: number; current: number }) => {
handleDownloadUploadProgress("encrypt", total, current);
}
);
@@ -162,20 +181,36 @@ export default function AppEffects({ setShow }: AppEffectsProps) {
if (!key) return;
const percent = Math.round((loaded / total) * 100);
const text = getStatus(key)?.status || `${status} attachment`;
const oldStatus = getStatus(key);
const text = oldStatus?.status || `${status} attachment`;
updateStatus({
key,
status: text,
progress: loaded === total ? 100 : percent
});
if (
(!oldStatus ||
(oldStatus.total === undefined &&
oldStatus.current === undefined) ||
oldStatus.total === oldStatus.current) &&
loaded === total
) {
removeStatus(key);
} else {
updateStatus({
...oldStatus,
key,
status: text,
progress: loaded === total ? 100 : percent
});
}
}
);
registerKeyMap();
return () => {
attachmentsLoadingEvent.unsubscribe();
progressEvent.unsubscribe();
[
...fileDownloadEvents,
...fileUploadEvents,
progressEvent,
fileEncrypted
].forEach((e) => e.unsubscribe());
// systemTimeInvalidEvent.unsubscribe();
};
}, []);

View File

@@ -24,6 +24,7 @@ export const AppEvents = {
UPDATE_ATTACHMENT_PROGRESS: "updateAttachmentProgress",
UPDATE_STATUS: "updateStatus",
REMOVE_STATUS: "removeStatus",
fileEncrypted: "file:encrypted",
checkingForUpdate: "checkingForUpdate",
updateAvailable: "updateAvailable",

View File

@@ -49,17 +49,17 @@ function PublishView(props) {
}, [noteId]);
useEffect(() => {
const attachmentsLoadingEvent = EV.subscribe(
EVENTS.attachmentsLoading,
({ type, groupId, total, current }) => {
if (!groupId || !groupId.includes(noteId) || type !== "download")
return;
const fileDownloadedEvent = EV.subscribe(
EVENTS.fileDownloaded,
({ total, current, groupId }) => {
if (!groupId || !groupId.includes(noteId)) return;
if (current === total) setProcessingStatus();
else setProcessingStatus({ total, current });
}
);
return () => {
attachmentsLoadingEvent.unsubscribe();
fileDownloadedEvent.unsubscribe();
};
}, [noteId]);

View File

@@ -40,7 +40,7 @@ import {
showIssueDialog,
showUpdateAvailableNotice
} from "../../common/dialog-controller";
import useStatus from "../../hooks/use-status";
import useStatus, { statusToString } from "../../hooks/use-status";
import { ScopedThemeProvider } from "../theme-provider";
import { checkForUpdate, installUpdate } from "../../utils/updater";
import { toTitleCase } from "@notesnook/common";
@@ -132,7 +132,8 @@ function StatusBar() {
Report an issue
</Text>
</Button>
{statuses?.map(({ key, status, progress, icon: Icon }) => {
{statuses?.map((status) => {
const { key, icon: Icon } = status;
return (
<Flex
key={key}
@@ -141,7 +142,7 @@ function StatusBar() {
>
{Icon ? <Icon size={12} /> : <Loading size={12} />}
<Text variant="subBody" ml={1} sx={{ color: "paragraph" }}>
{progress ? `${progress}% ${status}` : status}
{statusToString(status)}
</Text>
</Flex>
);

View File

@@ -24,6 +24,8 @@ import { Icon } from "../components/icons";
type Status = {
key: string;
status: string;
total?: number;
current?: number;
progress?: number;
icon?: Icon | null;
};
@@ -36,13 +38,20 @@ interface IStatusStore {
const useStatusStore = create<IStatusStore>((set, get) => ({
statuses: {},
getStatus: (key: string) => get().statuses[key],
updateStatus: ({ key, status, progress, icon }: Status) =>
updateStatus: ({ key, status, progress, icon, current, total }: Status) =>
set(
produce((state) => {
if (!key) return;
const { statuses } = state;
const statusText = status || statuses[key]?.status;
statuses[key] = { key, status: statusText, progress, icon };
statuses[key] = {
current,
total,
key,
status: statusText,
progress,
icon
};
})
),
removeStatus: (key) =>
@@ -63,3 +72,12 @@ export default function useStatus() {
export const updateStatus = useStatusStore.getState().updateStatus;
export const removeStatus = useStatusStore.getState().removeStatus;
export const getStatus = useStatusStore.getState().getStatus;
export function statusToString(status: Status) {
const parts: string[] = [];
if (status.progress) parts.push(`${status.progress}%`);
parts.push(status.status);
if (status.total !== undefined && status.current !== undefined)
parts.push(`(${status.current}/${status.total})`);
return parts.join(" ");
}

View File

@@ -24,7 +24,6 @@ import { AppEventManager, AppEvents } from "../common/app-events";
import { StreamableFS } from "@notesnook/streamable-fs";
import { NNCrypto } from "./nncrypto";
import hosts from "@notesnook/core/dist/utils/constants";
import { sendAttachmentsProgressEvent } from "@notesnook/core/dist/common";
import { saveAs } from "file-saver";
import { showToast } from "../utils/toast";
import { db } from "../common/db";
@@ -61,7 +60,11 @@ async function writeEncryptedFile(
// let offset = 0;
// let encrypted = 0;
const fileHandle = await streamablefs.createFile(hash, file.size, file.type);
sendAttachmentsProgressEvent("encrypt", hash, 1, 0);
AppEventManager.publish(AppEvents.fileEncrypted, {
hash,
total: 1,
current: 0
});
const { iv, stream } = await NNCrypto.createEncryptionStream(key);
await file
@@ -82,7 +85,11 @@ async function writeEncryptedFile(
)
.pipeTo(fileHandle.writeable);
sendAttachmentsProgressEvent("encrypt", hash, 1, 1);
AppEventManager.publish(AppEvents.fileEncrypted, {
hash,
total: 1,
current: 1
});
return {
chunkSize: CHUNK_SIZE,
@@ -504,8 +511,12 @@ async function downloadFile(filename: string, requestOptions: RequestOptions) {
}
}
function exists(filename: string) {
return streamablefs.exists(filename);
async function exists(filename: string) {
const handle = await streamablefs.readFile(filename);
return (
handle &&
handle.file.size === (await handle.size()) - handle.file.chunks * ABYTES
);
}
type FileMetadata = {

View File

@@ -21,7 +21,6 @@ import {
checkSyncStatus,
EV,
EVENTS,
sendAttachmentsProgressEvent,
sendSyncProgressEvent,
SYNC_CHECK_IDS
} from "../../common";
@@ -339,23 +338,10 @@ class Sync {
const attachments = this.db.attachments.pending;
this.logger.info("Uploading attachments...", { total: attachments.length });
for (var i = 0; i < attachments.length; ++i) {
const attachment = attachments[i];
const { hash } = attachment.metadata;
sendAttachmentsProgressEvent("upload", hash, attachments.length, i);
try {
const isUploaded = await this.db.fs.uploadFile(hash, hash);
if (!isUploaded) throw new Error("Failed to upload file.");
await this.db.attachments.markAsUploaded(attachment.id);
} catch (e) {
logger.error(e, { attachment });
const error = e.message;
await this.db.attachments.markAsFailed(attachment.id, error);
}
}
sendAttachmentsProgressEvent("upload", null, attachments.length);
await this.db.fs.queueUploads(
attachments.map((a) => ({ filename: a.metadata.hash })),
"sync-uploads"
);
}
/**

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import Collection from "./collection";
import { getId } from "../utils/id";
import { deleteItem, hasItem } from "../utils/array";
import { EV, EVENTS, sendAttachmentsProgressEvent } from "../common";
import { EV, EVENTS } from "../common";
import dataurl from "../utils/dataurl";
import dayjs from "dayjs";
import setManipulator from "../utils/set";
@@ -34,6 +34,36 @@ export default class Attachments extends Collection {
constructor(db, name, cached) {
super(db, name, cached);
this.key = null;
EV.subscribe(
EVENTS.fileDownloaded,
async ({ success, filename, groupId, eventData }) => {
if (!success || !eventData || !eventData.readOnDownload) return;
const attachment = this.attachment(filename);
if (!attachment) return;
const src = await this.read(filename, getOutputType(attachment));
if (!src) return;
EV.publish(EVENTS.mediaAttachmentDownloaded, {
groupId,
hash: attachment.metadata.hash,
attachmentType: getAttachmentType(attachment),
src
});
}
);
EV.subscribe(EVENTS.fileUploaded, async ({ success, error, filename }) => {
const attachment = this.attachment(filename);
if (!attachment) return;
if (success) await this.markAsUploaded(attachment.id);
else
await this.markAsFailed(
attachment.id,
error || "Failed to upload attachment."
);
});
}
merge(localAttachment, remoteAttachment) {
@@ -317,47 +347,15 @@ export default class Attachments extends Collection {
(!hashesToLoad || hasItem(hashesToLoad, attachment.metadata.hash))
);
try {
for (let i = 0; i < attachments.length; i++) {
const attachment = attachments[i];
await this._download(attachment, {
total: attachments.length,
current: i,
groupId: noteId
});
}
} finally {
sendAttachmentsProgressEvent("download", noteId, attachments.length);
}
}
async _download(attachment, { total, current, groupId }, notify = true) {
const { metadata, chunkSize } = attachment;
const filename = metadata.hash;
if (notify)
sendAttachmentsProgressEvent("download", groupId, total, current);
const isDownloaded = await this._db.fs.downloadFile(
groupId,
filename,
chunkSize,
metadata
await this._db.fs.queueDownloads(
attachments.map((a) => ({
filename: a.metadata.hash,
metadata: a.metadata,
chunkSize: a.chunkSize
})),
noteId,
{ readOnDownload: true }
);
if (!isDownloaded) return;
const src = await this.read(metadata.hash, getOutputType(attachment));
if (!src) return;
if (notify)
EV.publish(EVENTS.mediaAttachmentDownloaded, {
groupId,
hash: metadata.hash,
attachmentType: getAttachmentType(attachment),
src
});
return src;
}
async cleanup() {
@@ -451,7 +449,7 @@ export default class Attachments extends Collection {
}
}
function getOutputType(attachment) {
export function getOutputType(attachment) {
if (attachment.metadata.type === "application/vnd.notesnook.web-clip")
return "text";
else if (attachment.metadata.type.startsWith("image/")) return "base64";

View File

@@ -21,6 +21,7 @@ import Collection from "./collection";
import { getId } from "../utils/id";
import { getContentFromData } from "../content-types";
import { hasItem } from "../utils/array";
import { getOutputType } from "./attachments";
export default class Content extends Collection {
async add(content) {
@@ -116,17 +117,27 @@ export default class Content extends Collection {
async downloadMedia(groupId, contentItem, notify = true) {
const content = getContentFromData(contentItem.type, contentItem.data);
contentItem.data = await content.insertMedia((hash, { total, current }) => {
const attachment = this._db.attachments.attachment(hash);
if (!attachment) return;
const progressData = {
total,
current,
groupId
};
return this._db.attachments._download(attachment, progressData, notify);
contentItem.data = await content.insertMedia(async (hashes) => {
const attachments = hashes.map((h) => this._db.attachments.attachment(h));
await this._db.fs.queueDownloads(
attachments.map((a) => ({
filename: a.metadata.hash,
metadata: a.metadata,
chunkSize: a.chunkSize
})),
groupId,
notify ? { readOnDownload: false } : undefined
);
const sources = {};
for (const attachment of attachments) {
const src = await this._db.attachments.read(
attachment.metadata.hash,
getOutputType(attachment)
);
if (!src) continue;
sources[attachment.metadata.hash] = src;
}
return sources;
});
return contentItem;
}

View File

@@ -36,15 +36,6 @@ export async function checkSyncStatus(type) {
return results.some((r) => r.type === type && r.result === true);
}
export function sendAttachmentsProgressEvent(type, groupId, total, current) {
EV.publish(EVENTS.attachmentsLoading, {
type,
groupId,
total,
current: current === undefined ? total : current
});
}
export function sendSyncProgressEvent(EV, type, current) {
EV.publish(EVENTS.syncProgress, {
type,
@@ -98,7 +89,12 @@ export const EVENTS = {
noteRemoved: "note:removed",
tokenRefreshed: "token:refreshed",
userUnauthorized: "user:unauthorized",
attachmentsLoading: "attachments:loading",
downloadCanceled: "file:downloadCanceled",
uploadCanceled: "file:uploadCanceled",
fileDownload: "file:download",
fileUpload: "file:upload",
fileDownloaded: "file:downloaded",
fileUploaded: "file:uploaded",
attachmentDeleted: "attachment:deleted",
mediaAttachmentDownloaded: "attachments:mediaDownloaded",
vaultLocked: "vault:locked",

View File

@@ -41,7 +41,13 @@ test("img src is present after insert attachments", async () => {
return { key: "hello", metadata: {} };
});
const tiptap2 = new Tiptap(result.data);
const result2 = await tiptap2.insertMedia(() => "i am a data");
const result2 = await tiptap2.insertMedia((hashes) => {
const images = {};
for (const hash of hashes) {
images[hash] = "i am a data";
}
return images;
});
expect(result2).toContain(`src="i am a data"`);
});

View File

@@ -100,7 +100,7 @@ export class Tiptap {
return tokens.some((token) => lowercase.indexOf(token) > -1);
}
async insertMedia(getData) {
async insertMedia(resolve) {
let hashes = [];
new HTMLParser({
ontag: (name, attr) => {
@@ -108,21 +108,9 @@ export class Tiptap {
if (name === "img" && hash) hashes.push(hash);
}
}).parse(this.data);
if (!hashes.length) return this.data;
const images = {};
let hasImages = false;
for (let i = 0; i < hashes.length; ++i) {
const hash = hashes[i];
const src = await getData(hash, {
total: hashes.length,
current: i
});
if (!src) continue;
images[hash] = src;
hasImages = true;
}
if (!hasImages) return this.data;
const images = await resolve(hashes);
return new HTMLRewriter({
ontag: (name, attr) => {
const hash = attr[ATTRIBUTES.hash];

View File

@@ -19,12 +19,103 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import hosts from "../utils/constants";
import TokenManager from "../api/token-manager";
import { EV, EVENTS } from "../common";
export default class FileStorage {
constructor(fs, storage) {
this.fs = fs;
this.tokenManager = new TokenManager(storage);
this._queue = [];
this.downloads = new Map();
this.uploads = new Map();
}
async queueDownloads(files, groupId, eventData) {
const token = await this.tokenManager.getAccessToken();
const total = files.length;
let current = 0;
this.downloads.set(groupId, files);
for (const file of files) {
const { filename, metadata, chunkSize } = file;
if (await this.exists(filename)) {
current++;
EV.publish(EVENTS.fileDownloaded, {
success: true,
groupId,
filename,
eventData
});
continue;
}
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.downloadFile(filename, {
metadata,
url,
chunkSize,
headers: { Authorization: `Bearer ${token}` }
});
file.cancel = cancel;
EV.publish(EVENTS.fileDownload, {
total,
current,
groupId,
filename
});
const result = await execute().catch(() => false);
if (eventData)
EV.publish(EVENTS.fileDownloaded, {
success: result,
total,
current: ++current,
groupId,
filename,
eventData
});
}
this.downloads.delete(groupId);
}
async queueUploads(files, groupId) {
const token = await this.tokenManager.getAccessToken();
const total = files.length;
let current = 0;
this.uploads.set(groupId, files);
for (const file of files) {
const { filename } = file;
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.uploadFile(filename, {
url,
headers: { Authorization: `Bearer ${token}` }
});
file.cancel = cancel;
EV.publish(EVENTS.fileUpload, {
total,
current,
groupId,
filename
});
let error = null;
const result = await execute().catch((e) => {
console.error("Failed to upload attachment:", e);
error = e;
return false;
});
EV.publish(EVENTS.fileUploaded, {
error,
success: result,
total,
current: ++current,
groupId,
filename
});
}
this.uploads.delete(groupId);
}
async downloadFile(groupId, filename, chunkSize, metadata) {
@@ -36,37 +127,30 @@ export default class FileStorage {
chunkSize,
headers: { Authorization: `Bearer ${token}` }
});
this._queue.push({ groupId, filename, cancel, type: "download" });
this.downloads.set(groupId, [{ cancel }]);
const result = await execute();
this._deleteOp(groupId, "download");
return result;
}
async uploadFile(groupId, filename) {
const token = await this.tokenManager.getAccessToken();
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.uploadFile(filename, {
url,
headers: { Authorization: `Bearer ${token}` }
});
this._queue.push({ groupId, filename, cancel, type: "upload" });
const result = await execute();
this._deleteOp(groupId, "upload");
this.downloads.delete(groupId);
return result;
}
async cancel(groupId, type) {
const [op] = this._deleteOp(groupId, type);
if (!op) return;
await op.cancel("Operation canceled.");
}
_deleteOp(groupId, type) {
const opIndex = this._queue.findIndex(
(item) => item.groupId === groupId && (!type || item.type === type)
);
if (opIndex < 0) return [];
return this._queue.splice(opIndex, 1);
const queue =
type === "download"
? this.downloads.get(groupId)
: this.uploads.get(groupId);
if (!queue) return;
for (let i = 0; i < queue.length; ++i) {
const file = queue[i];
if (file.cancel) await file.cancel("Operation canceled.");
queue.splice(i, 1);
}
if (type === "download") {
this.downloads.delete(groupId);
EV.publish(EVENTS.downloadCanceled, { groupId, canceled: true });
} else if (type === "upload") {
this.uploads.delete(groupId);
EV.publish(EVENTS.uploadCanceled, { groupId, canceled: true });
}
}
readEncrypted(filename, encryptionKey, cipherData) {

View File

@@ -27,9 +27,7 @@ class EventManager {
}
subscribeMulti(names, handler, thisArg) {
names.forEach((name) => {
this.subscribe(name, handler.bind(thisArg));
});
return names.map((name) => this.subscribe(name, handler.bind(thisArg)));
}
subscribe(name, handler, once = false) {