From fb511b4d1a5f516fc0488aa7cc7a8866435d5684 Mon Sep 17 00:00:00 2001 From: jarvis2f <137974272+jarvis2f@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:44:28 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Support=20transfer=20files.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/build.gradle | 5 +- .../telegram/files/AutoDownloadVerticle.java | 83 +--- .../telegram/files/AutoRecordsHolder.java | 80 +++ .../java/telegram/files/HttpVerticle.java | 20 +- .../main/java/telegram/files/MessyUtils.java | 51 ++ api/src/main/java/telegram/files/Start.java | 2 +- .../main/java/telegram/files/TdApiHelp.java | 6 +- .../java/telegram/files/TelegramVerticle.java | 50 +- .../main/java/telegram/files/Transfer.java | 162 ++++++ .../java/telegram/files/TransferVerticle.java | 249 ++++++++++ .../telegram/files/repository/FileRecord.java | 27 +- .../files/repository/FileRepository.java | 17 +- .../files/repository/SettingAutoRecords.java | 37 +- .../repository/impl/FileRepositoryImpl.java | 84 +++- .../telegram/files/AutoRecordsHolderTest.java | 124 +++++ .../java/telegram/files/DataVerticleTest.java | 33 +- .../java/telegram/files/MessyUtilsTest.java | 86 ++++ .../java/telegram/files/TransferTest.java | 148 ++++++ web/package-lock.json | 463 +++++++++++++++++- web/package.json | 2 + web/src/components/auto-download-dialog.tsx | 393 ++++++++++++++- web/src/components/file-card.tsx | 10 +- web/src/components/file-extra.tsx | 2 +- web/src/components/file-row.tsx | 5 +- web/src/components/file-status.tsx | 88 +++- web/src/components/proxys.tsx | 4 +- web/src/components/ui/hover-card.tsx | 29 ++ web/src/components/ui/switch.tsx | 29 ++ web/src/hooks/use-copy-to-clipboard.tsx | 9 +- web/src/hooks/use-files.ts | 32 +- web/src/hooks/use-mutation-observer.ts | 20 + web/src/lib/types.ts | 21 + 32 files changed, 2208 insertions(+), 163 deletions(-) create mode 100644 api/src/main/java/telegram/files/AutoRecordsHolder.java create mode 100644 api/src/main/java/telegram/files/Transfer.java create mode 100644 api/src/main/java/telegram/files/TransferVerticle.java create mode 100644 api/src/test/java/telegram/files/AutoRecordsHolderTest.java create mode 100644 api/src/test/java/telegram/files/MessyUtilsTest.java create mode 100644 api/src/test/java/telegram/files/TransferTest.java create mode 100644 web/src/components/ui/hover-card.tsx create mode 100644 web/src/components/ui/switch.tsx create mode 100644 web/src/hooks/use-mutation-observer.ts diff --git a/api/build.gradle b/api/build.gradle index c2e0026..6059252 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -28,6 +28,7 @@ dependencies { testImplementation 'io.vertx:vertx-junit5:4.5.11' testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core:5.15.2' } test { @@ -43,7 +44,9 @@ test { } } } - jvmArgs "-Djava.library.path=${System.getenv('TDLIB_PATH')}" + jvmArgs "-Djava.library.path=${System.getenv('TDLIB_PATH')}", + "-XX:+EnableDynamicAgentLoading", + "-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('mockito-core') }}" environment "APP_ROOT", "${testClassesDirs.asPath}" useJUnitPlatform() finalizedBy jacocoTestReport diff --git a/api/src/main/java/telegram/files/AutoDownloadVerticle.java b/api/src/main/java/telegram/files/AutoDownloadVerticle.java index 1aa1527..376cf29 100644 --- a/api/src/main/java/telegram/files/AutoDownloadVerticle.java +++ b/api/src/main/java/telegram/files/AutoDownloadVerticle.java @@ -16,7 +16,10 @@ import telegram.files.repository.SettingAutoRecords; import telegram.files.repository.SettingKey; -import java.util.*; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -40,10 +43,17 @@ public class AutoDownloadVerticle extends AbstractVerticle { // telegramId -> messages private final Map> waitingDownloadMessages = new ConcurrentHashMap<>(); - private final SettingAutoRecords autoRecords = new SettingAutoRecords(); + private final SettingAutoRecords autoRecords; private int limit = DEFAULT_LIMIT; + public AutoDownloadVerticle(AutoRecordsHolder autoRecordsHolder) { + this.autoRecords = autoRecordsHolder.autoRecords(); + autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> + waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>()) + .removeIf(m -> m.chatId == item.chatId))); + } + @Override public void start(Promise startPromise) { initAutoDownload() @@ -77,37 +87,17 @@ public void stop(Promise stopPromise) { } private Future initAutoDownload() { - return Future.all( - DataVerticle.settingRepository.getByKey(SettingKey.autoDownloadLimit) - .onSuccess(limit -> { - if (limit != null) { - this.limit = limit; - } - }) - .onFailure(e -> log.error("Get Auto download limit failed!", e)), - DataVerticle.settingRepository.getByKey(SettingKey.autoDownload) - .onSuccess(settingAutoRecords -> { - if (settingAutoRecords == null) { - return; - } - settingAutoRecords.items.forEach(item -> HttpVerticle.getTelegramVerticle(item.telegramId) - .ifPresentOrElse(telegramVerticle -> { - if (telegramVerticle.authorized) { - autoRecords.add(item); - } else { - log.warn("Init auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId)); - } - }, () -> log.warn("Init auto download fail. Telegram verticle not found: %s".formatted(item.telegramId)))); - }) - .onFailure(e -> log.error("Init Auto download failed!", e)) - ).mapEmpty(); + return DataVerticle.settingRepository.getByKey(SettingKey.autoDownloadLimit) + .onSuccess(limit -> { + if (limit != null) { + this.limit = limit; + } + }) + .onFailure(e -> log.error("Get Auto download limit failed!", e)) + .mapEmpty(); } private Future initEventConsumer() { - vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> { - log.debug("Auto download update: %s".formatted(message.body())); - this.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class)); - }); vertx.eventBus().consumer(EventEnum.SETTING_UPDATE.address(SettingKey.autoDownloadLimit.name()), message -> { log.debug("Auto download limit update: %s".formatted(message.body())); this.limit = Convert.toInt(message.body(), DEFAULT_LIMIT); @@ -250,7 +240,7 @@ private void download(long telegramId) { .toList(); downloadMessages.forEach(message -> { Integer fileId = TdApiHelp.getFileId(message); - log.debug("Start process file: %s".formatted(fileId)); + log.debug("Start download file: %s".formatted(fileId)); telegramVerticle.startDownload(message.chatId, message.id, fileId) .onSuccess(v -> log.info("Start download file success! ChatId: %d MessageId:%d FileId:%d" .formatted(message.chatId, message.id, fileId)) @@ -266,37 +256,6 @@ private TelegramVerticle getTelegramVerticle(long telegramId) { .orElseThrow(() -> new IllegalArgumentException("Telegram verticle not found: %s".formatted(telegramId))); } - private void onAutoRecordsUpdate(SettingAutoRecords records) { - for (SettingAutoRecords.Item item : records.items) { - if (!autoRecords.exists(item.telegramId, item.chatId)) { - // new enabled - HttpVerticle.getTelegramVerticle(item.telegramId) - .ifPresentOrElse(telegramVerticle -> { - if (telegramVerticle.authorized) { - autoRecords.add(item); - log.info("Add auto download success: %s".formatted(item.uniqueKey())); - } else { - log.warn("Add auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId)); - } - }, () -> log.warn("Add auto download fail. Telegram verticle not found: %s".formatted(item.telegramId))); - } - } - // remove disabled - List removedItems = new ArrayList<>(); - autoRecords.items.removeIf(item -> { - if (records.exists(item.telegramId, item.chatId)) { - return false; - } - removedItems.add(item); - log.info("Remove auto download success: %s".formatted(item.uniqueKey())); - return true; - }); - removedItems.forEach(item -> - waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>()) - .removeIf(message -> message.chatId == item.chatId) - ); - } - private void onNewMessage(JsonObject jsonObject) { long telegramId = jsonObject.getLong("telegramId"); long chatId = jsonObject.getLong("chatId"); diff --git a/api/src/main/java/telegram/files/AutoRecordsHolder.java b/api/src/main/java/telegram/files/AutoRecordsHolder.java new file mode 100644 index 0000000..5aaecb3 --- /dev/null +++ b/api/src/main/java/telegram/files/AutoRecordsHolder.java @@ -0,0 +1,80 @@ +package telegram.files; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import io.vertx.core.Future; +import telegram.files.repository.SettingAutoRecords; +import telegram.files.repository.SettingKey; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public class AutoRecordsHolder { + private final Log log = LogFactory.get(); + + private final SettingAutoRecords autoRecords = new SettingAutoRecords(); + + private final List>> onRemoveListeners = new ArrayList<>(); + + public AutoRecordsHolder() { + } + + public SettingAutoRecords autoRecords() { + return autoRecords; + } + + public void registerOnRemoveListener(Consumer> onRemove) { + onRemoveListeners.add(onRemove); + } + + public Future init() { + return DataVerticle.settingRepository.getByKey(SettingKey.autoDownload) + .onSuccess(settingAutoRecords -> { + if (settingAutoRecords == null) { + return; + } + settingAutoRecords.items.forEach(item -> HttpVerticle.getTelegramVerticle(item.telegramId) + .ifPresentOrElse(telegramVerticle -> { + if (telegramVerticle.authorized) { + autoRecords.add(item); + } else { + log.warn("Init auto records fail. Telegram verticle not authorized: %s".formatted(item.telegramId)); + } + }, () -> log.warn("Init auto records fail. Telegram verticle not found: %s".formatted(item.telegramId)))); + }) + .onFailure(e -> log.error("Init auto records failed!", e)) + .mapEmpty(); + } + + public void onAutoRecordsUpdate(SettingAutoRecords records) { + for (SettingAutoRecords.Item item : records.items) { + if (!autoRecords.exists(item.telegramId, item.chatId)) { + // new enabled + HttpVerticle.getTelegramVerticle(item.telegramId) + .ifPresentOrElse(telegramVerticle -> { + if (telegramVerticle.authorized) { + autoRecords.add(item); + log.info("Add auto records success: %s".formatted(item.uniqueKey())); + } else { + log.warn("Add auto records fail. Telegram verticle not authorized: %s".formatted(item.telegramId)); + } + }, () -> log.warn("Add auto records fail. Telegram verticle not found: %s".formatted(item.telegramId))); + } + } + // remove disabled + List removedItems = new ArrayList<>(); + autoRecords.items.removeIf(item -> { + if (records.exists(item.telegramId, item.chatId)) { + return false; + } + removedItems.add(item); + log.info("Remove auto records success: %s".formatted(item.uniqueKey())); + return true; + }); + if (CollUtil.isNotEmpty(removedItems)) { + onRemoveListeners.forEach(listener -> listener.accept(removedItems)); + } + } +} diff --git a/api/src/main/java/telegram/files/HttpVerticle.java b/api/src/main/java/telegram/files/HttpVerticle.java index ac83b02..55f116f 100644 --- a/api/src/main/java/telegram/files/HttpVerticle.java +++ b/api/src/main/java/telegram/files/HttpVerticle.java @@ -26,6 +26,7 @@ import io.vertx.ext.web.sstore.LocalSessionStore; import io.vertx.ext.web.sstore.SessionStore; import org.drinkless.tdlib.TdApi; +import telegram.files.repository.SettingAutoRecords; import telegram.files.repository.SettingKey; import telegram.files.repository.SettingRecord; import telegram.files.repository.TelegramRecord; @@ -47,7 +48,7 @@ public class HttpVerticle extends AbstractVerticle { // session id -> telegram verticle private final Map sessionTelegramVerticles = new ConcurrentHashMap<>(); - private AutoDownloadVerticle autoDownloadVerticle; + private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder(); private static final String SESSION_COOKIE_NAME = "tf"; @@ -55,7 +56,9 @@ public class HttpVerticle extends AbstractVerticle { public void start(Promise startPromise) { initHttpServer() .compose(r -> initTelegramVerticles()) + .compose(r -> autoRecordsHolder.init()) .compose(r -> initAutoDownloadVerticle()) + .compose(r -> initTransferVerticle()) .compose(r -> initEventConsumer()) .onSuccess(startPromise::complete) .onFailure(startPromise::fail); @@ -212,13 +215,20 @@ public Future initTelegramVerticles() { } public Future initAutoDownloadVerticle() { - autoDownloadVerticle = new AutoDownloadVerticle(); - return vertx.deployVerticle(autoDownloadVerticle, new DeploymentOptions() + return vertx.deployVerticle(new AutoDownloadVerticle(autoRecordsHolder), new DeploymentOptions() .setThreadingModel(ThreadingModel.VIRTUAL_THREAD) ) .mapEmpty(); } + public Future initTransferVerticle() { + return vertx.deployVerticle(new TransferVerticle(autoRecordsHolder), new DeploymentOptions() + .setThreadingModel(ThreadingModel.VIRTUAL_THREAD) + ) + .mapEmpty(); + } + + private Future initEventConsumer() { vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> { log.debug("Received telegram event: %s".formatted(message.body())); @@ -237,6 +247,10 @@ private Future initEventConsumer() { }); }); + vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> { + log.debug("Auto download update: %s".formatted(message.body())); + autoRecordsHolder.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class)); + }); return Future.succeededFuture(); } diff --git a/api/src/main/java/telegram/files/MessyUtils.java b/api/src/main/java/telegram/files/MessyUtils.java index 76f6e68..839ebd7 100644 --- a/api/src/main/java/telegram/files/MessyUtils.java +++ b/api/src/main/java/telegram/files/MessyUtils.java @@ -1,6 +1,12 @@ package telegram.files; +import java.io.File; +import java.io.FileInputStream; +import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.security.MessageDigest; import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; public class MessyUtils { @@ -10,4 +16,49 @@ public static LocalDateTime withGrouping5Minutes(LocalDateTime time) { int newMinute = minuteGroup * 5; return time.withMinute(newMinute).withSecond(0).withNano(0); } + + public static String calculateFileMD5(File file) { + try (FileInputStream fis = new FileInputStream(file); + FileChannel channel = fis.getChannel()) { + + MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length()); + + MessageDigest md = MessageDigest.getInstance("MD5"); + + md.update(buffer); + + byte[] md5Bytes = md.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : md5Bytes) { + hexString.append(String.format("%02x", b)); + } + + return hexString.toString(); + } catch (Exception e) { + return null; + } + } + + public static boolean compareFilesMD5(File file1, File file2) { + CompletableFuture md5Task1 = CompletableFuture.supplyAsync(() -> { + try { + return calculateFileMD5(file1); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + CompletableFuture md5Task2 = CompletableFuture.supplyAsync(() -> { + try { + return calculateFileMD5(file2); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + String md5File1 = md5Task1.join(); + String md5File2 = md5Task2.join(); + + return md5File1.equals(md5File2); + } } diff --git a/api/src/main/java/telegram/files/Start.java b/api/src/main/java/telegram/files/Start.java index 796da69..17c3758 100644 --- a/api/src/main/java/telegram/files/Start.java +++ b/api/src/main/java/telegram/files/Start.java @@ -9,7 +9,7 @@ public class Start { private static final Log log = LogFactory.get(); - public static final String VERSION = "0.1.11"; + public static final String VERSION = "0.1.12"; private static final CountDownLatch shutdownLatch = new CountDownLatch(1); diff --git a/api/src/main/java/telegram/files/TdApiHelp.java b/api/src/main/java/telegram/files/TdApiHelp.java index b82edeb..2b86c99 100644 --- a/api/src/main/java/telegram/files/TdApiHelp.java +++ b/api/src/main/java/telegram/files/TdApiHelp.java @@ -8,13 +8,11 @@ import cn.hutool.core.convert.TypeConverter; import cn.hutool.core.util.ClassUtil; import cn.hutool.core.util.ReflectUtil; -import io.vertx.core.Future; import io.vertx.core.impl.NoStackTraceException; import org.drinkless.tdlib.TdApi; import org.jooq.lambda.tuple.Tuple; import org.jooq.lambda.tuple.Tuple1; import telegram.files.repository.FileRecord; -import telegram.files.repository.SettingKey; import java.util.*; @@ -283,6 +281,7 @@ public FileRecord convertFileRecord(long telegramId) { content.caption.text, null, "idle", + "idle", System.currentTimeMillis(), null ); @@ -343,6 +342,7 @@ public FileRecord convertFileRecord(long telegramId) { content.caption.text, null, "idle", + "idle", System.currentTimeMillis(), null ); @@ -390,6 +390,7 @@ public FileRecord convertFileRecord(long telegramId) { content.caption.text, null, "idle", + "idle", System.currentTimeMillis(), null ); @@ -437,6 +438,7 @@ public FileRecord convertFileRecord(long telegramId) { content.caption.text, null, "idle", + "idle", System.currentTimeMillis(), null ); diff --git a/api/src/main/java/telegram/files/TelegramVerticle.java b/api/src/main/java/telegram/files/TelegramVerticle.java index 26ffe49..e8ebe80 100644 --- a/api/src/main/java/telegram/files/TelegramVerticle.java +++ b/api/src/main/java/telegram/files/TelegramVerticle.java @@ -178,7 +178,9 @@ public Future getChats(Long activatedChatId, String query, boolean ar public Future getChatFiles(long chatId, MultiMap filter) { String status = filter.get("status"); if (Arrays.asList("downloading", "paused", "completed", "error").contains(status)) { - return DataVerticle.fileRepository.getFiles(chatId, filter) + Map filterMap = new HashMap<>(); + filter.forEach(filterMap::put); + return DataVerticle.fileRepository.getFiles(chatId, filterMap) .compose(r -> { long[] messageIds = r.v1.stream().mapToLong(FileRecord::messageId).toArray(); return client.execute(new TdApi.GetMessages(chatId, messageIds)) @@ -351,7 +353,7 @@ public Future startDownload(Long chatId, Long messageId, Integer fil TdApi.Message message = results.resultAt(1); if (file.local != null) { if (file.local.isDownloadingCompleted) { - return DataVerticle.fileRepository.updateStatus( + return DataVerticle.fileRepository.updateDownloadStatus( file.id, file.remote.uniqueId, file.local.path, @@ -373,8 +375,9 @@ public Future startDownload(Long chatId, Long messageId, Integer fil client.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32)) ) .onSuccess(r -> - sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() + sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() .put("fileId", fileId) + .put("uniqueId", fileRecord.uniqueId()) .put("downloadStatus", FileRecord.DownloadStatus.downloading) )) ); @@ -396,10 +399,11 @@ public Future cancelDownload(Integer fileId) { .map(file); }) .compose(file -> client.execute(new TdApi.DeleteFile(fileId)).map(file)) - .compose(file -> DataVerticle.fileRepository.deleteByUniqueId(file.remote.uniqueId)) - .onSuccess(r -> - sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() + .compose(file -> DataVerticle.fileRepository.deleteByUniqueId(file.remote.uniqueId).map(file)) + .onSuccess(file -> + sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() .put("fileId", fileId) + .put("uniqueId", file.remote.uniqueId) .put("downloadStatus", FileRecord.DownloadStatus.idle) ))) .mapEmpty(); @@ -416,7 +420,7 @@ public Future togglePauseDownload(Integer fileId, boolean isPaused) { return Future.failedFuture("File not started downloading"); } if (file.local.isDownloadingCompleted) { - return DataVerticle.fileRepository.updateStatus( + return DataVerticle.fileRepository.updateDownloadStatus( file.id, file.remote.uniqueId, file.local.path, @@ -455,7 +459,7 @@ public Future toggleAutoDownload(Long chatId, JsonObject params) { SettingAutoRecords.Rule rule = null; if (params != null && params.containsKey("rule")) { rule = params.getJsonObject("rule").mapTo(SettingAutoRecords.Rule.class); - if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes)) { + if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes) && rule.transferRule == null) { rule = null; } } @@ -619,15 +623,16 @@ public Future execute(String method, Object params) { }); } - private void sendHttpEvent(EventPayload payload) { - vertx.eventBus().send(EventEnum.TELEGRAM_EVENT.address(), + private void sendEvent(EventPayload payload) { + vertx.eventBus().publish(EventEnum.TELEGRAM_EVENT.address(), JsonObject.of("telegramId", this.getId(), "payload", JsonObject.mapFrom(payload))); } private void sendFileStatusHttpEvent(TdApi.File file, JsonObject fileUpdated) { if (fileUpdated == null || fileUpdated.isEmpty()) return; - sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() + sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() .put("fileId", file.id) + .put("uniqueId", file.remote.uniqueId) .put("downloadStatus", fileUpdated.getString("downloadStatus")) .put("localPath", fileUpdated.getString("localPath")) .put("completionDate", fileUpdated.getLong("completionDate")) @@ -638,7 +643,7 @@ private void sendFileStatusHttpEvent(TdApi.File file, JsonObject fileUpdated) { private void handleAuthorizationResult(TdApi.Object object) { switch (object.getConstructor()) { case TdApi.Error.CONSTRUCTOR: - sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, object)); + sendEvent(EventPayload.build(EventPayload.TYPE_ERROR, object)); break; case TdApi.Ok.CONSTRUCTOR: break; @@ -649,9 +654,9 @@ private void handleAuthorizationResult(TdApi.Object object) { private void handleDefaultResult(TdApi.Object object, String code) { if (object.getConstructor() == TdApi.Error.CONSTRUCTOR) { - sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, code, object)); + sendEvent(EventPayload.build(EventPayload.TYPE_ERROR, code, object)); } else { - sendHttpEvent(EventPayload.build(EventPayload.TYPE_METHOD_RESULT, code, object)); + sendEvent(EventPayload.build(EventPayload.TYPE_METHOD_RESULT, code, object)); } } @@ -734,7 +739,7 @@ private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationS case TdApi.AuthorizationStateWaitCode.CONSTRUCTOR: case TdApi.AuthorizationStateWaitRegistration.CONSTRUCTOR: case TdApi.AuthorizationStateWaitPassword.CONSTRUCTOR: - sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState)); + sendEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState)); break; case TdApi.AuthorizationStateReady.CONSTRUCTOR: authorized = true; @@ -751,7 +756,7 @@ private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationS } else { log.info("[%s] %s Authorization Ready".formatted(getRootId(), this.telegramRecord.firstName())); } - sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState)); + sendEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState)); telegramChats.loadMainChatList(); telegramChats.loadArchivedChatList(); break; @@ -784,16 +789,17 @@ private void onFileUpdated(TdApi.UpdateFile updateFile) { completionDate = System.currentTimeMillis(); } FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file); - DataVerticle.fileRepository.updateStatus(file.id, + DataVerticle.fileRepository.updateDownloadStatus(file.id, file.remote.uniqueId, localPath, downloadStatus, completionDate) .onSuccess(r -> sendFileStatusHttpEvent(file, r)); - } - if (lastFileEventTime == 0 || System.currentTimeMillis() - lastFileEventTime > 1000) { - sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE, updateFile)); - lastFileEventTime = System.currentTimeMillis(); + + if (completionDate != null || lastFileEventTime == 0 || System.currentTimeMillis() - lastFileEventTime > 1000) { + sendEvent(EventPayload.build(EventPayload.TYPE_FILE, updateFile)); + lastFileEventTime = System.currentTimeMillis(); + } } } @@ -801,7 +807,7 @@ private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownload log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads)); avgSpeed.update(updateFileDownloads.downloadedSize, System.currentTimeMillis()); if (lastFileDownloadEventTime == 0 || System.currentTimeMillis() - lastFileDownloadEventTime > 1000) { - sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads)); + sendEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads)); lastFileDownloadEventTime = System.currentTimeMillis(); } } diff --git a/api/src/main/java/telegram/files/Transfer.java b/api/src/main/java/telegram/files/Transfer.java new file mode 100644 index 0000000..95c35d0 --- /dev/null +++ b/api/src/main/java/telegram/files/Transfer.java @@ -0,0 +1,162 @@ +package telegram.files; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.io.FileUtil; +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import telegram.files.repository.FileRecord; + +import java.nio.file.Path; +import java.util.function.Consumer; + +public abstract class Transfer { + + private static final Log log = LogFactory.get(); + + public String destination; + + public DuplicationPolicy duplicationPolicy; + + public boolean transferHistory; + + public Consumer transferStatusUpdated; + + private FileRecord transferRecord; + + public static Transfer create(TransferPolicy transferPolicy) { + return switch (transferPolicy) { + case GROUP_BY_CHAT -> new GroupByChat(); + case GROUP_BY_TYPE -> new GroupByType(); + }; + } + + public void transfer(FileRecord fileRecord) { + log.debug("Start transfer file {}", fileRecord.id()); + transferRecord = fileRecord; + transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.transferring, null)); + try { + String transferPath = getTransferPath(fileRecord); + boolean isOverwrite = false; + if (FileUtil.exist(transferPath)) { + if (duplicationPolicy == DuplicationPolicy.SKIP) { + log.trace("Skip file {}", fileRecord.id()); + transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.idle, null)); + return; + } + + if (duplicationPolicy == DuplicationPolicy.OVERWRITE) { + log.trace("Overwrite file {}", fileRecord.id()); + isOverwrite = true; + } + + if (duplicationPolicy == DuplicationPolicy.RENAME) { + transferPath = getUniquePath(transferPath); + log.trace("Rename file {} to {}", fileRecord.id(), transferPath); + } + + if (duplicationPolicy == DuplicationPolicy.HASH) { + if (MessyUtils.compareFilesMD5(FileUtil.file(fileRecord.localPath()), FileUtil.file(transferPath))) { + log.trace("File {} is the same as {}", fileRecord.id(), transferPath); + FileUtil.del(fileRecord.localPath()); + transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.completed, transferPath)); + return; + } else { + transferPath = getUniquePath(transferPath); + log.trace("Rename file {} to {}", fileRecord.id(), transferPath); + } + } + } + + FileUtil.move(Path.of(fileRecord.localPath()), Path.of(transferPath), isOverwrite); + + transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.completed, transferPath)); + } catch (Exception e) { + log.error(e, "Transfer file {} error", fileRecord.id()); + transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.error, null)); + } finally { + transferRecord = null; + } + } + + private String getUniquePath(String path) { + if (!FileUtil.exist(path)) { + return path; + } + String name = FileUtil.getName(path); + String parent = FileUtil.getParent(path, 1); + String extension = FileUtil.extName(name); + String baseName = FileUtil.mainName(name); + int i = 1; + while (FileUtil.exists(Path.of(parent, "%s-%d.%s".formatted(baseName, i, extension)), false)) { + i++; + } + return Path.of(parent, "%s-%d.%s".formatted(baseName, i, extension)).toString(); + } + + public FileRecord getTransferRecord() { + return transferRecord; + } + + protected abstract String getTransferPath(FileRecord fileRecord); + + static class GroupByChat extends Transfer { + @Override + protected String getTransferPath(FileRecord fileRecord) { + String name = FileUtil.getName(fileRecord.localPath()); + return Path.of(destination, + Convert.toStr(fileRecord.telegramId()), + Convert.toStr(fileRecord.chatId()), + name + ).toString(); + } + } + + static class GroupByType extends Transfer { + @Override + protected String getTransferPath(FileRecord fileRecord) { + String name = FileUtil.getName(fileRecord.localPath()); + return Path.of(destination, + fileRecord.type(), + name + ).toString(); + } + } + + public record TransferStatusUpdated(FileRecord fileRecord, + FileRecord.TransferStatus transferStatus, + String localPath) { + } + + public enum TransferPolicy { + /** + * Transfer files by chat id + */ + GROUP_BY_CHAT, + /** + * Transfer files by type + */ + GROUP_BY_TYPE, + ; + } + + public enum DuplicationPolicy { + /** + * Overwrite the existing file + */ + OVERWRITE, + /** + * Rename the file with a suffix + */ + RENAME, + /** + * Skip the file + */ + SKIP, + /** + * Calculate the hash of the file and compare with the existing file, if the hash is the same, + * delete the original file and set the local path to the existing file, otherwise, move the file + */ + HASH, + ; + } +} diff --git a/api/src/main/java/telegram/files/TransferVerticle.java b/api/src/main/java/telegram/files/TransferVerticle.java new file mode 100644 index 0000000..8cd4866 --- /dev/null +++ b/api/src/main/java/telegram/files/TransferVerticle.java @@ -0,0 +1,249 @@ +package telegram.files; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.json.JsonObject; +import org.jooq.lambda.tuple.Tuple3; +import telegram.files.repository.FileRecord; +import telegram.files.repository.SettingAutoRecords; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class TransferVerticle extends AbstractVerticle { + private static final Log log = LogFactory.get(); + + private static final int HISTORY_SCAN_INTERVAL = 2 * 60 * 1000; + + private static final int TRANSFER_INTERVAL = 3 * 1000; + + private final SettingAutoRecords autoRecords; + + private final Map transfers = new HashMap<>(); + + private final BlockingQueue waitingTransferFiles = new LinkedBlockingQueue<>(); + + private volatile boolean isStopped = false; + + private volatile Transfer beingTransferred; + + public TransferVerticle(AutoRecordsHolder autoRecordsHolder) { + this.autoRecords = autoRecordsHolder.autoRecords(); + autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> { + waitingTransferFiles.removeIf(waitingTransferFile -> waitingTransferFile.uniqueId().equals(item.uniqueKey())); + transfers.remove(item.uniqueKey()); + })); + } + + @Override + public void start(Promise startPromise) { + initEventConsumer().onSuccess(v -> { + vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL, id -> addHistoryFiles()); + vertx.setPeriodic(0, TRANSFER_INTERVAL, id -> startTransfer()); + + log.info("Transfer verticle started"); + startPromise.complete(); + }).onFailure(startPromise::fail); + } + + @Override + public void stop(Promise stopPromise) { + isStopped = true; + if (beingTransferred != null) { + log.info("Wait for transfer to complete, file: %s".formatted(beingTransferred.getTransferRecord().uniqueId())); + while (beingTransferred != null) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + log.error("Stop transfer verticle error: %s".formatted(e.getMessage())); + stopPromise.fail(e); + } + } + } + log.info("Transfer verticle stopped"); + stopPromise.complete(); + } + + private Future initEventConsumer() { + vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> { + JsonObject jsonObject = (JsonObject) message.body(); + EventPayload payload = jsonObject.getJsonObject("payload").mapTo(EventPayload.class); + if (payload == null || payload.type() != EventPayload.TYPE_FILE_STATUS) { + return; + } + + if (payload.data() != null && payload.data() instanceof Map data && StrUtil.isNotBlank((String) data.get("downloadStatus"))) { + FileRecord.DownloadStatus downloadStatus = FileRecord.DownloadStatus.valueOf((String) data.get("downloadStatus")); + if (downloadStatus != FileRecord.DownloadStatus.completed) { + return; + } + FileRecord fileRecord = Future.await(DataVerticle.fileRepository.getByUniqueId((String) data.get("uniqueId"))); + if (getTransfer(autoRecords.getItem(fileRecord.telegramId(), fileRecord.chatId())) == null) { + return; + } + + if (addWaitingTransferFile(fileRecord)) { + log.debug("Add file to transfer queue: %s".formatted(fileRecord.uniqueId())); + } + } + }); + + return Future.succeededFuture(); + } + + private void addHistoryFiles() { + if (CollUtil.isEmpty(autoRecords.items)) { + return; + } + log.debug("Start scan history files for transfer"); + for (SettingAutoRecords.Item item : autoRecords.items) { + Transfer transfer = getTransfer(item); + if (transfer == null || !transfer.transferHistory) { + continue; + } + Tuple3, Long, Long> filesTuple = Future.await(DataVerticle.fileRepository.getFiles(item.chatId, + Map.of("status", FileRecord.DownloadStatus.completed.name(), + "transferStatus", FileRecord.TransferStatus.idle.name() + ) + )); + List files = filesTuple.v1; + if (CollUtil.isEmpty(files)) { + continue; + } + + int count = 0; + for (FileRecord fileRecord : files) { + if (addWaitingTransferFile(fileRecord)) { + count++; + } + } + + if (count > 0) { + log.info("Add history files to transfer queue: %s".formatted(count)); + break; + } + } + } + + private boolean addWaitingTransferFile(FileRecord fileRecord) { + WaitingTransferFile waitingTransferFile = new WaitingTransferFile(fileRecord.telegramId(), + fileRecord.chatId(), + fileRecord.uniqueId()); + if (!waitingTransferFiles.contains(waitingTransferFile)) { + waitingTransferFiles.add(waitingTransferFile); + return true; + } + return false; + } + + private Transfer getTransfer(SettingAutoRecords.Item item) { + if (item == null) { + return null; + } + if (transfers.containsKey(item.uniqueKey())) { + return transfers.get(item.uniqueKey()); + } + + SettingAutoRecords.TransferRule transferRule = BeanUtil.getProperty(item, "rule.transferRule"); + if (transferRule == null) { + return null; + } + + return transfers.computeIfAbsent(item.uniqueKey(), k -> { + Transfer transfer = Transfer.create(transferRule.transferPolicy); + transfer.destination = transferRule.destination; + transfer.duplicationPolicy = transferRule.duplicationPolicy; + transfer.transferHistory = transferRule.transferHistory; + transfer.transferStatusUpdated = updated -> + updateTransferStatus(updated.fileRecord(), updated.transferStatus(), updated.localPath()); + return transfer; + }); + } + + public void startTransfer() { + if (beingTransferred != null) { + return; + } + try { + WaitingTransferFile waitingTransferFile = waitingTransferFiles.poll(1, TimeUnit.SECONDS); + if (waitingTransferFile == null) { + log.trace("No file to transfer"); + return; + } + Transfer transfer = transfers.get("%d:%d".formatted(waitingTransferFile.telegramId(), waitingTransferFile.chatId())); + if (transfer == null) { + return; + } + if (beingTransferred == transfer) { + waitingTransferFiles.add(waitingTransferFile); + log.debug("Transfer is busy: %s".formatted(waitingTransferFile.uniqueId)); + return; + } + FileRecord fileRecord = Future.await(DataVerticle.fileRepository.getByUniqueId(waitingTransferFile.uniqueId)); + if (fileRecord == null) { + log.error("File not found: %s".formatted(waitingTransferFile.uniqueId)); + return; + } + + startTransfer(fileRecord, transfer); + } catch (Exception e) { + if (e instanceof InterruptedException) { + log.debug("Transfer loop interrupted"); + } else { + log.error(e, "Transfer error"); + } + } + } + + public void startTransfer(FileRecord fileRecord, Transfer transfer) { + if (isStopped) { + return; + } + if (fileRecord.transferStatus() != null + && !fileRecord.isTransferStatus(FileRecord.TransferStatus.idle)) { + log.debug("File {} transfer status is not idle: {}", fileRecord.id(), fileRecord.transferStatus()); + return; + } + + File originFile = new File(fileRecord.localPath()); + if (!originFile.exists()) { + log.error("File {} not found: {}", fileRecord.id(), fileRecord.localPath()); + return; + } + + beingTransferred = transfer; + transfer.transfer(fileRecord); + beingTransferred = null; + } + + private void updateTransferStatus(FileRecord fileRecord, FileRecord.TransferStatus transferStatus, String localPath) { + Future.await(DataVerticle.fileRepository.updateTransferStatus(fileRecord.uniqueId(), transferStatus, localPath) + .onSuccess(fileUpdated -> { + if (fileUpdated != null && !fileUpdated.isEmpty()) { + EventPayload payload = EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() + .put("fileId", fileRecord.id()) + .put("uniqueId", fileRecord.uniqueId()) + .put("transferStatus", fileUpdated.getString("transferStatus")) + .put("localPath", fileUpdated.getString("localPath")) + ); + vertx.eventBus().publish(EventEnum.TELEGRAM_EVENT.address(), + JsonObject.of("telegramId", fileRecord.telegramId(), "payload", JsonObject.mapFrom(payload)) + ); + } + })); + } + + private record WaitingTransferFile(long telegramId, long chatId, String uniqueId) { + } +} diff --git a/api/src/main/java/telegram/files/repository/FileRecord.java b/api/src/main/java/telegram/files/repository/FileRecord.java index a740217..e79b7e4 100644 --- a/api/src/main/java/telegram/files/repository/FileRecord.java +++ b/api/src/main/java/telegram/files/repository/FileRecord.java @@ -25,6 +25,7 @@ public record FileRecord(int id, //file id will change String caption, String localPath, String downloadStatus, // 'idle' | 'downloading' | 'paused' | 'completed' | 'error' + String transferStatus, // 'idle' | 'transferring' | 'completed' | 'error' long startDate, // date when the file was started to download Long completionDate // date when the file was downloaded ) { @@ -33,6 +34,10 @@ public enum DownloadStatus { idle, downloading, paused, completed, error } + public enum TransferStatus { + idle, transferring, completed, error + } + public static final String SCHEME = """ CREATE TABLE IF NOT EXISTS file_record ( @@ -52,6 +57,7 @@ thumbnail VARCHAR(255), caption VARCHAR(255), local_path VARCHAR(255), download_status VARCHAR(255), + transfer_status VARCHAR(255), start_date BIGINT, completion_date BIGINT, PRIMARY KEY (id, unique_id) @@ -62,6 +68,9 @@ PRIMARY KEY (id, unique_id) MapUtil.entry(new Version("0.1.7"), new String[]{ "ALTER TABLE file_record ADD COLUMN start_date BIGINT;", "ALTER TABLE file_record ADD COLUMN completion_date BIGINT;", + }), + MapUtil.entry(new Version("0.1.12"), new String[]{ + "ALTER TABLE file_record ADD COLUMN transfer_status VARCHAR(255) DEFAULT 'idle';", }) )); @@ -94,6 +103,7 @@ public TreeMap getMigrations() { row.getString("caption"), row.getString("local_path"), row.getString("download_status"), + row.getString("transfer_status"), Objects.requireNonNullElse(row.getLong("start_date"), 0L), row.getLong("completion_date") ); @@ -116,12 +126,27 @@ public TreeMap getMigrations() { MapUtil.entry("caption", r.caption()), MapUtil.entry("local_path", r.localPath()), MapUtil.entry("download_status", r.downloadStatus()), + MapUtil.entry("transfer_status", r.transferStatus()), MapUtil.entry("start_date", r.startDate()), MapUtil.entry("completion_date", r.completionDate()) )); public FileRecord withSourceField(int id, long downloadedSize) { - return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, startDate, completionDate); + return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, transferStatus, startDate, completionDate); + } + + public boolean isDownloadStatus(DownloadStatus status) { + if (status == null && downloadStatus == null) { + return true; + } + return downloadStatus != null && DownloadStatus.valueOf(downloadStatus) == status; + } + + public boolean isTransferStatus(TransferStatus status) { + if (status == null && transferStatus == null) { + return true; + } + return transferStatus != null && TransferStatus.valueOf(transferStatus) == status; } } diff --git a/api/src/main/java/telegram/files/repository/FileRepository.java b/api/src/main/java/telegram/files/repository/FileRepository.java index 9918f0f..582f587 100644 --- a/api/src/main/java/telegram/files/repository/FileRepository.java +++ b/api/src/main/java/telegram/files/repository/FileRepository.java @@ -1,7 +1,6 @@ package telegram.files.repository; import io.vertx.core.Future; -import io.vertx.core.MultiMap; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.jooq.lambda.tuple.Tuple3; @@ -14,7 +13,7 @@ public interface FileRepository { Future> getFiles(long chatId, List fileIds); - Future, Long, Long>> getFiles(long chatId, MultiMap filter); + Future, Long, Long>> getFiles(long chatId, Map filter); Future> getFilesByUniqueId(List uniqueIds); @@ -28,11 +27,15 @@ public interface FileRepository { Future countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus); - Future updateStatus(int fileId, - String uniqueId, - String localPath, - FileRecord.DownloadStatus downloadStatus, - Long completionDate); + Future updateDownloadStatus(int fileId, + String uniqueId, + String localPath, + FileRecord.DownloadStatus downloadStatus, + Long completionDate); + + Future updateTransferStatus(String uniqueId, + FileRecord.TransferStatus transferStatus, + String localPath); Future updateFileId(int fileId, String uniqueId); diff --git a/api/src/main/java/telegram/files/repository/SettingAutoRecords.java b/api/src/main/java/telegram/files/repository/SettingAutoRecords.java index 2c3f83e..74ff165 100644 --- a/api/src/main/java/telegram/files/repository/SettingAutoRecords.java +++ b/api/src/main/java/telegram/files/repository/SettingAutoRecords.java @@ -1,5 +1,7 @@ package telegram.files.repository; +import telegram.files.Transfer; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -40,12 +42,38 @@ public static class Rule { public List fileTypes; + public TransferRule transferRule; + public Rule() { } - public Rule(String query, List fileTypes) { + public Rule(String query, List fileTypes, TransferRule transferRule) { this.query = query; this.fileTypes = fileTypes; + this.transferRule = transferRule; + } + } + + public static class TransferRule { + public boolean transferHistory; + + public String destination; + + public Transfer.TransferPolicy transferPolicy; + + public Transfer.DuplicationPolicy duplicationPolicy; + + public TransferRule() { + } + + public TransferRule(boolean transferHistory, + String destination, + Transfer.TransferPolicy transferPolicy, + Transfer.DuplicationPolicy duplicationPolicy) { + this.transferHistory = transferHistory; + this.destination = destination; + this.transferPolicy = transferPolicy; + this.duplicationPolicy = duplicationPolicy; } } @@ -80,4 +108,11 @@ public Map getItems(long telegramId) { .collect(Collectors.toMap(i -> i.chatId, Function.identity())); } + public Item getItem(long telegramId, long chatId) { + return items.stream() + .filter(item -> item.telegramId == telegramId && item.chatId == chatId) + .findFirst() + .orElse(null); + } + } diff --git a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java index 0782c0d..c3dec68 100644 --- a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java +++ b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java @@ -10,7 +10,6 @@ import cn.hutool.log.Log; import cn.hutool.log.LogFactory; import io.vertx.core.Future; -import io.vertx.core.MultiMap; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.jdbcclient.JDBCPool; @@ -42,9 +41,9 @@ public Future create(FileRecord fileRecord) { INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size, type, mime_type, file_name, thumbnail, caption, local_path, - download_status, start_date) + download_status, start_date, transfer_status) values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type}, - #{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date}) + #{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date}, #{transfer_status}) """) .mapFrom(FileRecord.PARAM_MAPPER) .execute(fileRecord) @@ -78,18 +77,25 @@ public Future> getFiles(long chatId, List file } @Override - public Future, Long, Long>> getFiles(long chatId, MultiMap filter) { + public Future, Long, Long>> getFiles(long chatId, Map filter) { String status = filter.get("status"); + String transferStatus = filter.get("transferStatus"); String search = filter.get("search"); Long fromMessageId = Convert.toLong(filter.get("fromMessageId"), 0L); String type = filter.get("type"); + int limit = Convert.toInt(filter.get("limit"), 20); String whereClause = "chat_id = #{chatId}"; Map params = MapUtil.of("chatId", chatId); + params.put("limit", limit); if (StrUtil.isNotBlank(status)) { whereClause += " AND download_status = #{status}"; params.put("status", status); } + if (StrUtil.isNotBlank(transferStatus)) { + whereClause += " AND transfer_status = #{transferStatus}"; + params.put("transferStatus", transferStatus); + } if (StrUtil.isNotBlank(search)) { whereClause += " AND (file_name LIKE #{search} OR caption LIKE #{search})"; params.put("search", "%%" + search + "%%"); @@ -110,7 +116,7 @@ public Future, Long, Long>> getFiles(long chatId, MultiM return Future.all( SqlTemplate .forQuery(pool, """ - SELECT * FROM file_record WHERE %s ORDER BY message_id desc LIMIT 20 + SELECT * FROM file_record WHERE %s ORDER BY message_id desc LIMIT #{limit} """.formatted(whereClause)) .mapTo(FileRecord.ROW_MAPPER) .execute(params) @@ -293,21 +299,26 @@ SELECT COUNT(*) FROM file_record WHERE telegram_id = #{telegramId} AND download_ } @Override - public Future updateStatus(int fileId, - String uniqueId, - String localPath, - FileRecord.DownloadStatus downloadStatus, - Long completionDate) { + public Future updateDownloadStatus(int fileId, + String uniqueId, + String localPath, + FileRecord.DownloadStatus downloadStatus, + Long completionDate) { if (StrUtil.isBlank(localPath) && downloadStatus == null) { return Future.succeededFuture(null); } return getByUniqueId(uniqueId) .compose(record -> { - if (record == null) { + if (record == null + // Because if file transfer is completed, + // the telegram client will detect the file and change the download status to paused + || (downloadStatus == FileRecord.DownloadStatus.paused + && record.isTransferStatus(FileRecord.TransferStatus.completed)) + ) { return Future.succeededFuture(null); } boolean pathUpdated = !Objects.equals(record.localPath(), localPath); - boolean downloadStatusUpdated = !Objects.equals(record.downloadStatus(), downloadStatus.name()); + boolean downloadStatusUpdated = !record.isDownloadStatus(downloadStatus); if (!pathUpdated && !downloadStatusUpdated) { return Future.succeededFuture(null); } @@ -339,7 +350,54 @@ public Future updateStatus(int fileId, result.put("downloadStatus", downloadStatus.name()); } log.debug("Successfully updated file record: %s, path: %s, status: %s, before: %s, %s" - .formatted(fileId, localPath, downloadStatus.name(), record.localPath(), record.downloadStatus())); + .formatted(uniqueId, localPath, downloadStatus.name(), record.localPath(), record.downloadStatus())); + return result; + }); + }); + } + + @Override + public Future updateTransferStatus(String uniqueId, + FileRecord.TransferStatus transferStatus, + String localPath) { + if (StrUtil.isBlank(localPath) && transferStatus == null) { + return Future.succeededFuture(null); + } + return getByUniqueId(uniqueId) + .compose(record -> { + if (record == null) { + return Future.succeededFuture(null); + } + boolean pathUpdated = StrUtil.isNotBlank(localPath) && !Objects.equals(record.localPath(), localPath); + boolean transferStatusUpdated = !record.isTransferStatus(transferStatus); + if (!pathUpdated && !transferStatusUpdated) { + return Future.succeededFuture(null); + } + + return SqlTemplate + .forUpdate(pool, """ + UPDATE file_record + SET transfer_status = #{transferStatus}, + local_path = #{localPath} + WHERE unique_id = #{uniqueId} + """) + .execute(MapUtil.ofEntries(MapUtil.entry("uniqueId", uniqueId), + MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()), + MapUtil.entry("transferStatus", transferStatusUpdated ? transferStatus.name() : record.transferStatus()) + )) + .onFailure(err -> + log.error("Failed to update file record: %s".formatted(err.getMessage())) + ) + .map(r -> { + JsonObject result = JsonObject.of(); + if (pathUpdated) { + result.put("localPath", localPath); + } + if (transferStatusUpdated) { + result.put("transferStatus", transferStatus.name()); + } + log.debug("Successfully updated file record: %s, path: %s, transfer status: %s, before: %s %s" + .formatted(uniqueId, localPath, transferStatus.name(), record.localPath(), record.transferStatus())); return result; }); }); diff --git a/api/src/test/java/telegram/files/AutoRecordsHolderTest.java b/api/src/test/java/telegram/files/AutoRecordsHolderTest.java new file mode 100644 index 0000000..255c436 --- /dev/null +++ b/api/src/test/java/telegram/files/AutoRecordsHolderTest.java @@ -0,0 +1,124 @@ +package telegram.files; + +import io.vertx.core.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import telegram.files.repository.SettingAutoRecords; +import telegram.files.repository.SettingKey; +import telegram.files.repository.SettingRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class AutoRecordsHolderTest { + private AutoRecordsHolder autoRecordsHolder; + + private MockedStatic httpVerticleMockedStatic; + + private SettingAutoRecords settingAutoRecords1; + + private SettingAutoRecords.Item item1; + + @BeforeEach + public void setUp() { + autoRecordsHolder = new AutoRecordsHolder(); + httpVerticleMockedStatic = mockStatic(HttpVerticle.class); + + // Prepare test data + settingAutoRecords1 = new SettingAutoRecords(); + item1 = new SettingAutoRecords.Item(); + item1.telegramId = 123L; + item1.chatId = 456L; + settingAutoRecords1.items.add(item1); + + // Mock dependencies + TelegramVerticle mockTelegramVerticle = mock(TelegramVerticle.class); + mockTelegramVerticle.authorized = true; + when(HttpVerticle.getTelegramVerticle(item1.telegramId)) + .thenReturn(Optional.of(mockTelegramVerticle)); + } + + @AfterEach + public void tearDown() { + httpVerticleMockedStatic.close(); + } + + @Test + public void testInit_WhenSettingExists_AddsAuthorizedRecords() { + DataVerticle.settingRepository = mock(SettingRepository.class); + + // Mock dependencies + when(DataVerticle.settingRepository.getByKey(SettingKey.autoDownload)) + .thenReturn(Future.succeededFuture(settingAutoRecords1)); + + // Execute + Future result = autoRecordsHolder.init(); + + // Verify + assertNotNull(result); + assertTrue(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId)); + } + + @Test + public void testOnAutoRecordsUpdate_AddingNewRecords() { + // Execute + autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1); + + // Verify + assertTrue(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId)); + } + + @Test + public void testOnAutoRecordsUpdate_RemovingRecords() { + autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1); + + // Prepare new records with no items + SettingAutoRecords newRecords = new SettingAutoRecords(); + + // Mock listener + Consumer> mockListener = mock(Consumer.class); + autoRecordsHolder.registerOnRemoveListener(mockListener); + + // Execute + autoRecordsHolder.onAutoRecordsUpdate(newRecords); + + // Verify + assertFalse(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId)); + verify(mockListener).accept(argThat(list -> + list.size() == 1 && + list.getFirst().telegramId == item1.telegramId && + list.getFirst().chatId == item1.chatId + )); + } + + @Test + public void testRegisterOnRemoveListener() { + // Prepare test listener + List receivedItems = new ArrayList<>(); + Consumer> listener = receivedItems::addAll; + + // Register listener + autoRecordsHolder.registerOnRemoveListener(listener); + + // Prepare initial and new records + autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1); + + SettingAutoRecords newRecords = new SettingAutoRecords(); + + // Execute + autoRecordsHolder.onAutoRecordsUpdate(newRecords); + + // Verify + assertFalse(receivedItems.isEmpty()); + assertEquals(1, receivedItems.size()); + assertEquals(item1.telegramId, receivedItems.getFirst().telegramId); + assertEquals(item1.chatId, receivedItems.getFirst().chatId); + } +} diff --git a/api/src/test/java/telegram/files/DataVerticleTest.java b/api/src/test/java/telegram/files/DataVerticleTest.java index c5873c8..1e92357 100644 --- a/api/src/test/java/telegram/files/DataVerticleTest.java +++ b/api/src/test/java/telegram/files/DataVerticleTest.java @@ -82,7 +82,7 @@ void getAllTelegramRecordTest(Vertx vertx, VertxTestContext testContext) { @DisplayName("Test Get file record by primary key") void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) { FileRecord fileRecord = new FileRecord( - 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", 0, null + 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", "transfer_status", 0, null ); DataVerticle.fileRepository.create(fileRecord) .compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId())) @@ -93,16 +93,16 @@ void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) { } @Test - @DisplayName("Test update file status") - void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) { + @DisplayName("Test update file download status") + void updateFileDownloadStatusTest(Vertx vertx, VertxTestContext testContext) { FileRecord fileRecord = new FileRecord( - 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status", 0, null + 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, FileRecord.DownloadStatus.idle.name(), FileRecord.TransferStatus.idle.name(), 0, null ); String updateLocalPath = "local_path"; Long completionDate = 1L; int newFileId = 2; DataVerticle.fileRepository.create(fileRecord) - .compose(r -> DataVerticle.fileRepository.updateStatus(newFileId, r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate)) + .compose(r -> DataVerticle.fileRepository.updateDownloadStatus(newFileId, r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate)) .compose(r -> { testContext.verify(() -> { Assertions.assertEquals(updateLocalPath, r.getString("localPath")); @@ -119,4 +119,27 @@ void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) { }))); } + @Test + @DisplayName("Test update file transfer status") + void updateFileTransferStatusTest(Vertx vertx, VertxTestContext testContext) { + FileRecord fileRecord = new FileRecord( + 1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, FileRecord.DownloadStatus.idle.name(), FileRecord.TransferStatus.idle.name(), 0, null + ); + String updateLocalPath = "local_path"; + DataVerticle.fileRepository.create(fileRecord) + .compose(r -> DataVerticle.fileRepository.updateTransferStatus(r.uniqueId(), FileRecord.TransferStatus.completed, updateLocalPath)) + .compose(r -> { + testContext.verify(() -> { + Assertions.assertEquals(updateLocalPath, r.getString("localPath")); + Assertions.assertEquals(FileRecord.TransferStatus.completed.name(), r.getString("transferStatus")); + }); + return DataVerticle.fileRepository.getByUniqueId(fileRecord.uniqueId()); + }) + .onComplete(testContext.succeeding(r -> testContext.verify(() -> { + Assertions.assertEquals(FileRecord.TransferStatus.completed.name(), r.transferStatus()); + Assertions.assertEquals(updateLocalPath, r.localPath()); + testContext.completeNow(); + }))); + } + } diff --git a/api/src/test/java/telegram/files/MessyUtilsTest.java b/api/src/test/java/telegram/files/MessyUtilsTest.java new file mode 100644 index 0000000..50014e3 --- /dev/null +++ b/api/src/test/java/telegram/files/MessyUtilsTest.java @@ -0,0 +1,86 @@ +package telegram.files; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.security.MessageDigest; + +import static org.junit.jupiter.api.Assertions.*; + +class MessyUtilsTest { + + private File smallFile; + + private File largeFile; + + @BeforeEach + void setUp() throws Exception { + smallFile = new File("small_test_file.txt"); + try (FileWriter writer = new FileWriter(smallFile)) { + writer.write("Hello, this is a small test file!"); + } + + largeFile = new File("large_test_file.txt"); + try (FileWriter writer = new FileWriter(largeFile)) { + for (int i = 0; i < 1_000_000; i++) { + writer.write("This is a large test file for MD5 computation.\n"); + } + } + } + + @AfterEach + void tearDown() { + if (smallFile.exists()) { + smallFile.delete(); + } + if (largeFile.exists()) { + largeFile.delete(); + } + } + + @Test + void testCalculateFileMD5ForSmallFile() throws Exception { + String md5 = MessyUtils.calculateFileMD5(smallFile); + + String expectedMd5 = calculateExpectedMD5(smallFile); + assertEquals(expectedMd5, md5, "MD5 hash for the small file does not match!"); + } + + @Test + void testCalculateFileMD5ForLargeFile() throws Exception { + String md5 = MessyUtils.calculateFileMD5(largeFile); + + String expectedMd5 = calculateExpectedMD5(largeFile); + assertEquals(expectedMd5, md5, "MD5 hash for the large file does not match!"); + } + + @Test + void testCalculateFileMD5ForNonExistentFile() { + File nonExistentFile = new File("non_existent_file.txt"); + String md5 = MessyUtils.calculateFileMD5(nonExistentFile); + + assertNull(md5, "MD5 hash for non-existent file should be null!"); + } + + @Test + void testCompareFilesMD5() { + assertTrue(MessyUtils.compareFilesMD5(smallFile, smallFile), "MD5 hashes for the same file should match!"); + assertFalse(MessyUtils.compareFilesMD5(smallFile, largeFile), "MD5 hashes for different files should not match!"); + } + + private String calculateExpectedMD5(File file) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] fileBytes = Files.readAllBytes(file.toPath()); + byte[] md5Bytes = md.digest(fileBytes); + + StringBuilder hexString = new StringBuilder(); + for (byte b : md5Bytes) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } +} diff --git a/api/src/test/java/telegram/files/TransferTest.java b/api/src/test/java/telegram/files/TransferTest.java new file mode 100644 index 0000000..0a2d613 --- /dev/null +++ b/api/src/test/java/telegram/files/TransferTest.java @@ -0,0 +1,148 @@ +package telegram.files; + +import cn.hutool.core.io.FileUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import telegram.files.Transfer.DuplicationPolicy; +import telegram.files.Transfer.TransferPolicy; +import telegram.files.repository.FileRecord; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class TransferTest { + private Transfer transfer; + + private FileRecord mockFileRecord; + + private Consumer mockStatusUpdater; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + mockFileRecord = mock(FileRecord.class); + mockStatusUpdater = mock(Consumer.class); + + transfer = Transfer.create(TransferPolicy.GROUP_BY_CHAT); + transfer.destination = tempDir.toString(); + transfer.duplicationPolicy = DuplicationPolicy.OVERWRITE; + transfer.transferStatusUpdated = mockStatusUpdater; + } + + @Test + void testCreateTransfer() { + Transfer chatTransfer = Transfer.create(TransferPolicy.GROUP_BY_CHAT); + Transfer typeTransfer = Transfer.create(TransferPolicy.GROUP_BY_TYPE); + + assertNotNull(chatTransfer); + assertNotNull(typeTransfer); + assertInstanceOf(Transfer.GroupByChat.class, chatTransfer); + assertInstanceOf(Transfer.GroupByType.class, typeTransfer); + } + + @Test + void testTransferSuccessful(@TempDir Path tempDir) { + // Prepare mock file record + String fileName = "source.txt"; + String sourcePath = mockWaitingTransfer(tempDir, fileName); + + // Transfer + transfer.transfer(mockFileRecord); + + // Verify status updates and file moved + verify(mockStatusUpdater, times(2)).accept(any()); + + // Check final destination + Path expectedDestination = Path.of(transfer.destination).resolve("456").resolve("789").resolve(fileName); + assertTrue(Files.exists(expectedDestination)); + assertFalse(Files.exists(Paths.get(sourcePath))); + } + + @Test + void testDuplicationPolicySkip(@TempDir Path tempDir) { + String fileName = "source.txt"; + + // Prepare existing file + createExistingFile(fileName); + + // Setup mock + mockWaitingTransfer(tempDir, fileName); + + // Set skip policy + transfer.duplicationPolicy = DuplicationPolicy.SKIP; + + // Transfer + transfer.transfer(mockFileRecord); + + // Verify status updates + verify(mockStatusUpdater, times(1)).accept(argThat( + status -> status.transferStatus() == FileRecord.TransferStatus.idle + )); + } + + @Test + void testDuplicationPolicyRename(@TempDir Path tempDir) { + String fileName = "source.txt"; + // Prepare existing file + String existingFile = createExistingFile(fileName); + + // Setup mock + mockWaitingTransfer(tempDir, fileName); + + // Set rename policy + transfer.duplicationPolicy = DuplicationPolicy.RENAME; + + // Transfer + transfer.transfer(mockFileRecord); + + // Verify a new file was created with a suffix + File[] filesInDir = FileUtil.ls(Path.of(existingFile).getParent().toString()); + assertTrue(filesInDir.length >= 2); + assertTrue(Arrays.stream(filesInDir) + .anyMatch(f -> f.getName().contains("source-1.txt"))); + } + + @Test + void testTransferError(@TempDir Path tempDir) { + // Force an error by providing an invalid path + when(mockFileRecord.id()).thenReturn(123); + when(mockFileRecord.localPath()).thenReturn("/invalid/nonexistent/path"); + + // Transfer + transfer.transfer(mockFileRecord); + + // Verify error status update + verify(mockStatusUpdater, times(1)).accept(argThat( + status -> status.transferStatus() == FileRecord.TransferStatus.error + )); + } + + private String mockWaitingTransfer(Path tempDir, String fileName) { + String sourcePath = tempDir.resolve(fileName).toString(); + FileUtil.writeUtf8String("test content", sourcePath); + + when(mockFileRecord.id()).thenReturn(123); + when(mockFileRecord.localPath()).thenReturn(sourcePath); + when(mockFileRecord.telegramId()).thenReturn(456L); + when(mockFileRecord.chatId()).thenReturn(789L); + + return sourcePath; + } + + private String createExistingFile(String fileName) { + Path existingFile = Path.of(transfer.destination).resolve("456").resolve("789").resolve(fileName); + FileUtil.createTempFile(existingFile.getParent().toFile()); + FileUtil.writeUtf8String("existing content", existingFile.toString()); + + return existingFile.toString(); + } + +} diff --git a/web/package-lock.json b/web/package-lock.json index 3bfc5e9..e833bad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "telegram-files-web", - "version": "0.1.9", + "version": "0.1.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "telegram-files-web", - "version": "0.1.9", + "version": "0.1.11", "dependencies": { "@dnd-kit/core": "^6.2.0", "@dnd-kit/modifiers": "^8.0.0", @@ -17,12 +17,14 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.4", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.1", @@ -1658,6 +1660,229 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz", + "integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -2100,6 +2325,97 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", @@ -8963,6 +9279,106 @@ "@radix-ui/react-use-callback-ref": "1.1.0" } }, + "@radix-ui/react-hover-card": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz", + "integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "requires": { + "@radix-ui/react-primitive": "2.0.1" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "requires": {} + }, + "@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + } + }, + "@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "requires": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + } + }, + "@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "requires": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, + "@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, + "@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "requires": { + "@radix-ui/react-slot": "1.1.1" + } + }, + "@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1" + } + } + } + }, "@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -9186,6 +9602,49 @@ "@radix-ui/react-compose-refs": "1.1.0" } }, + "@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "requires": { + "@radix-ui/react-slot": "1.1.1" + } + }, + "@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1" + } + } + } + }, "@radix-ui/react-tabs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", diff --git a/web/package.json b/web/package.json index 9c3d677..e3c6fbe 100644 --- a/web/package.json +++ b/web/package.json @@ -25,12 +25,14 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.4", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.1", diff --git a/web/src/components/auto-download-dialog.tsx b/web/src/components/auto-download-dialog.tsx index 59e5cfd..59f26e0 100644 --- a/web/src/components/auto-download-dialog.tsx +++ b/web/src/components/auto-download-dialog.tsx @@ -2,12 +2,13 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useSWRMutation from "swr/mutation"; import { POST } from "@/lib/api"; import { useDebounce } from "use-debounce"; @@ -23,7 +24,15 @@ import { } from "./ui/accordion"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; -import { type AutoDownloadRule, type FileType } from "@/lib/types"; +import { + type AutoDownloadRule, + DuplicationPolicies, + type DuplicationPolicy, + type FileType, + TransferPolices, + type TransferPolicy, + type TransferRule, +} from "@/lib/types"; import { Select, SelectContent, @@ -32,7 +41,14 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "./ui/badge"; -import { X } from "lucide-react"; +import { Check, ChevronsUpDown, X } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { motion } from "framer-motion"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; +import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command"; +import { cn } from "@/lib/utils"; +import { useMutationObserver } from "@/hooks/use-mutation-observer"; export default function AutoDownloadDialog() { const { accountId } = useTelegramAccount(); @@ -90,6 +106,7 @@ export default function AutoDownloadDialog() { aria-describedby={undefined} onPointerDownOutside={() => setOpen(false)} onClick={(e) => e.stopPropagation()} + className="h-full w-full overflow-auto md:h-auto md:max-h-[85%] md:w-auto" > @@ -122,14 +139,14 @@ export default function AutoDownloadDialog() { Filter Keyword - + {chat.autoRule.query || "No keyword specified"}
- + File Types
@@ -150,6 +167,53 @@ export default function AutoDownloadDialog() { )}
+ + {/* Transfer Rule Section */} + {chat.autoRule.transferRule && ( +
+
+ + Transfer Rule + +
+
+ + Destination Folder + + + {chat.autoRule.transferRule.destination} + +
+
+ + Transfer Policy + + + {chat.autoRule.transferRule.transferPolicy} + +
+
+ + Duplication Policy + + + {chat.autoRule.transferRule.duplicationPolicy} + +
+
+ + Transfer History + + + {chat.autoRule.transferRule.transferHistory + ? "Enabled" + : "Disabled"} + +
+
+
+
+ )} )} @@ -200,22 +264,39 @@ export default function AutoDownloadDialog() { )} -
+ -
+ ); @@ -227,6 +308,8 @@ interface AutoDownloadRuleProps { } function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) { + const [autoTransfer, setAutoTransfer] = useState(false); + const handleQueryChange = (e: React.ChangeEvent) => { onChange({ ...value, @@ -252,6 +335,20 @@ function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) { }); }; + const handleTransferRuleChange = (changes: Partial) => { + if (!value.transferRule) { + return; + } + + onChange({ + ...value, + transferRule: { + ...value.transferRule, + ...changes, + }, + }); + }; + return ( @@ -304,8 +401,278 @@ function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) { + +
+
+ + { + if (checked) { + onChange({ + ...value, + transferRule: { + destination: "", + transferPolicy: "GROUP_BY_CHAT", + duplicationPolicy: "OVERWRITE", + transferHistory: false, + }, + }); + } else { + onChange({ + ...value, + transferRule: undefined, + }); + } + setAutoTransfer(checked); + }} + /> +
+ + +
+ + { + handleTransferRuleChange({ destination: e.target.value }); + }} + /> +
+ +
+ + + handleTransferRuleChange({ + transferPolicy: policy as TransferPolicy, + }) + } + /> +
+ +
+ + + handleTransferRuleChange({ + duplicationPolicy: policy as DuplicationPolicy, + }) + } + /> +
+ +
+
+ + + handleTransferRuleChange({ transferHistory: checked }) + } + /> +
+

+ Transfer files that are already downloaded to the specified + location. +

+
+
+
); } + +const PolicyLegends: Record< + TransferPolicy | DuplicationPolicy, + { + title: string; + description: string | React.ReactNode; + } +> = { + GROUP_BY_CHAT: { + title: "Group by Chat", + description: ( +
+

+ Transfer files to folders based on the chat name. +

+

Example:

+

+ {"/${Destination Folder}/${Telegram Id}/${Chat Id}/${file}"} +

+
+ ), + }, + GROUP_BY_TYPE: { + title: "Group by Type", + description: ( +
+

+ Transfer files to folders based on the file type.
+ All account files will be transferred to the same folder. +

+

Example:

+

+ {"/${Destination Folder}/${File Type}/${file}"} +

+
+ ), + }, + OVERWRITE: { + title: "Overwrite", + description: "This will overwrite the existing file if it already exists.", + }, + SKIP: { + title: "Skip", + description: "This will skip the file if it already exists.", + }, + RENAME: { + title: "Rename", + description: + "This strategy will rename the file, add a serial number after the file name, and then move the file to the destination folder", + }, + HASH: { + title: "Hash", + description: + "Calculate the hash (md5) of the file and compare with the existing file, if the hash is the same, delete the original file and set the local path to the existing file, otherwise, move the file", + }, +}; + +interface PolicySelectProps { + policyType: "transfer" | "duplication"; + value?: string; + onChange: (value: string) => void; +} + +function PolicySelect({ policyType, value, onChange }: PolicySelectProps) { + const [open, setOpen] = useState(false); + const polices = + policyType === "transfer" ? TransferPolices : DuplicationPolicies; + const [peekedPolicy, setPeekedPolicy] = useState(value ?? polices[0]); + + const peekPolicyLegend = useMemo(() => { + return PolicyLegends[peekedPolicy as TransferPolicy | DuplicationPolicy]; + }, [peekedPolicy]); + + return ( + + + + + + + +
+

+ {peekPolicyLegend?.title} +

+ {typeof peekPolicyLegend?.description === "string" ? ( +

+ {peekPolicyLegend?.description ?? ""} +

+ ) : ( + peekPolicyLegend?.description + )} +
+
+ + + + + {polices.map((policy) => ( + { + onChange(policy); + setOpen(false); + }} + /> + ))} + + + +
+
+
+ ); +} + +interface PolicyItemProps { + policy: string; + isSelected: boolean; + onSelect: () => void; + onPeek: (policy: string) => void; +} + +function PolicyItem({ policy, isSelected, onSelect, onPeek }: PolicyItemProps) { + const ref = React.useRef(null); + + useMutationObserver(ref, (mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "aria-selected" && + ref.current?.getAttribute("aria-selected") === "true" + ) { + onPeek(policy); + } + }); + }); + + return ( + + {policy} + + + ); +} diff --git a/web/src/components/file-card.tsx b/web/src/components/file-card.tsx index 6b84f07..e3316db 100644 --- a/web/src/components/file-card.tsx +++ b/web/src/components/file-card.tsx @@ -55,7 +55,7 @@ export function FileCard({ file }: FileCardProps) { {prettyBytes(file.size)} • {file.type} - +
- +
@@ -154,7 +158,7 @@ function FileDrawer({ {file.fileName}
- +
{file.caption && ( diff --git a/web/src/components/file-extra.tsx b/web/src/components/file-extra.tsx index 93c18e6..2f605de 100644 --- a/web/src/components/file-extra.tsx +++ b/web/src/components/file-extra.tsx @@ -95,7 +95,7 @@ function FilePath({ file }: { file: TelegramFile }) { -
{file.localPath}
+
{file.localPath}
); diff --git a/web/src/components/file-row.tsx b/web/src/components/file-row.tsx index eec9a27..e8aa405 100644 --- a/web/src/components/file-row.tsx +++ b/web/src/components/file-row.tsx @@ -16,7 +16,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import PhotoPreview from "@/components/photo-preview"; -import { env } from "@/env"; import prettyBytes from "pretty-bytes"; import FileStatus from "@/components/file-status"; import FileExtra from "@/components/file-extra"; @@ -114,13 +113,13 @@ export default function FileRow({ type: (
{file.type} - {env.NEXT_PUBLIC_MOCK_DATA === true && ( + {process.env.NODE_ENV === "development" && ( {file.id} )}
), size: {prettyBytes(file.size)}, - status: , + status: , extra: , actions: ( - {STATUS[status].text} - +
+ + {file.transferStatus === "idle" && ( + + + + {DOWNLOAD_STATUS[file.downloadStatus].text} + + + + )} + {file.downloadStatus === "completed" && + file.transferStatus && + file.transferStatus !== "idle" && ( + + + + {TRANSFER_STATUS[file.transferStatus].text} + + + + )} + +
); } diff --git a/web/src/components/proxys.tsx b/web/src/components/proxys.tsx index 28e0e04..07267dd 100644 --- a/web/src/components/proxys.tsx +++ b/web/src/components/proxys.tsx @@ -224,7 +224,7 @@ export default function Proxys({ ))} {enableSelect && ( -
+
-