Compare commits

..
18 Commits
Author SHA1 Message Date
death-claw c657cc7688 version bump 2024-01-14 19:06:16 +01:00
death-claw 49fb659d08 fix pom.xml 2024-01-14 18:54:05 +01:00
death-claw 71f9e208e6 version bump 2024-01-14 18:37:16 +01:00
death-clawandGitHub 7293d3d031 Merge pull request #159 from death-claw/feature/issue-156/allow-download-folder-rename
Feature/issue 156/allow download folder rename
2024-01-14 17:35:15 +00:00
death-clawandGitHub bf26100549 Feature/issue 156/allow download folder rename (#158)
* fixes #156
2024-01-14 17:33:43 +00:00
death-claw b6c7bd8e26 fixes #156 2024-01-14 18:33:08 +01:00
death-clawandGitHub 134abf791e fixes #156 (#157) 2024-01-14 17:31:34 +00:00
death-claw 1dcb07e124 fixes #156 2024-01-14 18:30:47 +01:00
death-clawandGitHub b5db9f8d7b fixes #154 (#155) 2024-01-13 21:27:29 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2130b30329 Bump ch.qos.logback:logback-classic from 1.4.11 to 1.4.12 (#140)
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.4.11 to 1.4.12.
- [Commits](https://github.com/qos-ch/logback/compare/v_1.4.11...v_1.4.12)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-13 19:10:13 +00:00
death-clawandGitHub 5f9c231eab fixes #152 (#153) 2024-01-13 19:00:46 +00:00
death-clawandGitHub 537ad90084 fixes #147 (#150) 2024-01-13 17:57:12 +00:00
death-clawandGitHub 81cf97cc50 fixes #145 (#146) 2023-12-12 20:15:48 +00:00
UncleRoger33andGitHub d82cf55590 Update README.md (#142) 2023-12-12 19:23:28 +00:00
death-clawandGitHub 884704c70d fixes #143 (#144) 2023-12-08 13:36:54 +00:00
death-claw e798218a21 version bump 2023-11-25 18:37:35 +01:00
death-claw 04df3bcc81 version bump 2023-11-25 18:30:02 +01:00
death-clawandGitHub 7af0aeafa7 fixes #137 (#138) 2023-11-25 18:25:57 +01:00
79 changed files with 10784 additions and 16867 deletions
+52
View File
@@ -2,6 +2,58 @@
This is my spin for a cross-platform gallery ripper for [vipergirls.to](https://vipergirls.to).
![1Jw0oq8h_o](https://github.com/UncleRoger33/vripper-project/assets/66418211/80b44389-7620-4e05-8696-4b62fa626b1b)
## Requirements
Direct access to vipergirls.to (or one of its [alternative domains](https://vipergirls.to/threads/5887340)), consider using a VPN otherwise.
## Supported Image Hosts
The following hosts are supported:
* acidimg.cc
* imagetwist.com
* imagezilla.com
* imgspice.com
* imagebam.com
* imgbox.com
* imx.to
* pimpandhost.com
* pixhost.to
* pixxxels.cc
* turboimagehost.com
* postimg.cc
* imagevenue.com
* pixroute.to
* vipr.im
## Installation
Download the latest version from the Release page and execute the installer depending on your Operating System
Application data (application logs, settings and persisted data) is stored in
HOME_FOLDER/vripper (Windows)
HOME_FOLDER/.config/vripper (Linux and macOS)
## Instructions for Jar
You need Java 17+, you can download from https://adoptium.net/
Download the latest jar file from the Release page, open a command prompt and run the jar file using the following command
For the GUI app
javaw -jar vripper-gui.jar
For the WEB app
java -jar vripper-web.jar
Application data (application logs, settings and persisted data) is stored in the location where you launched the jar for both GUI and WEB
## How to build
You need JDK 17 and a recent version of maven 3.6.1+
+32 -2
View File
@@ -6,7 +6,7 @@
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<packaging>pom</packaging>
<version>5.0.0</version>
<version>${revision}</version>
<properties>
<java.version>21</java.version>
<kotlin.version>1.9.20</kotlin.version>
@@ -16,7 +16,37 @@
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<revision>5.2.0</revision>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.1.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<modules>
<module>vripper-core</module>
<module>vripper-web-ui</module>
@@ -28,7 +58,7 @@
<dependency>
<artifactId>logback-classic</artifactId>
<groupId>ch.qos.logback</groupId>
<version>1.4.11</version>
<version>1.4.12</version>
</dependency>
<dependency>
<artifactId>kotlin-stdlib</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<version>5.0.0</version>
<version>${revision}</version>
</parent>
<artifactId>vripper-core</artifactId>
@@ -20,6 +20,7 @@ import me.vripper.utilities.formatToString
import net.jodah.failsafe.Failsafe
import net.jodah.failsafe.RetryPolicy
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.concurrent.CompletableFuture
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -218,41 +219,43 @@ class DownloadService(
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload()).with(GLOBAL_EXECUTOR)
.onFailure {
try {
dataTransaction.saveLog(
LogEntry(
type = LogEntry.Type.DOWNLOAD,
status = LogEntry.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
CompletableFuture.runAsync({
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
.onFailure {
try {
dataTransaction.saveLog(
LogEntry(
type = LogEntry.Type.DOWNLOAD,
status = LogEntry.Status.ERROR,
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.updateImage(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(), pendingCount()
)
)
)
} catch (exp: Exception) {
log.error("Failed to save event", exp)
}
log.error(
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
it.failure
)
val image = imageDownloadRunnable.context.image
image.status = Status.ERROR
dataTransaction.updateImage(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
eventBus.publishEvent(
QueueStateEvent(
QueueState(
runningCount(), pendingCount()
)
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
)
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
}.runAsync(imageDownloadRunnable::run)
}.run(imageDownloadRunnable::run)
}, GLOBAL_EXECUTOR)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
@@ -20,6 +20,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.pathString
class ImageDownloadRunnable(
@@ -43,10 +44,8 @@ class ImageDownloadRunnable(
image.status = Status.DOWNLOADING
image.downloaded = 0
dataTransaction.updateImage(image)
val downloadDirectory: String
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
downloadDirectory = post.downloadDirectory
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
@@ -61,13 +60,14 @@ class ImageDownloadRunnable(
log.debug(
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
)
checkImageTypeAndRename(
downloadDirectory, downloadedImage, image.index
)
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, image.index
)
if (image.downloaded == image.size && image.size > 0) {
image.status = Status.FINISHED
val post = dataTransaction.findPostById(context.postId).orElseThrow()
post.done += 1
post.downloaded += image.size
dataTransaction.updatePost(post)
@@ -102,9 +102,7 @@ class ImageDownloadRunnable(
if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
synchronized(downloadDestinationFolder.pathString.intern()) {
Files.createDirectories(downloadDestinationFolder)
}
Files.createDirectories(downloadDestinationFolder)
val image = downloadDestinationFolder.resolve(
"${
if (settings.downloadSettings.forceOrder) String.format(
@@ -3,7 +3,7 @@ package me.vripper.entities
import me.vripper.entities.domain.Status
data class Image(
var id: Long = -1,
val id: Long = -1,
val postId: Long,
val url: String,
val thumbUrl: String,
@@ -3,9 +3,11 @@ package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import me.vripper.entities.domain.Status
import java.time.LocalDateTime
import kotlin.io.path.Path
import kotlin.io.path.pathString
data class Post(
var id: Long = -1,
val id: Long = -1,
val postTitle: String,
val threadTitle: String,
val forum: String,
@@ -17,12 +19,18 @@ data class Post(
val hosts: Set<String>,
val downloadDirectory: String,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val addedOn: LocalDateTime = LocalDateTime.now(),
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
) {
fun getDownloadFolder(): String {
return Path(downloadDirectory, folderName).pathString
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@@ -9,12 +9,9 @@ import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Settings
data class PostEvent(
val add: List<Post> = emptyList(),
val update: List<Post> = emptyList(),
val delete: List<Long> = emptyList()
)
data class PostCreateEvent(val posts: List<Post>)
data class PostUpdateEvent(val posts: List<Post>)
data class PostDeleteEvent(val postIds: List<Long>)
data class ImageEvent(val images: List<Image>)
data class ThreadCreateEvent(val thread: Thread)
data class ThreadDeleteEvent(val threadId: Long)
@@ -8,10 +8,10 @@ import java.util.concurrent.Executors
object EventBus {
private val Scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
private val scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
private val _events = Sinks.many().multicast().onBackpressureBuffer<Any>()
val events: Flux<Any> = _events.asFlux().publishOn(Scheduler)
val events: Flux<Any> = _events.asFlux().publishOn(scheduler)
fun publishEvent(event: Any) {
_events.tryEmitNext(event)
_events.emitNext(event) { _, _ -> true }
}
}
@@ -23,6 +23,7 @@ class PostDownloadStateRepositoryImpl :
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postTitle] = post.postTitle
@@ -90,6 +91,7 @@ class PostDownloadStateRepositoryImpl :
it[rank] = post.rank
it[size] = post.size
it[downloaded] = post.downloaded
it[folderName] = post.folderName
}
}
@@ -102,6 +104,7 @@ class PostDownloadStateRepositoryImpl :
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postTitle] = post.postTitle
@@ -171,6 +174,7 @@ class PostDownloadStateRepositoryImpl :
val hosts =
resultRow[PostTable.hosts].split(delimiter).dropLastWhile { it.isEmpty() }.toSet()
val downloadDirectory = resultRow[PostTable.outputPath]
val folderName = resultRow[PostTable.folderName]
val addedOn = resultRow[PostTable.addedAt]
val rank = resultRow[PostTable.rank]
val size = resultRow[PostTable.size]
@@ -188,6 +192,7 @@ class PostDownloadStateRepositoryImpl :
hosts,
downloadDirectory,
addedOn,
folderName,
status,
done,
rank,
@@ -8,9 +8,12 @@ import me.vripper.services.*
import me.vripper.tasks.AddPostRunnable
import me.vripper.tasks.ThreadLookupRunnable
import me.vripper.utilities.GLOBAL_EXECUTOR
import me.vripper.utilities.PathUtils
import java.util.*
import java.util.concurrent.CompletableFuture
import java.util.regex.Pattern
import kotlin.io.path.Path
import kotlin.io.path.exists
class AppEndpointService(
private val downloadService: DownloadService,
@@ -65,7 +68,7 @@ class AppEndpointService(
@Synchronized
fun restartAll(posIds: List<Long> = listOf()) {
downloadService.restartAll(posIds.map { dataTransaction.findPostsByPostId(it) }.filter { it.isPresent }
downloadService.restartAll(posIds.map { dataTransaction.findPostByPostId(it) }.filter { it.isPresent }
.map { it.get() })
}
@@ -139,6 +142,19 @@ class AppEndpointService(
fun logClear() {
dataTransaction.deleteAllLogs()
}
fun rename(postId: Long, name: String) {
CompletableFuture.runAsync({
synchronized(postId.toString().intern()) {
dataTransaction.findPostByPostId(postId).ifPresent { post ->
if (Path(post.downloadDirectory, post.folderName).exists()) {
PathUtils.rename(post.downloadDirectory, post.folderName, name)
}
post.folderName = name
dataTransaction.updatePost(post)
}
}
}, GLOBAL_EXECUTOR)
}
}
@@ -7,6 +7,7 @@ import me.vripper.model.ErrorCount
import me.vripper.model.PostItem
import me.vripper.repositories.*
import me.vripper.utilities.PathUtils
import me.vripper.utilities.PathUtils.sanitize
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
import kotlin.io.path.pathString
@@ -27,12 +28,12 @@ class DataTransaction(
fun updatePosts(posts: List<Post>) {
transaction { postDownloadStateRepository.update(posts) }
eventBus.publishEvent(PostEvent(update = posts))
eventBus.publishEvent(PostUpdateEvent(posts))
}
fun updatePost(posts: Post) {
transaction { postDownloadStateRepository.update(posts) }
eventBus.publishEvent(PostEvent(update = listOf(posts)))
fun updatePost(post: Post) {
transaction { postDownloadStateRepository.update(post) }
eventBus.publishEvent(PostUpdateEvent(listOf(post)))
}
fun save(thread: Thread) {
@@ -75,10 +76,11 @@ class DataTransaction(
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
postItem.title,
postItem.postId,
settingsService.settings
).pathString
).pathString,
folderName = if (settingsService.settings.downloadSettings.appendPostId) "${sanitize(postItem.title)}_${postItem.postId}" else sanitize(
postItem.title
)
)
val images = postItem.imageItemList.mapIndexed { index, imageItem ->
Image(
@@ -102,7 +104,7 @@ class DataTransaction(
}
savedPosts
}
eventBus.publishEvent(PostEvent(add = savedPosts))
eventBus.publishEvent(PostCreateEvent(savedPosts))
return savedPosts
}
@@ -115,7 +117,7 @@ class DataTransaction(
}
fun finishPost(postId: Long, automatic: Boolean = false) {
val post = findPostsByPostId(postId).orElseThrow()
val post = findPostByPostId(postId).orElseThrow()
val imagesInErrorStatus = findByPostIdAndIsError(post.postId)
if (imagesInErrorStatus.isNotEmpty()) {
post.status = Status.ERROR
@@ -150,7 +152,7 @@ class DataTransaction(
}
eventBus.publishEvent(PostEvent(delete = postIds))
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
@@ -239,7 +241,7 @@ class DataTransaction(
return transaction { imageRepository.countError() }
}
fun findPostsByPostId(postId: Long): Optional<Post> {
fun findPostByPostId(postId: Long): Optional<Post> {
return transaction { postDownloadStateRepository.findByPostId(postId) }
}
@@ -8,6 +8,7 @@ object PostTable : LongIdTable(name = "POST", columnName = "ID") {
val done = integer("DONE")
val hosts = varchar("HOSTS", 255)
val outputPath = varchar("OUTPUT_PATH", 260)
val folderName = varchar("FOLDER_NAME", 260)
val postId = long("POST_ID")
val status = varchar("STATUS", 15)
val threadId = long("THREAD_ID")
@@ -6,7 +6,7 @@ import kotlin.io.path.Path
import kotlin.io.path.pathString
object ApplicationProperties {
const val VERSION: String = "5.0.0"
const val VERSION: String = "5.2.0"
private const val BASE_DIR_NAME: String = "vripper"
private val portable = System.getProperty("vripper.portable", "true").toBoolean()
private val BASE_DIR: String = getBaseDir()
@@ -1,7 +1,13 @@
package me.vripper.utilities
import me.vripper.exception.RenameException
import me.vripper.model.Settings
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.Path
object PathUtils {
private val log by me.vripper.delegate.LoggerDelegate()
@@ -9,7 +15,7 @@ object PathUtils {
fun calculateDownloadPath(
forum: String, threadTitle: String, postTitle: String, postId: Long, settings: Settings
forum: String, threadTitle: String, settings: Settings
): Path {
var downloadDirectory = if (settings.downloadSettings.forumSubDirectory) Path.of(
settings.downloadSettings.downloadPath, sanitize(forum)
@@ -18,14 +24,29 @@ object PathUtils {
)
downloadDirectory =
if (settings.downloadSettings.threadSubLocation) downloadDirectory.resolve(threadTitle) else downloadDirectory
downloadDirectory = downloadDirectory.resolve(
if (settings.downloadSettings.appendPostId) "${sanitize(postTitle)}_${postId}" else sanitize(
postTitle
)
)
return downloadDirectory
}
@Throws(RenameException::class)
fun rename(downloadDirectory: String, oldFolder: String, newFolder: String) {
val currentDownloadDirectory = Path(downloadDirectory, oldFolder)
val newDownloadDirectory = Path(downloadDirectory, sanitize(newFolder))
try {
Files.move(
currentDownloadDirectory,
newDownloadDirectory,
StandardCopyOption.ATOMIC_MOVE
)
} catch (e: IOException) {
throw RenameException(
String.format(
"Failed to move files from %s to %s", currentDownloadDirectory, newDownloadDirectory
),
e
)
}
}
/**
* Will sanitize the image name and remove extension
*
@@ -145,4 +145,9 @@
<column name="THREAD_ID"/>
</createIndex>
</changeSet>
<changeSet id="0006" author="vripper">
<addColumn tableName="POST">
<column name="FOLDER_NAME" type="VARCHAR(260)" afterColumn="OUTPUT_PATH" defaultValue=""/>
</addColumn>
</changeSet>
</databaseChangeLog>
+2 -2
View File
@@ -7,7 +7,7 @@
<parent>
<artifactId>vripper-project</artifactId>
<groupId>me.vripper</groupId>
<version>5.0.0</version>
<version>${revision}</version>
</parent>
@@ -100,7 +100,7 @@
<dependency>
<artifactId>vripper-core</artifactId>
<groupId>me.vripper</groupId>
<version>5.0.0</version>
<version>${revision}</version>
</dependency>
</dependencies>
@@ -2,7 +2,6 @@ package me.vripper.gui.controller
import kotlinx.coroutines.*
import me.vripper.entities.Image
import me.vripper.entities.Post
import me.vripper.gui.model.PostModel
import me.vripper.services.AppEndpointService
import me.vripper.services.DataTransaction
@@ -58,25 +57,26 @@ class PostController : Controller() {
}
fun findAllPosts(): Deferred<List<PostModel>> {
return coroutineScope.async { dataTransaction.findAllPosts().map(::mapper) }
return coroutineScope.async { dataTransaction.findAllPosts().map { it.id }.map(::mapper) }
}
fun mapper(it: Post): PostModel {
val updated = dataTransaction.findPostById(it.id).orElseThrow()
fun mapper(id: Long): PostModel {
val post = dataTransaction.findPostById(id).orElseThrow()
return PostModel(
updated.postId,
updated.postTitle,
progress(updated.total, updated.done),
updated.status.name,
updated.url,
updated.done,
updated.total,
updated.hosts.joinToString(separator = ", "),
updated.addedOn.format(dateTimeFormatter),
updated.rank + 1,
updated.downloadDirectory,
progressCount(updated.total, updated.done, updated.downloaded),
dataTransaction.findImagesByPostId(updated.postId).map(Image::thumbUrl).take(4)
post.postId,
post.postTitle,
progress(post.total, post.done),
post.status.name,
post.url,
post.done,
post.total,
post.hosts.joinToString(separator = ", "),
post.addedOn.format(dateTimeFormatter),
post.rank + 1,
post.getDownloadFolder(),
post.folderName,
progressCount(post.total, post.done, post.downloaded),
dataTransaction.findImagesByPostId(post.postId).map(Image::thumbUrl).take(4)
)
}
@@ -87,4 +87,8 @@ class PostController : Controller() {
fun progress(total: Int, done: Int): Double {
return if (done == 0 && total == 0) 0.0 else (done.toDouble() / total)
}
fun rename(postId: Long, value: String) {
appEndpointService.rename(postId, value)
}
}
@@ -19,6 +19,7 @@ class PostModel(
addedOn: String,
order: Int,
path: String,
folderName: String,
progressCount: String,
previewList: List<String>
) {
@@ -55,6 +56,9 @@ class PostModel(
val pathProperty = SimpleStringProperty(path)
var path: String by pathProperty
val folderNameProperty = SimpleStringProperty(folderName)
var folderName: String by folderNameProperty
val progressCountProperty = SimpleStringProperty(progressCount)
var progressCount: String by progressCountProperty
@@ -1,5 +1,6 @@
package me.vripper.gui.view
import javafx.event.EventHandler
import javafx.scene.control.ProgressIndicator
import javafx.scene.image.Image
import javafx.scene.image.ImageView
@@ -7,13 +8,15 @@ import javafx.scene.layout.HBox
import javafx.stage.Popup
import javafx.stage.Stage
import kotlinx.coroutines.*
import me.vripper.delegate.LoggerDelegate
import me.vripper.gui.view.PreviewCache.previewDispatcher
import tornadofx.add
import tornadofx.runLater
import java.io.ByteArrayInputStream
class Preview(private val owner: Stage, val images: List<String>) {
class Preview(private val owner: Stage, private val images: List<String>) {
private val log by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var previewLoadJob: Job? = null
val previewPopup = Popup()
@@ -43,6 +46,9 @@ class Preview(private val owner: Stage, val images: List<String>) {
yield()
runLater {
val hBox = HBox()
hBox.onMouseEntered = EventHandler {
hide()
}
imageViewList.forEach { hBox.add(it) }
previewPopup.content.clear()
previewPopup.content.add(hBox)
@@ -54,16 +60,19 @@ class Preview(private val owner: Stage, val images: List<String>) {
private fun previewLoading(url: String): Deferred<ImageView?> {
return coroutineScope.async(previewDispatcher) {
val imageView = try {
ImageView(Image(ByteArrayInputStream(PreviewCache.cache[url]))).apply {
isPreserveRatio = true
ByteArrayInputStream(PreviewCache.cache[url]).use {
ImageView(Image(it)).apply {
isPreserveRatio = true
fitWidth = if (image.width > 200.0) {
if (image.width > image.height) 200.0 * image.width / image.height else 200.0
} else {
200.0
fitWidth = if (image.width > 200.0) {
if (image.width > image.height) 200.0 * image.width / image.height else 200.0
} else {
200.0
}
}
}
} catch (e: Exception) {
log.warn("Failed to load preview $url")
null
}
imageView
@@ -28,7 +28,7 @@ object PreviewCache : KoinComponent {
.maximumWeight(1024 * 1024 * 100)
.build(::load)
fun load(url: String): ByteArray {
private fun load(url: String): ByteArray {
val path = cachePath.resolve(url.hash256())
if (Files.exists(path) && Files.isRegularFile(path)) {
return Files.readAllBytes(path)
@@ -98,6 +98,25 @@ class DownloadActionsView : View() {
postsTableView.stopSelected()
}
}
button("Rename") {
setPrefSize(32.0, 32.0)
imageview("edit.png") {
fitWidth = 32.0
fitHeight = 32.0
}
addClass(Styles.actionBarButton)
contentDisplay = ContentDisplay.GRAPHIC_ONLY
tooltip("Rename selected [Ctrl+R]")
shortcut(KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN))
enableWhen(
postsTableView.tableView.selectionModel.selectedItems.sizeProperty.greaterThan(
0
)
)
action {
postsTableView.renameSelected()
}
}
button("Delete") {
setPrefSize(32.0, 32.0)
imageview("trash.png") {
@@ -0,0 +1,42 @@
package me.vripper.gui.view.popup
import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Pos
import javafx.scene.control.TextField
import javafx.scene.layout.Priority
import javafx.scene.layout.VBox
import me.vripper.gui.controller.PostController
import tornadofx.*
class RenameView : Fragment("Rename download post") {
val postId: Long by param()
val name: String by param()
private val textAreaProperty = SimpleStringProperty()
private val postController: PostController by inject()
lateinit var input: TextField
override fun onDock() {
textAreaProperty.value = name
}
override val root = vbox(alignment = Pos.CENTER_RIGHT) {
padding = insets(all = 5)
spacing = 5.0
input = textfield {
VBox.setVgrow(this, Priority.ALWAYS)
bind(textAreaProperty)
}
button("Ok") {
imageview("search.png") {
fitWidth = 18.0
fitHeight = 18.0
}
disableWhen(textAreaProperty.isEmpty)
action {
postController.rename(postId, textAreaProperty.value)
close()
}
}
}
}
@@ -51,7 +51,7 @@ class ImagesTableView : Fragment("Photos") {
it.copy(images = it.images.filter { image: Image -> image.postId == postId })
}.filter {
it.images.isNotEmpty()
}.buffer(Duration.ofMillis(125))
}.buffer(Duration.ofMillis(175))
.subscribe { imageEvent ->
runLater {
for (image in imageEvent.map { it.images }.flatten().reversed().distinct()) {
@@ -14,13 +14,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.PostEvent
import me.vripper.event.PostCreateEvent
import me.vripper.event.PostDeleteEvent
import me.vripper.event.PostUpdateEvent
import me.vripper.gui.clipboard.ClipboardService
import me.vripper.gui.controller.PostController
import me.vripper.gui.model.PostModel
import me.vripper.gui.view.*
import me.vripper.gui.view.popup.RenameView
import tornadofx.*
import java.time.Duration
class PostsTableView : View() {
@@ -54,15 +56,17 @@ class PostsTableView : View() {
clipboardService.init()
}
}
eventBus.events.ofType(PostEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { postEvents ->
runLater {
postEvents.map { it.delete }.flatten().forEach {
items.removeIf { p -> p.postId == it }
}
val add = postEvents.map { it.add }.flatten().map { postController.mapper(it) }
tableView.items.addAll(add)
for (post in postEvents.map { it.update }.flatten().reversed().distinct()) {
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { postEvents ->
val add = postEvents.posts.map { postController.mapper(it.id) }
runLater {
tableView.items.addAll(add)
}
}
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { postEvents ->
runLater {
for (post in postEvents.posts) {
val postModel = items.find { it.postId == post.postId } ?: continue
postModel.status = post.status.name
@@ -74,6 +78,16 @@ class PostsTableView : View() {
postModel.progress = postController.progress(
post.total, post.done
)
postModel.path = post.getDownloadFolder()
postModel.folderName = post.folderName
}
}
}
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { postEvents ->
runLater {
postEvents.postIds.forEach {
items.removeIf { p -> p.postId == it }
}
}
}
@@ -110,6 +124,16 @@ class PostsTableView : View() {
}
}
val renameItem = MenuItem("Rename").apply {
setOnAction {
rename(tableRow.item)
}
graphic = ImageView("edit.png").apply {
fitWidth = 18.0
fitHeight = 18.0
}
}
val deleteItem = MenuItem("Delete").apply {
setOnAction {
deleteSelected()
@@ -152,7 +176,7 @@ class PostsTableView : View() {
val contextMenu = ContextMenu()
contextMenu.items.addAll(
startItem, stopItem, deleteItem, SeparatorMenuItem(), detailsItem, locationItem, urlItem
startItem, stopItem, renameItem, deleteItem, SeparatorMenuItem(), detailsItem, locationItem, urlItem
)
tableRow.contextMenuProperty()
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
@@ -163,23 +187,24 @@ class PostsTableView : View() {
cellFactory = Callback {
val cell = PreviewTableCell<PostModel>()
cell.onMouseEntered = EventHandler { mouseEvent ->
preview?.hide()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview = Preview(currentStage!!, cell.tableRow.item.previewList)
preview?.previewPopup?.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseExited = EventHandler {
preview?.hide()
}
}
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseExited = EventHandler {
preview?.hide()
}
cell
}
}
@@ -272,6 +297,20 @@ class PostsTableView : View() {
}
}
private fun rename(post: PostModel) {
find<RenameView>(mapOf(RenameView::postId to post.postId, RenameView::name to post.folderName)).openModal()
?.apply {
minWidth = 300.0
}
}
fun renameSelected() {
val selectedItem = tableView.selectionModel.selectedItem
if (selectedItem != null) {
rename(selectedItem)
}
}
fun deleteSelected() {
val postIdList = tableView.selectionModel.selectedItems.map { it.postId }
confirm(
@@ -84,23 +84,24 @@ class ThreadSelectionTableView : Fragment("Thread") {
val cell = PreviewTableCell<ThreadSelectionModel>()
cell.alignment = Pos.CENTER
cell.onMouseEntered = EventHandler { mouseEvent ->
preview?.hide()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview = Preview(currentStage!!, cell.tableRow.item.previewList)
preview?.previewPopup?.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseExited = EventHandler {
preview?.hide()
}
}
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseExited = EventHandler {
preview?.hide()
}
cell
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

+3 -5
View File
@@ -30,7 +30,6 @@
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
@@ -61,10 +60,10 @@
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "vripper-web-ui:build:production"
"buildTarget": "vripper-web-ui:build:production"
},
"development": {
"browserTarget": "vripper-web-ui:build:development"
"buildTarget": "vripper-web-ui:build:development"
}
},
"options": {
@@ -75,7 +74,7 @@
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "vripper-web-ui:build"
"buildTarget": "vripper-web-ui:build"
}
},
"test": {
@@ -92,7 +91,6 @@
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
-15784
View File
File diff suppressed because it is too large Load Diff
+23 -25
View File
@@ -11,37 +11,35 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^16.0.0",
"@angular/cdk": "^16.0.0",
"@angular/common": "^16.0.0",
"@angular/compiler": "^16.0.0",
"@angular/core": "^16.0.0",
"@angular/forms": "^16.0.0",
"@angular/material": "^16.0.0",
"@angular/platform-browser": "^16.0.0",
"@angular/platform-browser-dynamic": "^16.0.0",
"@angular/router": "^16.0.0",
"@angular/animations": "^17.0.5",
"@angular/cdk": "^17.0.1",
"@angular/common": "^17.0.5",
"@angular/compiler": "^17.0.5",
"@angular/core": "^17.0.5",
"@angular/forms": "^17.0.5",
"@angular/material": "^17.0.1",
"@angular/platform-browser": "^17.0.5",
"@angular/platform-browser-dynamic": "^17.0.5",
"@angular/router": "^17.0.5",
"@stomp/rx-stomp": "^2.0.0",
"@stomp/stompjs": "^7.0.0",
"ag-grid-angular": "^29.3.4",
"ag-grid-community": "^29.3.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.13.0"
"zone.js": "~0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.0.0",
"@angular-eslint/builder": "16.0.1",
"@angular-eslint/eslint-plugin": "16.0.1",
"@angular-eslint/eslint-plugin-template": "16.0.1",
"@angular-eslint/schematics": "16.0.1",
"@angular-eslint/template-parser": "16.0.1",
"@angular/cli": "~16.0.0",
"@angular/compiler-cli": "^16.0.0",
"@angular-devkit/build-angular": "^17.0.5",
"@angular-eslint/builder": "17.1.1",
"@angular-eslint/eslint-plugin": "17.1.1",
"@angular-eslint/eslint-plugin-template": "17.1.1",
"@angular-eslint/schematics": "17.1.1",
"@angular-eslint/template-parser": "17.1.1",
"@angular/cli": "~17.0.5",
"@angular/compiler-cli": "^17.0.5",
"@types/jasmine": "~4.3.0",
"@typescript-eslint/eslint-plugin": "5.59.2",
"@typescript-eslint/parser": "5.59.2",
"eslint": "^8.39.0",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"jasmine-core": "~4.6.0",
@@ -52,6 +50,6 @@
"karma-jasmine-html-reporter": "~2.0.0",
"prettier": "^2.8.8",
"prettier-eslint": "^15.0.1",
"typescript": "~5.0.2"
"typescript": "~5.2.2"
}
}
+19 -9
View File
@@ -3,9 +3,15 @@
xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<version>${revision}</version>
</parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-web-ui</artifactId>
<version>5.0.0</version>
<version>${revision}</version>
<name>vripper-web-ui</name>
<build>
@@ -19,27 +25,31 @@
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.1</version>
<version>1.15.0</version>
<configuration>
<nodeVersion>v18.16.0</nodeVersion>
<nodeVersion>v20.10.0</nodeVersion>
<yarnVersion>v1.22.21</yarnVersion>
</configuration>
<executions>
<execution>
<id>install node</id>
<id>install node and yarn</id>
<goals>
<goal>install-node-and-npm</goal>
<goal>install-node-and-yarn</goal>
</goals>
</execution>
<execution>
<id>npm install</id>
<id>yarn install</id>
<goals>
<goal>npm</goal>
<goal>yarn</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>npm run build</id>
<id>yarn run build</id>
<goals>
<goal>npm</goal>
<goal>yarn</goal>
</goals>
<configuration>
<arguments>run build</arguments>
+3 -1
View File
@@ -9,6 +9,7 @@
(startSelected)="onStartSelected()"
(stopSelected)="onStopSelected()"
(deleteSelected)="onDeleteSelected()"
(renameSelected)='onRenameSelected()'
(clearDownload)="onClearDownloads()"
(clearLogs)="onClearLogs()"
(clearThreads)="onClearThreads()"></app-toolbar>
@@ -24,7 +25,8 @@
</mat-icon>
</ng-template>
<app-download-table
(selectedChange)="onPostsSelectionChange($event)"
(selectedPostsChange)='onPostsSelectionChange($event)'
(selectedPostChange)='onPostSelectionChange($event)'
(rowCountChange)="downloadCount.set($event)"></app-download-table>
</mat-tab>
<mat-tab>
+53 -3
View File
@@ -1,4 +1,4 @@
import { Component, computed, signal } from '@angular/core';
import { Component, computed, signal, WritableSignal } from '@angular/core';
import { ToolbarComponent } from './toolbar/toolbar.component';
import { CommonModule, NgIf } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
@@ -16,10 +16,18 @@ import {
} from '@angular/material/dialog';
import { SettingsComponent } from './settings/settings.component';
import { Settings } from './domain/settings.model';
import { ConfirmComponent, ConfirmDialogData } from './confirm/confirm.component';
import { Subject } from 'rxjs';
import {
ConfirmComponent,
ConfirmDialogData,
} from './confirm/confirm.component';
import { EMPTY, mergeMap, Subject } from 'rxjs';
import { RxStompState } from '@stomp/rx-stomp';
import { StatusBarComponent } from './status-bar/status-bar.component';
import {
RenameDialogComponent,
RenameDialogData,
RenameDialogResult,
} from './rename-dialog/rename-dialog.component';
@Component({
selector: 'app-root',
@@ -46,6 +54,7 @@ export class AppComponent {
logCount = signal(0);
tabIndex = signal(0);
selectedPost: WritableSignal<Post | null> = signal(null);
selectedPosts = signal([] as Post[]);
noPostSelected = computed(() => this.selectedPosts().length === 0);
loading = computed(
@@ -68,6 +77,10 @@ export class AppComponent {
this.selectedPosts.set(selectedPosts);
};
onPostSelectionChange = (selectedPost: Post) => {
this.selectedPost.set(selectedPost);
};
onStartSelected = () => {
if (this.selectedPosts().length > 0) {
this.applicationEndpoint.startPosts(this.selectedPosts()).subscribe();
@@ -96,6 +109,10 @@ export class AppComponent {
this.applicationEndpoint.settings().subscribe(s =>
this.dialog.open<SettingsComponent, Settings>(SettingsComponent, {
data: s,
maxWidth: '100vw',
maxHeight: '100vh',
width: '80vw',
height: '80vh',
})
);
};
@@ -115,6 +132,39 @@ export class AppComponent {
});
};
onRenameSelected = () => {
if (this.selectedPost() == null) {
return;
}
const post = this.selectedPost();
if (post?.postId == null || post?.folderName == null) {
return;
}
const dialog: MatDialogRef<RenameDialogComponent, RenameDialogResult> =
this.dialog.open<
RenameDialogComponent,
RenameDialogData,
RenameDialogResult
>(RenameDialogComponent, {
data: {
postId: post.postId,
name: post.folderName,
},
});
dialog
.afterClosed()
.pipe(
mergeMap(result => {
if (result) {
return this.applicationEndpoint.renamePost(result);
} else {
return EMPTY;
}
})
)
.subscribe();
};
onClearDownloads = () => {
const confirmDialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
this.dialog.open<ConfirmComponent, ConfirmDialogData>(ConfirmComponent, {
@@ -1,10 +1,8 @@
<h2 mat-dialog-title>Confirmation</h2>
<mat-dialog-content
class="mat-typography"
style="min-width: 30vw">
<mat-dialog-content class="mat-typography" style="min-width: 30vw">
{{ data.message }}
<mat-dialog-actions align="end">
<button mat-button (click)="confirm()" cdkFocusInitial>Yes</button>
<button mat-button mat-dialog-close cdkFocusInitial>No</button>
<button mat-flat-button mat-dialog-close cdkFocusInitial>No</button>
<button mat-flat-button color="primary" (click)="confirm()">Yes</button>
</mat-dialog-actions>
</mat-dialog-content>
@@ -0,0 +1,21 @@
import { Image } from './image.model';
import { signal, WritableSignal } from '@angular/core';
import { progress, statusIcon } from '../utils/utils';
export class ImageRow extends Image {
public progress: WritableSignal<number>;
public statusIcon: WritableSignal<string>;
constructor(
postId: number,
url: string,
status: string,
index: number,
downloaded: number,
size: number
) {
super(postId, url, status, index, downloaded, size);
this.progress = signal(progress(downloaded, size));
this.statusIcon = signal(statusIcon(status));
}
}
@@ -0,0 +1,42 @@
import { Log } from './log.model';
import { signal, WritableSignal } from '@angular/core';
export class LogRow extends Log {
public statusSignal: WritableSignal<string>;
public messageSignal: WritableSignal<string>;
constructor(
id: number,
type: string,
status: string,
time: string,
message: string
) {
super(id, formatType(type), status, time, message);
this.statusSignal = signal(status);
this.messageSignal = signal(message);
}
}
export function formatType(status: string) {
switch (status) {
case 'POST':
return '🖼️ New gallery';
case 'THREAD':
return '🧵 New thread';
case 'THANKS':
return '👍 Sending a like ';
case 'SCAN':
return '🔍 Links scan';
case 'METADATA':
case 'METADATA_CACHE_MISS':
return '🗄️ Loading post metadata';
case 'QUEUED':
case 'QUEUED_CACHE_MISS':
return '📋 Loading multi-post link';
case 'DOWNLOAD':
return '📥 Download';
default:
return status;
}
}
@@ -0,0 +1,46 @@
import { Post } from './post.model';
import { signal, WritableSignal } from '@angular/core';
import { progress, statusIcon, totalFormatter } from '../utils/utils';
export class PostRow extends Post {
public statusIcon: WritableSignal<string>;
public progress: WritableSignal<number>;
public total2: WritableSignal<string>;
public path: WritableSignal<string>;
constructor(
postId: number,
postTitle: string,
status: string,
url: string,
done: number,
total: number,
hosts: string[],
addedOn: string,
rank: number,
downloadDirectory: string,
folderName: string,
downloadFolder: string,
downloaded: number
) {
super(
postId,
postTitle,
status,
url,
done,
total,
hosts,
addedOn,
rank,
downloadDirectory,
folderName,
downloadFolder,
downloaded
);
this.statusIcon = signal(statusIcon(status));
this.progress = signal(progress(done, total));
this.total2 = signal(totalFormatter(done, total, downloaded));
this.path = signal(downloadFolder);
}
}
@@ -10,6 +10,8 @@ export class Post {
public addedOn: string,
public rank: number,
public downloadDirectory: string,
public folderName: string,
public downloadFolder: string,
public downloaded: number
) {}
}
@@ -0,0 +1,7 @@
import { Thread } from './thread.model';
export class ThreadRow extends Thread {
constructor(link: string, title: string, threadId: number, total: number) {
super(link, title, threadId, total);
}
}
@@ -0,0 +1,18 @@
@use 'sass:map';
@use '@angular/material' as mat;
@mixin color($theme) {
.row:hover {
background-color: mat.get-theme-color($theme, background, hover);
}
.selected.row {
background-color: mat.get-theme-color($theme, background, selected-button);
}
}
@mixin theme($theme) {
@if mat.theme-has($theme, color) {
@include color($theme);
}
}
@@ -1,8 +1,105 @@
<ag-grid-angular
id="download-grid"
style="width: 100%; height: 100%"
class="ag-theme-alpine"
#agGrid
[gridOptions]="gridOptions"
(contextmenu)="disableForRows($event)">
</ag-grid-angular>
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
matColumnDef="title">
<th *matHeaderCellDef mat-header-cell>
<mat-checkbox
(change)="$event ? toggleAllRows() : null"
[aria-label]="checkboxLabel()"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
color="primary">
</mat-checkbox>
Title
</th>
<td
*matCellDef="let element"
[title]="element.postTitle"
class="truncate-cell"
mat-cell>
{{ element.postTitle }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
matColumnDef="progress">
<th *matHeaderCellDef mat-header-cell>Progress</th>
<td *matCellDef="let element" mat-cell>
<mat-progress-bar
[value]="element.progress()"
mode="determinate"></mat-progress-bar>
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
matColumnDef="status">
<th *matHeaderCellDef mat-header-cell>Status</th>
<td *matCellDef="let element" mat-cell>
<mat-icon
[fontIcon]="element.statusIcon()"
aria-hidden="false"
color="primary"></mat-icon>
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'path')"
matColumnDef="path">
<th *matHeaderCellDef mat-header-cell>Path</th>
<td
*matCellDef="let element"
[title]='element.path()'
class="truncate-cell"
mat-cell>
{{ element.path() }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
matColumnDef="total">
<th *matHeaderCellDef mat-header-cell>Total</th>
<td *matCellDef="let element" class="truncate-cell" mat-cell>
{{ element.total2() }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
matColumnDef="hosts">
<th *matHeaderCellDef mat-header-cell>Hosts</th>
<td *matCellDef="let element" class="truncate-cell" mat-cell>
{{ element.hosts }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'addedOn')"
matColumnDef="addedOn">
<th *matHeaderCellDef mat-header-cell>AddedOn</th>
<td *matCellDef="let element" class="truncate-cell" mat-cell>
{{ element.addedOn }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'order')"
matColumnDef="order">
<th *matHeaderCellDef mat-header-cell>Order</th>
<td *matCellDef="let element" class="truncate-cell" mat-cell>
{{ element.rank }}
</td>
</ng-container>
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
<tr
(click)="onClick(row, $event)"
(contextmenu)="onContextMenu($event, row)"
(dblclick)="onRowDoubleClicked(row)"
*matRowDef="let row; columns: columnsToDisplay()"
[ngClass]="{ selected: selection.isSelected(row) }"
class="row"
mat-row></tr>
</table>
@@ -0,0 +1,40 @@
.mat-column-progress {
text-align: center;
width: 150px;
}
.mat-column-status {
text-align: center;
width: 25px;
}
.mat-column-addedOn {
width: 165px;
text-align: center;
}
.mat-column-order {
text-align: center;
width: 25px;
max-width: 100px;
}
.mat-column-total {
text-align: center;
width: 150px;
max-width: 200px;
}
.mat-column-path {
text-align: center;
}
.mat-column-hosts {
text-align: center;
width: 150px;
max-width: 200px;
}
.row {
cursor: default;
}
@@ -1,24 +1,21 @@
import {
ChangeDetectionStrategy,
Component,
ComponentRef,
EventEmitter,
OnDestroy,
Output,
ViewChild,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
import {
CellContextMenuEvent,
GridOptions,
IRowNode,
RowDataUpdatedEvent,
RowDoubleClickedEvent,
SelectionChangedEvent,
ValueFormatterParams,
} from 'ag-grid-community';
import { Post } from '../domain/post.model';
import { fromEvent, merge, Subscription, take } from 'rxjs';
import {
BehaviorSubject,
EMPTY,
mergeMap,
Observable,
Subscription,
take,
} from 'rxjs';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import {
Overlay,
@@ -26,289 +23,331 @@ import {
OverlayPositionBuilder,
} from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
import {
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { MatTableModule } from '@angular/material/table';
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import {
isDisplayed,
progress,
statusIcon,
totalFormatter,
} from '../utils/utils';
import { ImageDialogData, ImagesComponent } from '../images/images.component';
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
import {
ConfirmComponent,
ConfirmDialogData,
} from '../confirm/confirm.component';
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
import { Image } from '../domain/image.model';
import { ImageDialogData, ImagesComponent } from '../images/images.component';
import { formatBytes } from '../utils/utils';
import { PostRow } from '../domain/post-row.model';
import {
RenameDialogComponent,
RenameDialogData,
RenameDialogResult,
} from '../rename-dialog/rename-dialog.component';
@Component({
selector: 'app-download-table',
standalone: true,
imports: [
CommonModule,
AgGridModule,
OverlayModule,
PortalModule,
MatDialogModule,
ProgressCellComponent,
MatListModule,
MatTableModule,
MatCheckboxModule,
MatIconModule,
MatProgressBarModule,
],
templateUrl: './download-table.component.html',
styleUrls: ['./download-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadTableComponent implements OnDestroy {
@ViewChild('agGrid') agGrid!: AgGridAngular;
gridOptions: GridOptions;
export class DownloadTableComponent {
dataSource = new PostDataSource(this.applicationEndpoint);
@Output()
rowCountChange = new EventEmitter<number>();
@Output()
selectedChange = new EventEmitter<Post[]>();
selectedPostsChange = new EventEmitter<Post[]>();
@Output()
selectedPostChange = new EventEmitter<Post>();
subscriptions: Subscription[] = [];
displayedColumns: string[] = [
'title',
'progress',
'status',
'path',
'total',
'hosts',
'addedOn',
'order',
];
selection = new SelectionModel<PostRow>(
true,
[],
true,
(a, b) => a.postId === b.postId
);
isDisplayed = isDisplayed;
columnsToDisplay = signal([...this.displayedColumns]);
constructor(
private applicationEndpoint: ApplicationEndpointService,
private overlayPositionBuilder: OverlayPositionBuilder,
private overlay: Overlay,
private dialog: MatDialog
private dialog: MatDialog,
private breakpointObserver: BreakpointObserver
) {
this.gridOptions = <GridOptions>{
columnDefs: [
{
headerName: 'Title',
field: 'postTitle',
tooltipField: 'postTitle',
headerCheckboxSelection: true,
headerCheckboxSelectionFilteredOnly: true,
flex: 2,
},
{
headerName: 'Progress',
tooltipValueGetter: params => {
if (!params.data) {
return 0;
}
return params.data.done === 0 || params.data.total === 0
? 0
: (params.data.done / params.data.total) * 100;
},
valueGetter: (params: ValueGetterParams<Post>) => {
if (!params.data) {
return 0;
}
return params.data.done === 0 || params.data.total === 0
? 0
: (params.data.done / params.data.total) * 100;
},
cellRenderer: ProgressCellComponent,
},
{
headerName: 'Status',
field: 'status',
tooltipValueGetter: params => {
const value = params.value as string;
return (
value.at(0)?.toUpperCase() +
value.substring(1, value.length).toLowerCase()
);
},
valueFormatter: (params: ValueFormatterParams<Image>) => {
const value = params.value as string;
return (
value.at(0)?.toUpperCase() +
value.substring(1, value.length).toLowerCase()
);
},
},
{
headerName: 'Path',
field: 'downloadDirectory',
tooltipField: 'downloadDirectory',
flex: 1,
},
{
headerName: 'Total',
tooltipValueGetter: params =>
`${params.data?.done}/${params.data?.total} (${formatBytes(
params.data?.downloaded
)})`,
valueGetter: params =>
`${params.data?.done}/${params.data?.total} (${formatBytes(
params.data?.downloaded
)})`,
},
{
headerName: 'Hosts',
field: 'hosts',
tooltipField: 'hosts',
valueGetter: (params: ValueGetterParams<Post>) => {
return params.data?.hosts.join(', ');
},
},
{
headerName: 'Added On',
field: 'addedOn',
tooltipField: 'addedOn',
},
{
headerName: 'Order',
field: 'rank',
tooltipField: 'rank',
sort: 'asc',
},
],
defaultColDef: {
sortable: true,
resizable: true,
},
rowSelection: 'multiple',
getRowId: row => row.data['postId'],
onGridReady: () => this.connect(),
onRowDataUpdated: (event: RowDataUpdatedEvent<Post>) =>
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
onCellContextMenu: (event: CellContextMenuEvent<Post>) => {
if (event.api.getSelectedRows().length > 1) {
event.node.setSelected(true);
this.dataSource._dataStream.subscribe(v =>
this.rowCountChange.emit(v.length)
);
this.selection.changed.subscribe(selectionChange => {
this.selectedPostChange.emit(selectionChange.added[0]);
this.selectedPostsChange.emit(this.selection.selected);
});
this.breakpointObserver
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
.subscribe(result => {
if (result.matches) {
this.columnsToDisplay.set(['title', 'progress', 'status']);
} else {
event.node.setSelected(true, true);
}
const mouseEvent = event.event as MouseEvent;
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
.withPush(true)
.withGrowAfterOpen(true)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
this.columnsToDisplay.set([
'title',
'progress',
'status',
'path',
'total',
'hosts',
'addedOn',
'order',
]);
const postContextMenuOverlayRef = this.overlay.create({
positionStrategy,
});
const postContextMenuPortal = new ComponentPortal(
PostContextmenuComponent
);
const ref: ComponentRef<PostContextmenuComponent> =
postContextMenuOverlayRef.attach(postContextMenuPortal);
ref.instance.post = event.data as Post;
ref.instance.onPostStart = () =>
this.applicationEndpoint
.startPosts(event.api.getSelectedRows())
.subscribe();
ref.instance.onPostStop = () =>
this.applicationEndpoint
.stopPosts(event.api.getSelectedRows())
.subscribe();
ref.instance.onPostDelete = () => {
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
ConfirmComponent,
{
data: {
message: `Confirm removal of ${
event.api.getSelectedRows().length
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
confirmCallback: () => {
this.applicationEndpoint
.deletePosts(event.api.getSelectedRows())
.subscribe(() => dialog.close());
},
},
}
);
};
const subscription = merge(
fromEvent<MouseEvent>(document, 'click'),
fromEvent<MouseEvent>(document, 'contextmenu')
)
.pipe(take(1))
.subscribe(() => {
subscription.unsubscribe();
postContextMenuOverlayRef?.detach();
postContextMenuOverlayRef?.dispose();
ref.destroy();
});
},
onSelectionChanged: (event: SelectionChangedEvent<Post>) => {
this.selectedChange.emit(event.api.getSelectedRows());
},
onRowDoubleClicked: (event: RowDoubleClickedEvent<Post>) => {
if (!event.data) {
return;
}
const data: ImageDialogData = { postId: event.data.postId };
this.dialog.open(ImagesComponent, { data });
},
};
});
}
private connect() {
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource._dataStream.value.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}
this.selection.select(...this.dataSource._dataStream.value);
}
/** The label for the checkbox on the passed row */
checkboxLabel(row?: PostRow): string {
if (!row) {
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
}
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
row.postId
}`;
}
onRowDoubleClicked(event: PostRow) {
const data: ImageDialogData = { postId: event.postId };
this.dialog.open(ImagesComponent, {
data,
maxWidth: '100vw',
maxHeight: '100vh',
width: '80vw',
height: '80vh',
});
}
onClick(row: PostRow, $event: MouseEvent) {
if (!$event.ctrlKey) {
this.selection.clear();
}
this.selection.select(row);
}
onContextMenu(mouseEvent: MouseEvent, row: PostRow) {
mouseEvent.preventDefault();
if (this.selection.selected.length > 1) {
this.selection.select(row);
} else {
this.selection.clear();
this.selection.select(row);
}
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
.withPush(true)
.withGrowAfterOpen(true)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
]);
const postContextMenuOverlayRef = this.overlay.create({
positionStrategy,
});
const postContextMenuPortal = new ComponentPortal(PostContextmenuComponent);
const ref: ComponentRef<PostContextmenuComponent> =
postContextMenuOverlayRef.attach(postContextMenuPortal);
ref.instance.post = row as PostRow;
ref.instance.close = () => {
postContextMenuOverlayRef?.detach();
postContextMenuOverlayRef?.dispose();
ref.destroy();
};
ref.instance.onPostStart = () =>
this.applicationEndpoint.startPosts(this.selection.selected).subscribe();
ref.instance.onPostStop = () =>
this.applicationEndpoint.stopPosts(this.selection.selected).subscribe();
ref.instance.onPostRename = () => {
const dialog: MatDialogRef<RenameDialogComponent, RenameDialogResult> =
this.dialog.open<
RenameDialogComponent,
RenameDialogData,
RenameDialogResult
>(RenameDialogComponent, {
data: { postId: row.postId, name: row.folderName },
});
dialog
.afterClosed()
.pipe(
mergeMap(result => {
if (result) {
return this.applicationEndpoint.renamePost(result);
} else {
return EMPTY;
}
})
)
.subscribe();
};
ref.instance.onPostDelete = () => {
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
ConfirmComponent,
{
data: {
message: `Confirm removal of ${
this.selection.selected.length
} post${this.selection.selected.length > 1 ? 's' : ''}`,
confirmCallback: () => {
this.applicationEndpoint
.deletePosts(this.selection.selected)
.subscribe(() => dialog.close());
},
},
}
);
};
postContextMenuOverlayRef
.outsidePointerEvents()
.pipe(take(1))
.subscribe(() => {
console.log('click away');
postContextMenuOverlayRef?.detach();
postContextMenuOverlayRef?.dispose();
ref.destroy();
});
}
}
class PostDataSource extends DataSource<PostRow> {
subscriptions: Subscription[] = [];
_dataStream = new BehaviorSubject<PostRow[]>([]);
constructor(private applicationEndpoint: ApplicationEndpointService) {
super();
}
connect(): Observable<PostRow[]> {
this.subscriptions.push(
this.applicationEndpoint.newPosts$.subscribe((newPosts: Post[]) => {
this.agGrid.api.applyTransaction({ add: newPosts });
this._dataStream.next([
...this._dataStream.value,
...newPosts.map(
e =>
new PostRow(
e.postId,
e.postTitle,
e.status,
e.url,
e.done,
e.total,
e.hosts,
e.addedOn,
e.rank,
e.downloadDirectory,
e.folderName,
e.downloadFolder,
e.downloaded
)
),
]);
})
);
this.subscriptions.push(
this.applicationEndpoint.deletedPosts$.subscribe((e: string[]) => {
const toRemove: string[] = [];
e.forEach(v => {
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
if (rowNode != null) {
toRemove.push(rowNode.data);
}
});
this.agGrid.api.applyTransaction({ remove: toRemove });
this.applicationEndpoint.deletedPosts$.subscribe((e: number[]) => {
this._dataStream.next([
...this._dataStream.value.filter(
v => e.find(d => d === v.postId) == null
),
]);
})
);
this.subscriptions.push(
this.applicationEndpoint.updatedPosts$.subscribe((e: Post[]) => {
const toUpdate: Post[] = [];
e.forEach(v => {
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
String(v.postId)
const rowNode = this._dataStream.value.find(
d => d.postId === v.postId
);
if (rowNode != null) {
toUpdate.push(v);
Object.assign(rowNode, v);
rowNode.statusIcon.set(statusIcon(v.status));
rowNode.progress.set(progress(v.done, v.total));
rowNode.total2.set(totalFormatter(v.done, v.total, v.downloaded));
rowNode.path.set(v.downloadFolder);
}
});
this.agGrid.api.applyTransaction({ update: toUpdate });
})
);
return this._dataStream.asObservable();
}
private disconnect() {
disconnect(): void {
this.subscriptions.forEach(e => e.unsubscribe());
}
ngOnDestroy(): void {
this.disconnect();
}
disableForRows(event: MouseEvent) {
const target = event.target as HTMLElement;
const element = document
.getElementById('download-grid')
?.getElementsByClassName('ag-center-cols-container')
?.item(0) as HTMLElement;
if (element.contains(target)) {
event.preventDefault();
}
}
}
@@ -1,14 +1,58 @@
<h2 mat-dialog-title>Images</h2>
<mat-dialog-content
class="mat-typography"
style="min-width: 50vw; min-height: 50vh">
<ag-grid-angular
style="width: 50vw; height: 50vh"
class="ag-theme-alpine"
#agGrid
[gridOptions]="gridOptions">
</ag-grid-angular>
<mat-dialog-content class="mat-typography">
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'index')"
matColumnDef="index">
<th *matHeaderCellDef mat-header-cell>Index</th>
<td *matCellDef="let element" mat-cell>
{{ element.index }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
matColumnDef="url">
<th *matHeaderCellDef mat-header-cell>URL</th>
<td *matCellDef="let element" mat-cell>
{{ element.url }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
matColumnDef="progress">
<th *matHeaderCellDef mat-header-cell>Progress</th>
<td *matCellDef="let element" mat-cell>
<mat-progress-bar
[value]="element.progress()"
mode="determinate"></mat-progress-bar>
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
matColumnDef="status">
<th *matHeaderCellDef mat-header-cell>Status</th>
<td *matCellDef="let element" mat-cell>
<mat-icon
[fontIcon]="element.statusIcon()"
aria-hidden="false"
color="primary"></mat-icon>
</td>
</ng-container>
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
<tr
(click)="onClick(row)"
*matRowDef="let row; columns: columnsToDisplay()"
[ngClass]="{ selected: selection.isSelected(row) }"
class="row"
mat-row></tr>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button [mat-dialog-close]="true" cdkFocusInitial>Close</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-flat-button>
Close
</button>
</mat-dialog-actions>
@@ -0,0 +1,15 @@
.mat-column-progress {
text-align: center;
width: 150px;
}
.mat-column-status {
text-align: center;
width: 25px;
}
.mat-column-index {
text-align: center;
width: 25px;
max-width: 100px;
}
@@ -1,15 +1,19 @@
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
import { Component, Inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
import { GridOptions, ValueFormatterParams } from 'ag-grid-community';
import { Subscription } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { Image } from '../domain/image.model';
import { MatButtonModule } from '@angular/material/button';
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
import { ITooltipParams } from 'ag-grid-community/dist/lib/rendering/tooltipComponent';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { DialogRef } from '@angular/cdk/dialog';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table';
import { isDisplayed, progress, statusIcon } from '../utils/utils';
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { ImageRow } from '../domain/image-row.model';
export interface ImageDialogData {
postId: number;
@@ -18,100 +22,104 @@ export interface ImageDialogData {
@Component({
selector: 'app-images',
standalone: true,
imports: [CommonModule, AgGridModule, MatDialogModule, MatButtonModule],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatIconModule,
MatProgressBarModule,
MatTableModule,
],
templateUrl: './images.component.html',
styleUrls: ['./images.component.scss'],
})
export class ImagesComponent implements OnDestroy {
@ViewChild('agGrid') agGrid!: AgGridAngular;
export class ImagesComponent {
displayedColumns: string[] = ['index', 'url', 'progress', 'status'];
selection = new SelectionModel<ImageRow>(
false,
[],
true,
(a, b) => a.url === b.url
);
dataSource = new ImageDataSource(this.applicationEndpoint, this.data.postId);
gridOptions: GridOptions<Image>;
subscriptions: Subscription[] = [];
columnsToDisplay = signal([...this.displayedColumns]);
isDisplayed = isDisplayed;
constructor(
@Inject(MAT_DIALOG_DATA) public data: ImageDialogData,
private applicationEndpoint: ApplicationEndpointService
public dialogRef: DialogRef<ImageDialogData>,
private applicationEndpoint: ApplicationEndpointService,
breakpointObserver: BreakpointObserver
) {
this.gridOptions = <GridOptions>{
columnDefs: [
{
headerName: '#',
field: 'index',
tooltipField: 'index',
sort: 'asc',
},
{
headerName: 'URL',
field: 'url',
tooltipField: 'url',
flex: 1,
},
{
headerName: 'Progress',
field: 'progress',
tooltipValueGetter: (params: ITooltipParams<Image>) => {
if (!params.data) {
return 0;
}
return params.data.downloaded === 0 || params.data.size === 0
? 0
: (params.data.downloaded / params.data.size) * 100;
},
valueGetter: (params: ValueGetterParams<Image>) => {
if (!params.data) {
return 0;
}
return params.data.downloaded === 0 || params.data.size === 0
? 0
: (params.data.downloaded / params.data.size) * 100;
},
cellRenderer: ProgressCellComponent,
},
{
headerName: 'Status',
field: 'status',
valueFormatter: (params: ValueFormatterParams<Image>) => {
const value = params.value as string;
return (
value.at(0)?.toUpperCase() +
value.substring(1, value.length).toLowerCase()
);
},
},
],
defaultColDef: {
sortable: true,
resizable: true,
},
getRowId: row => row.data['url'].toString(),
onGridReady: () => this.connect(),
};
breakpointObserver
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
.subscribe(result => {
if (result.matches) {
this.columnsToDisplay.set(['index', 'progress', 'status']);
} else {
this.columnsToDisplay.set(['index', 'url', 'progress', 'status']);
}
if (result.matches) {
this.dialogRef.updateSize('100vw', '80vh');
} else {
this.dialogRef.updateSize('80vw', '80vh');
}
this.dialogRef.updatePosition();
});
}
private connect() {
onClick(row: ImageRow) {
this.selection.clear();
this.selection.select(row);
}
}
class ImageDataSource extends DataSource<ImageRow> {
subscriptions: Subscription[] = [];
_dataStream = new BehaviorSubject<ImageRow[]>([]);
constructor(
private applicationEndpoint: ApplicationEndpointService,
private postId: number
) {
super();
}
connect(): Observable<ImageRow[]> {
this.subscriptions.push(
this.applicationEndpoint
.postDetails$(this.data.postId)
.postDetails$(this.postId)
.subscribe((e: Image[]) => {
const toAdd: Image[] = [];
const toUpdate: Image[] = [];
e.forEach(v => {
if (this.agGrid.api.getRowNode(v.url.toString()) == null) {
toAdd.push(v);
e.forEach(image => {
const rowNode = this._dataStream.value.find(
d => d.url === image.url
);
if (rowNode == null) {
this._dataStream.next([
...this._dataStream.value,
new ImageRow(
image.postId,
image.url,
image.status,
image.index,
image.downloaded,
image.size
),
]);
} else {
toUpdate.push(v);
Object.assign(rowNode, image);
rowNode.statusIcon.set(statusIcon(image.status));
rowNode.progress.set(progress(image.downloaded, image.size));
}
});
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
})
);
return this._dataStream.asObservable();
}
private disconnect() {
disconnect(): void {
this.subscriptions.forEach(e => e.unsubscribe());
}
ngOnDestroy(): void {
this.disconnect();
}
}
@@ -1,6 +1,61 @@
<ag-grid-angular
style="width: 100%; height: 100%"
class="ag-theme-alpine"
#agGrid
[gridOptions]="gridOptions">
</ag-grid-angular>
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'time')"
matColumnDef="time">
<th *matHeaderCellDef mat-header-cell>Time</th>
<td
*matCellDef="let element"
[title]="element.time"
class="truncate-cell"
mat-cell>
{{ element.time }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'type')"
matColumnDef="type">
<th *matHeaderCellDef mat-header-cell>Type</th>
<td
*matCellDef="let element"
[title]="element.type"
class="truncate-cell"
mat-cell>
{{ element.type }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
matColumnDef="status">
<th *matHeaderCellDef mat-header-cell>Status</th>
<td
*matCellDef="let element"
[title]="element.statusSignal()"
class="truncate-cell"
mat-cell>
{{ element.statusSignal() }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'message')"
matColumnDef="message">
<th *matHeaderCellDef mat-header-cell>Message</th>
<td
*matCellDef="let element"
[title]="element.messageSignal()"
class="truncate-cell"
mat-cell>
{{ element.messageSignal() }}
</td>
</ng-container>
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
<tr
(click)="onClick(row)"
*matRowDef="let row; columns: columnsToDisplay()"
[ngClass]="{ selected: selection.isSelected(row) }"
class="row"
mat-row></tr>
</table>
@@ -3,142 +3,109 @@ import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
import { GridOptions, IRowNode, RowDataUpdatedEvent } from 'ag-grid-community';
import { Observable, Subscription } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { Log } from '../domain/log.model';
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { formatType, LogRow } from '../domain/log-row.model';
import { isDisplayed } from '../utils/utils';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTableModule } from '@angular/material/table';
@Component({
selector: 'app-log-table',
standalone: true,
imports: [CommonModule, AgGridModule],
imports: [CommonModule, MatCheckboxModule, MatTableModule],
templateUrl: './log-table.component.html',
styleUrls: ['./log-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LogTableComponent implements OnInit, OnDestroy {
@ViewChild('agGrid') agGrid!: AgGridAngular;
export class LogTableComponent implements OnInit {
dataSource = new LogDataSource(this.applicationEndpoint);
displayedColumns: string[] = ['time', 'type', 'status', 'message'];
selection = new SelectionModel<LogRow>(
false,
[],
true,
(a, b) => a.id === b.id
);
isDisplayed = isDisplayed;
columnsToDisplay = signal([...this.displayedColumns]);
@Output()
rowCountChange = new EventEmitter<number>();
@Input({ required: true })
clear!: Observable<void>;
gridOptions: GridOptions;
subscriptions: Subscription[] = [];
constructor(private applicationEndpoint: ApplicationEndpointService) {
this.gridOptions = <GridOptions>{
columnDefs: [
{
headerName: 'Time',
field: 'time',
tooltipField: 'time',
sort: 'desc',
},
{
headerName: 'Type',
field: 'type',
tooltipField: 'type',
valueGetter: params => {
switch (params.data.type) {
case 'POST':
return '🖼️ New gallery';
case 'THREAD':
return '🧵 New thread';
case 'THANKS':
return '👍 Sending a like ';
case 'SCAN':
return '🔍 Links scan';
case 'METADATA':
case 'METADATA_CACHE_MISS':
return '🗄️ Loading post metadata';
case 'QUEUED':
case 'QUEUED_CACHE_MISS':
return '📋 Loading multi-post link';
case 'DOWNLOAD':
return '📥 Download';
default:
return params.data.type;
}
},
},
{
headerName: 'Status',
field: 'status',
tooltipField: 'status',
},
{
headerName: 'Message',
field: 'message',
tooltipField: 'message',
flex: 1,
},
],
defaultColDef: {
sortable: true,
resizable: true,
},
rowSelection: 'single',
getRowId: row => row.data['id'].toString(),
onGridReady: () => this.connect(),
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
};
this.dataSource._dataStream.subscribe(v =>
this.rowCountChange.emit(v.length)
);
}
ngOnInit(): void {
this.clear.subscribe(() => this.agGrid.api.setRowData([]));
this.clear.subscribe(() => {
this.dataSource._dataStream.next([]);
});
}
private connect() {
this.subscriptions.push(
this.applicationEndpoint.newLogs$.subscribe((e: Log[]) => {
this.agGrid.api.applyTransaction({ add: e });
})
);
onClick(row: LogRow) {
this.selection.clear();
this.selection.select(row);
}
}
class LogDataSource extends DataSource<LogRow> {
subscriptions: Subscription[] = [];
_dataStream = new BehaviorSubject<LogRow[]>([]);
constructor(private applicationEndpoint: ApplicationEndpointService) {
super();
}
connect(): Observable<LogRow[]> {
this.subscriptions.push(
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
const toUpdate: Log[] = [];
e.forEach(v => {
if (this.agGrid.api.getRowNode(v.id.toString()) != null) {
toUpdate.push(v);
}
});
this.agGrid.api.applyTransaction({ update: toUpdate });
this.applicationEndpoint.newLogs$.subscribe((newLogs: Log[]) => {
this._dataStream.next([
...this._dataStream.value,
...newLogs.map(
e => new LogRow(e.id, e.type, e.status, e.time, e.message)
),
]);
})
);
this.subscriptions.push(
this.applicationEndpoint.logsRemove$.subscribe((e: number[]) => {
const toRemove: any[] = [];
e.forEach(v => {
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
v.toString()
);
if (rowNode != null) {
toRemove.push(rowNode.data);
}
});
this.agGrid.api.applyTransaction({ remove: toRemove });
this._dataStream.next([
...this._dataStream.value.filter(
v => e.find(d => d === v.id) == null
),
]);
})
);
this.subscriptions.push(
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
e.forEach(v => {
const rowNode = this._dataStream.value.find(d => d.id === v.id);
if (rowNode != null) {
Object.assign(rowNode, v);
rowNode.type = formatType(v.type);
rowNode.statusSignal.set(v.status);
rowNode.messageSignal.set(v.message);
}
});
})
);
return this._dataStream.asObservable();
}
private disconnect() {
disconnect(): void {
this.subscriptions.forEach(e => e.unsubscribe());
}
ngOnDestroy(): void {
this.disconnect();
}
}
@@ -6,7 +6,7 @@
post.status === 'ERROR' ||
post.status === 'STOPPED'
"
(click)="onPostStart()">
(click)="onPostStart(); close()">
<mat-icon matListItemIcon>play_arrow</mat-icon>
<div matListItemTitle>
{{ post.done / post.total === 0 ? 'Start' : 'Resume' }}
@@ -18,15 +18,19 @@
post.status === 'PARTIAL' ||
post.status === 'PENDING'
"
(click)="onPostStop()">
(click)="onPostStop(); close()">
<mat-icon matListItemIcon>pause</mat-icon>
<div matListItemTitle>Stop</div>
</mat-list-item>
<mat-list-item (click)="onPostDelete()">
<mat-list-item (click)='onPostRename(); close()'>
<mat-icon matListItemIcon>edit</mat-icon>
<div matListItemTitle>Rename</div>
</mat-list-item>
<mat-list-item (click)="onPostDelete(); close()">
<mat-icon matListItemIcon>delete</mat-icon>
<div matListItemTitle>Remove</div>
</mat-list-item>
<mat-list-item (click)="openImages()">
<mat-list-item (click)="openImages(); close()">
<mat-icon matListItemIcon>list</mat-icon>
<div matListItemTitle>Photos</div>
</mat-list-item>
@@ -1,11 +1,5 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output,
} from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule, NgIf } from '@angular/common';
import { Post } from '../domain/post.model';
import {
animate,
state,
@@ -18,6 +12,7 @@ import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { ImageDialogData, ImagesComponent } from '../images/images.component';
import { PostRow } from '../domain/post-row.model';
@Component({
selector: 'app-post-contextmenu',
@@ -35,18 +30,24 @@ import { ImageDialogData, ImagesComponent } from '../images/images.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PostContextmenuComponent {
post!: Post;
post!: PostRow;
constructor(public dialog: MatDialog) {}
openImages = () => {
const data: ImageDialogData = { postId: this.post.postId };
this.dialog.open(ImagesComponent, { data });
this.dialog.open(ImagesComponent, {
data,
maxWidth: '100vw',
maxHeight: '100vh',
width: '80vw',
height: '80vh',
});
};
onPostStart!: () => void;
onPostStop!: () => void;
onPostRename!: () => void;
onPostDelete!: () => void;
close!: () => void;
}
@@ -1,3 +0,0 @@
<div style="display: flex; height: 100%; align-items: center">
<mat-progress-bar mode="determinate" [value]="progress()"></mat-progress-bar>
</div>
@@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProgressCellComponent } from './progress-cell.component';
describe('ProgressCellComponent', () => {
let component: ProgressCellComponent;
let fixture: ComponentFixture<ProgressCellComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProgressCellComponent]
});
fixture = TestBed.createComponent(ProgressCellComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,24 +0,0 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ICellRendererParams } from 'ag-grid-community';
import { AgGridModule, ICellRendererAngularComp } from 'ag-grid-angular';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@Component({
selector: 'app-progress-cell',
standalone: true,
imports: [CommonModule, AgGridModule, MatProgressBarModule],
templateUrl: './progress-cell.component.html',
styleUrls: ['./progress-cell.component.scss'],
})
export class ProgressCellComponent implements ICellRendererAngularComp {
progress = signal(0);
agInit(params: ICellRendererParams): void {
this.progress.set(params.value);
}
refresh(params: ICellRendererParams): boolean {
this.progress.set(params.value);
return true;
}
}
@@ -0,0 +1,13 @@
<h2 mat-dialog-title>Confirmation</h2>
<mat-dialog-content class='mat-typography' style='min-width: 30vw'>
<form>
<mat-form-field appearance='outline' style='width: 100%; padding-top: 20px'>
<mat-label>Folder name</mat-label>
<input [formControl]='formControl' matInput name='folderName' required />
</mat-form-field>
</form>
<mat-dialog-actions align='end'>
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
<button mat-flat-button color='primary' (click)='confirm()'>Ok</button>
</mat-dialog-actions>
</mat-dialog-content>
@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RenameDialogComponent } from './rename-dialog.component';
describe('ConfirmComponent', () => {
let component: RenameDialogComponent;
let fixture: ComponentFixture<RenameDialogComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RenameDialogComponent],
});
fixture = TestBed.createComponent(RenameDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
export interface RenameDialogData {
postId: number;
name: string;
}
export interface RenameDialogResult {
postId: number;
name: string;
}
@Component({
selector: 'app-rename-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
],
templateUrl: './rename-dialog.component.html',
styleUrls: ['./rename-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RenameDialogComponent {
formControl = new FormControl(this.data.name);
constructor(
public dialogRef: MatDialogRef<RenameDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: RenameDialogData
) {}
confirm() {
this.dialogRef.close({
postId: this.data.postId,
name: this.formControl.value,
} as RenameDialogResult);
}
}
@@ -2,12 +2,13 @@
<mat-dialog-content
class="mat-typography"
style="min-width: 50vw; min-height: 50vh">
<mat-form-field appearance="outline" style="width: 100%; height: 100%; padding-top: 20px">
<mat-form-field appearance="outline" style="width: 100%; padding-top: 20px">
<mat-label>Add new links</mat-label>
<textarea
[formControl]="formControl"
cdkTextareaAutosize
cdkAutosizeMinRows="5"
[cdkTextareaAutosize]="true"
[cdkAutosizeMinRows]="5"
(paste)="onPaste($event)"
matInput
name="url"
placeholder="Put each link in a new line"
@@ -15,8 +16,11 @@
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-button [mat-dialog-close]="formControl.value" cdkFocusInitial>
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
<button
mat-flat-button
color="primary"
[mat-dialog-close]="formControl.value">
Scan
</button>
</mat-dialog-actions>
+25 -2
View File
@@ -4,7 +4,8 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TextFieldModule } from '@angular/cdk/text-field';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { DialogRef } from '@angular/cdk/dialog';
@Component({
selector: 'app-scan',
@@ -15,11 +16,33 @@ import { TextFieldModule } from '@angular/cdk/text-field';
MatButtonModule,
MatInputModule,
ReactiveFormsModule,
TextFieldModule,
],
templateUrl: './scan.component.html',
styleUrls: ['./scan.component.scss'],
})
export class ScanComponent {
formControl = new FormControl<string>('');
constructor(
public dialogRef: DialogRef<never>,
breakpointObserver: BreakpointObserver
) {
breakpointObserver
.observe(Breakpoints.HandsetPortrait)
.subscribe(result => {
if (result.matches) {
this.dialogRef.updateSize('100vw', '80vh');
} else {
this.dialogRef.updateSize('80vw', '80vh');
}
this.dialogRef.updatePosition();
});
}
onPaste(event: ClipboardEvent) {
event.preventDefault();
const oldValue = this.formControl.value || '';
this.formControl.setValue(
`${oldValue}${event?.clipboardData?.getData('text')}\n`
);
}
}
@@ -42,9 +42,9 @@ export class ApplicationEndpointService {
return this.updatedPosts;
}
private deletedPosts!: Observable<string[]>;
private deletedPosts!: Observable<number[]>;
get deletedPosts$(): Observable<string[]> {
get deletedPosts$(): Observable<number[]> {
return this.deletedPosts;
}
@@ -207,7 +207,6 @@ export class ApplicationEndpointService {
this.newPosts = this.rxStomp.watch('/topic/posts/new').pipe(
map(e => {
// const posts: Array<Post> = [];
return JSON.parse(e.body).map((element: any) => {
return new Post(
element.postId,
@@ -220,6 +219,8 @@ export class ApplicationEndpointService {
element.addedOn,
element.rank + 1,
element.downloadDirectory,
element.folderName,
element.downloadFolder,
element.downloaded
);
});
@@ -229,7 +230,6 @@ export class ApplicationEndpointService {
this.updatedPosts = this.rxStomp.watch('/topic/posts/updated').pipe(
map(e => {
// const posts: Array<Post> = [];
return JSON.parse(e.body).map((element: any) => {
return new Post(
element.postId,
@@ -242,6 +242,8 @@ export class ApplicationEndpointService {
element.addedOn,
element.rank + 1,
element.downloadDirectory,
element.folderName,
element.downloadFolder,
element.downloaded
);
});
@@ -309,9 +311,9 @@ export class ApplicationEndpointService {
}
getThreadPosts(threadId: number) {
return this.httpClient
.get<PostItem[]>(this.baseUrl + `/api/grab/${threadId}`)
.pipe(map(v => v.map(p => ({ ...p, hosts: p.hosts }))));
return this.httpClient.get<PostItem[]>(
this.baseUrl + `/api/grab/${threadId}`
);
}
startDownload() {
@@ -372,4 +374,8 @@ export class ApplicationEndpointService {
clearThreads() {
return this.httpClient.get<void>(this.baseUrl + '/api/grab/clear');
}
renamePost(data: { postId: number; name: string }) {
return this.httpClient.post<void>(this.baseUrl + '/api/post/rename', data);
}
}
@@ -128,22 +128,22 @@
<mat-form-field appearance="outline">
<mat-label>Global concurrent downloads</mat-label>
<input
formControlName='maxConcurrentPerHost'
formControlName="maxConcurrentPerHost"
matInput
max="12"
min="0"
name='maxConcurrentPerHost'
name="maxConcurrentPerHost"
required
type="number" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Concurrent downloads per host</mat-label>
<input
formControlName='maxGlobalConcurrent'
formControlName="maxGlobalConcurrent"
matInput
max="4"
min="1"
name='maxGlobalConcurrent'
name="maxGlobalConcurrent"
required
type="number" />
</mat-form-field>
@@ -229,9 +229,9 @@
</div>
</form>
</mat-tab>
<mat-tab label='System'>
<mat-tab label="System">
<form
[formGroup]='systemSettingsForm'
[formGroup]="systemSettingsForm"
autocomplete="off"
style="
display: flex;
@@ -242,17 +242,17 @@
">
<mat-form-field appearance="outline">
<mat-label>Temporary Path</mat-label>
<input formControlName='tempPath' matInput name='tempPath' required />
<input formControlName="tempPath" matInput name="tempPath" required />
</mat-form-field>
<mat-form-field appearance='outline'>
<mat-form-field appearance="outline">
<mat-label>Cache Path</mat-label>
<input
formControlName='cachePath'
formControlName="cachePath"
matInput
name='cachePath'
name="cachePath"
required />
</mat-form-field>
<mat-form-field appearance='outline'>
<mat-form-field appearance="outline">
<mat-label>Maximum log entries</mat-label>
<input
formControlName="maxEventLog"
@@ -268,6 +268,6 @@
</mat-tab-group>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button (click)="save()" mat-button cdkFocusInitial>Save</button>
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
<button (click)="save()" mat-flat-button color="primary">Save</button>
</mat-dialog-actions>
@@ -25,6 +25,8 @@ import {
ViperSettings,
} from '../domain/settings.model';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { DialogRef } from '@angular/cdk/dialog';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'app-settings',
@@ -87,13 +89,23 @@ export class SettingsComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: Settings,
public dialogRef: MatDialogRef<SettingsComponent>,
private applicationEndpoint: ApplicationEndpointService
public dialogRef: DialogRef<Settings>,
private applicationEndpoint: ApplicationEndpointService,
breakpointObserver: BreakpointObserver
) {
this.viperGirlsSettingsForm.reset(data.viperSettings);
this.downloadSettingsForm.reset(data.downloadSettings);
this.connectionSettingsForm.reset(data.connectionSettings);
this.systemSettingsForm.reset(data.systemSettings);
breakpointObserver
.observe(Breakpoints.HandsetPortrait)
.subscribe(result => {
if (result.matches) {
this.dialogRef.updateSize('100vw', '80vh');
} else {
this.dialogRef.updateSize('80vw', '80vh');
}
});
}
save = () => {
@@ -1,22 +1,32 @@
<div class='status-bar' style='display: flex'>
<span style='padding-left: 10px; flex-grow: 1'>
<ng-container *ngIf='vgUsername$ | async as vgUsername'>
<span>{{
vgUsername.length > 0 ? 'Logged in as: ' + vgUsername : ''
<div class="status-bar" style="display: flex; justify-content: space-between">
<div>
<span
*ngIf="!(handsetPortrait$ | async)?.matches"
style="padding-left: 10px; flex-grow: 1">
<ng-container *ngIf="vgUsername$ | async as vgUsername">
<span>{{
vgUsername.length > 0 ? 'Logged in as: ' + vgUsername : ''
}}</span>
</ng-container>
</span>
<span *ngIf='downloadSpeed$ | async as downloadSpeed'>{{
(downloadSpeed.speed | downloadSpeed) + '/s'
</ng-container>
</span>
</div>
<div style="display: flex">
<span *ngIf="downloadSpeed$ | async as downloadSpeed">{{
(downloadSpeed.speed | downloadSpeed) + '/s'
}}</span>
<mat-divider [vertical]='true' style='height: 20px'></mat-divider>
<ng-container *ngIf='queueState$ | async as queueState'>
<span>Downloading: {{ queueState.running }}</span>
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
<span>Pending: {{ queueState.remaining }}</span>
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
</ng-container>
<span *ngIf='errorCount$ | async as errorCount' style='padding-right: 10px'
>Error: {{ errorCount.count }}</span
>
<ng-container *ngIf="queueState$ | async as queueState">
<ng-container *ngIf="!(handsetPortrait$ | async)?.matches">
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
<span>Downloading: {{ queueState.running }}</span>
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
<span>Pending: {{ queueState.remaining }}</span>
</ng-container>
</ng-container>
<ng-container *ngIf="errorCount$ | async as errorCount">
<ng-container *ngIf="!(handsetPortrait$ | async)?.matches">
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
<span style="padding-right: 10px">Error: {{ errorCount.count }}</span>
</ng-container>
</ng-container>
</div>
</div>
@@ -3,6 +3,7 @@ import { CommonModule, NgIf } from '@angular/common';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { MatDividerModule } from '@angular/material/divider';
import { DownloadSpeedPipe } from '../pipes/download-speed.pipe';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'app-status-bar',
@@ -12,7 +13,13 @@ import { DownloadSpeedPipe } from '../pipes/download-speed.pipe';
styleUrls: ['./status-bar.component.scss'],
})
export class StatusBarComponent {
constructor(private applicationEndpoint: ApplicationEndpointService) {}
handsetPortrait$ = this.breakpointObserver.observe(
Breakpoints.HandsetPortrait
);
constructor(
private applicationEndpoint: ApplicationEndpointService,
private breakpointObserver: BreakpointObserver
) {}
queueState$ = this.applicationEndpoint.queueState$;
downloadSpeed$ = this.applicationEndpoint.downloadSpeed$;
@@ -1,10 +1,10 @@
<mat-card class="mat-elevation-z4">
<mat-action-list>
<mat-list-item (click)="onThreadSelection()">
<mat-list-item (click)="onThreadSelection(); close()">
<mat-icon matListItemIcon>check_box</mat-icon>
<div matListItemTitle>Select posts</div>
</mat-list-item>
<mat-list-item (click)="onThreadDelete()">
<mat-list-item (click)="onThreadDelete(); close()">
<mat-icon matListItemIcon>delete</mat-icon>
<div matListItemTitle>Remove</div>
</mat-list-item>
@@ -5,10 +5,6 @@ import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { Thread } from '../domain/thread.model';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import {
ThreadDialogData,
ThreadSelectionComponent,
} from '../thread-selection/thread-selection.component';
@Component({
selector: 'app-thread-contextmenu',
@@ -31,4 +27,5 @@ export class ThreadContextmenuComponent {
onThreadSelection!: () => void;
onThreadDelete!: () => void;
close!: () => void;
}
@@ -1,14 +1,89 @@
<h2 mat-dialog-title>Thread</h2>
<mat-dialog-content
class="mat-typography"
style="min-width: 50vw; min-height: 50vh">
<ag-grid-angular
style="width: 50vw; height: 50vh"
class="ag-theme-alpine"
#agGrid
[gridOptions]="gridOptions">
</ag-grid-angular>
<mat-dialog-content class="mat-typography">
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'number')"
matColumnDef="number">
<th *matHeaderCellDef mat-header-cell>
<mat-checkbox
(change)="$event ? toggleAllRows() : null"
[aria-label]="checkboxLabel()"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
color="primary">
</mat-checkbox>
Number
</th>
<td
*matCellDef="let element"
[title]="element.number"
class="truncate-cell"
mat-cell>
{{ element.number }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
matColumnDef="title">
<th *matHeaderCellDef mat-header-cell>Title</th>
<td
*matCellDef="let element"
[title]="element.title"
class="truncate-cell"
mat-cell>
{{ element.title }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
matColumnDef="url">
<th *matHeaderCellDef mat-header-cell>URL</th>
<td
*matCellDef="let element"
[title]="element.url"
class="truncate-cell"
mat-cell>
{{ element.url }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
matColumnDef="hosts">
<th *matHeaderCellDef mat-header-cell>URL</th>
<td
*matCellDef="let element"
[title]="formatHosts(element.hosts)"
class="truncate-cell"
mat-cell>
{{ formatHosts(element.hosts) }}
</td>
</ng-container>
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
<tr
(click)="onClick(row, $event)"
(dblclick)="onRowDoubleClicked(row)"
*matRowDef="let row; columns: columnsToDisplay()"
[ngClass]="{ selected: selection.isSelected(row) }"
class="row"
mat-row></tr>
</table>
@if (dataSource.loading()) {
<div style="display: flex; justify-content: center">
<p>Loading</p>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="downloadSelected()" cdkFocusInitial>Download</button>
<button mat-flat-button [mat-dialog-close]="true" cdkFocusInitial>
Cancel
</button>
<button (click)="downloadSelected()" color="primary" mat-flat-button>
Download
</button>
</mat-dialog-actions>
@@ -1,21 +1,16 @@
import { Component, Inject, ViewChild } from '@angular/core';
import { Component, EventEmitter, Inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import {
GridOptions,
ITooltipParams,
RowDoubleClickedEvent,
} from 'ag-grid-community';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { GridReadyEvent } from 'ag-grid-community/dist/lib/events';
import { PostItem } from '../domain/post-item.model';
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
import { DialogRef } from '@angular/cdk/dialog';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTableModule } from '@angular/material/table';
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { Thread } from '../domain/thread.model';
import { BehaviorSubject, finalize, Observable } from 'rxjs';
import { isDisplayed } from '../utils/utils';
export interface ThreadDialogData {
threadId: number;
@@ -24,85 +19,111 @@ export interface ThreadDialogData {
@Component({
selector: 'app-thread-selection',
standalone: true,
imports: [CommonModule, AgGridModule, MatButtonModule, MatDialogModule],
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatCheckboxModule,
MatTableModule,
],
templateUrl: './thread-selection.component.html',
styleUrls: ['./thread-selection.component.scss'],
})
export class ThreadSelectionComponent {
@ViewChild('agGrid') agGrid!: AgGridAngular<PostItem>;
gridOptions: GridOptions;
dataSource = new ThreadSelectionDataSource(
this.applicationEndpoint,
this.data.threadId
);
displayedColumns: string[] = ['number', 'title', 'url', 'hosts'];
selection = new SelectionModel<PostItem>(
true,
[],
true,
(a, b) => a.url === b.url
);
isDisplayed = isDisplayed;
selectedChange = new EventEmitter<Thread[]>();
columnsToDisplay = signal([...this.displayedColumns]);
constructor(
@Inject(MAT_DIALOG_DATA) public data: ThreadDialogData,
private dialogRef: MatDialogRef<ThreadSelectionComponent>,
private dialogRef: DialogRef<ThreadDialogData>,
private applicationEndpoint: ApplicationEndpointService
) {
this.gridOptions = <GridOptions>{
columnDefs: [
{
headerName: 'Number',
field: 'number',
tooltipField: 'number',
checkboxSelection: true,
headerCheckboxSelection: true,
},
{
headerName: 'Title',
field: 'title',
tooltipField: 'title',
flex: 2,
},
{
headerName: 'URL',
field: 'url',
tooltipField: 'url',
flex: 1,
},
{
headerName: 'Hosts',
field: 'hosts',
tooltipValueGetter: (params: ITooltipParams<PostItem>) => {
return params.data?.hosts
.map(v => `${v.first} (${v.second})`)
.join(', ');
},
valueGetter: (params: ValueGetterParams<PostItem>) => {
return params.data?.hosts
.map(v => `${v.first} (${v.second})`)
.join(', ');
},
},
],
defaultColDef: {
resizable: true,
sortable: true,
},
rowSelection: 'multiple',
getRowId: row => row.data['postId'].toString(),
onGridReady: (event: GridReadyEvent<PostItem>) => {
this.applicationEndpoint
.getThreadPosts(this.data.threadId)
.subscribe(result => {
event.api.applyTransaction({ add: result });
});
},
onRowDoubleClicked: (event: RowDoubleClickedEvent<PostItem>) => {
if (!event.data) {
return;
}
this.download([event.data]);
},
};
) {}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource._dataStream.value.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}
this.selection.select(...this.dataSource._dataStream.value);
}
/** The label for the checkbox on the passed row */
checkboxLabel(row?: PostItem): string {
if (!row) {
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
}
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
row.url
}`;
}
onClick(row: PostItem, event: MouseEvent) {
if (!event.ctrlKey) {
this.selection.clear();
}
this.selection.select(row);
}
onRowDoubleClicked(row: PostItem) {
this.download([row]);
}
downloadSelected() {
this.download(this.agGrid.api.getSelectedRows());
this.download(this.selection.selected);
}
formatHosts = (hosts: [{ first: string; second: number }]): string => {
return hosts.map(v => `${v.first} (${v.second})`).join(', ');
};
private download = (items: PostItem[]) => {
this.applicationEndpoint.download(items).subscribe(() => {
this.dialogRef.close();
});
};
}
class ThreadSelectionDataSource extends DataSource<PostItem> {
_dataStream = new BehaviorSubject<PostItem[]>([]);
loading = signal(true);
constructor(
private applicationEndpoint: ApplicationEndpointService,
private threadId: number
) {
super();
}
connect(): Observable<PostItem[]> {
this.applicationEndpoint
.getThreadPosts(this.threadId)
.pipe(finalize(() => this.loading.set(false)))
.subscribe(result => {
this._dataStream.next(result);
});
return this._dataStream.asObservable();
}
disconnect(): void {}
}
@@ -1,8 +1,55 @@
<ag-grid-angular
id="thread-grid"
style="width: 100%; height: 100%"
class="ag-theme-alpine"
#agGrid
[gridOptions]="gridOptions"
(contextmenu)="disableForRows($event)">
</ag-grid-angular>
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
matColumnDef="title">
<th *matHeaderCellDef mat-header-cell>
<mat-checkbox
(change)="$event ? toggleAllRows() : null"
[aria-label]="checkboxLabel()"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
color="primary">
</mat-checkbox>
Title
</th>
<td
*matCellDef="let element"
[title]="element.title"
class="truncate-cell"
mat-cell>
{{ element.title }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'link')"
matColumnDef="link">
<th *matHeaderCellDef mat-header-cell>Link</th>
<td
*matCellDef="let element"
[title]="element.link"
class="truncate-cell"
mat-cell>
{{ element.link }}
</td>
</ng-container>
<ng-container
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
matColumnDef="total">
<th *matHeaderCellDef mat-header-cell>Total</th>
<td *matCellDef="let element" class="truncate-cell" mat-cell>
{{ element.total }}
</td>
</ng-container>
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
<tr
(click)="onClick(row)"
(contextmenu)="onContextMenu($event, row)"
(dblclick)="onRowDoubleClicked(row)"
*matRowDef="let row; columns: columnsToDisplay()"
[ngClass]="{ selected: selection.isSelected(row) }"
class="row"
mat-row></tr>
</table>
@@ -3,20 +3,11 @@ import {
Component,
ComponentRef,
EventEmitter,
OnDestroy,
Output,
ViewChild,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
import {
CellContextMenuEvent,
GridOptions,
IRowNode,
RowDataUpdatedEvent,
RowDoubleClickedEvent,
} from 'ag-grid-community';
import { fromEvent, merge, Subscription, take } from 'rxjs';
import { BehaviorSubject, Observable, Subscription, take } from 'rxjs';
import { ApplicationEndpointService } from '../services/application-endpoint.service';
import { Thread } from '../domain/thread.model';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
@@ -39,29 +30,46 @@ import {
ConfirmComponent,
ConfirmDialogData,
} from '../confirm/confirm.component';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table';
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { isDisplayed } from '../utils/utils';
import { ThreadRow } from '../domain/thread-row.model';
@Component({
selector: 'app-thread-table',
standalone: true,
imports: [
CommonModule,
AgGridModule,
OverlayModule,
PortalModule,
MatDialogModule,
MatCheckboxModule,
MatIconModule,
MatProgressBarModule,
MatTableModule,
],
templateUrl: './thread-table.component.html',
styleUrls: ['./thread-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ThreadTableComponent implements OnDestroy {
@ViewChild('agGrid') agGrid!: AgGridAngular;
export class ThreadTableComponent {
dataSource = new ThreadDataSource(this.applicationEndpoint);
displayedColumns: string[] = ['title', 'link', 'total'];
selection = new SelectionModel<ThreadRow>(
true,
[],
true,
(a, b) => a.link === b.link
);
isDisplayed = isDisplayed;
@Output()
rowCountChange = new EventEmitter<number>();
gridOptions: GridOptions;
subscriptions: Subscription[] = [];
@Output()
selectedChange = new EventEmitter<Thread[]>();
columnsToDisplay = signal([...this.displayedColumns]);
constructor(
private applicationEndpoint: ApplicationEndpointService,
@@ -69,158 +77,138 @@ export class ThreadTableComponent implements OnDestroy {
private overlay: Overlay,
private dialog: MatDialog
) {
this.gridOptions = <GridOptions>{
columnDefs: [
this.dataSource._dataStream.subscribe(v =>
this.rowCountChange.emit(v.length)
);
this.selection.changed.subscribe(() =>
this.selectedChange.emit(this.selection.selected)
);
}
onRowDoubleClicked(row: ThreadRow) {
const data: ThreadDialogData = { threadId: row.threadId };
this.dialog.open(ThreadSelectionComponent, {
data,
maxWidth: '100vw',
maxHeight: '100vh',
width: '80vw',
height: '80vh',
});
}
onClick(row: ThreadRow) {
this.selection.clear();
this.selection.select(row);
}
onContextMenu(mouseEvent: MouseEvent, row: ThreadRow) {
mouseEvent.preventDefault();
if (this.selection.selected.length > 1) {
this.selection.select(row);
} else {
this.selection.clear();
this.selection.select(row);
}
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
.withPush(true)
.withGrowAfterOpen(true)
.withPositions([
{
headerName: 'Title',
field: 'title',
tooltipField: 'title',
flex: 1,
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
headerName: 'Url',
field: 'link',
tooltipField: 'link',
flex: 1,
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
{
headerName: 'Count',
field: 'total',
tooltipField: 'total',
},
],
defaultColDef: {
sortable: true,
resizable: true,
},
rowSelection: 'multiple',
getRowId: row => row.data['threadId'],
onGridReady: () => this.connect(),
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
onCellContextMenu: (event: CellContextMenuEvent<Thread>) => {
if (event.api.getSelectedRows().length > 1) {
event.node.setSelected(true);
} else {
event.node.setSelected(true, true);
}
const mouseEvent = event.event as MouseEvent;
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
.withPush(true)
.withGrowAfterOpen(true)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
]);
const postContextMenuOverlayRef = this.overlay.create({
positionStrategy,
});
const postContextMenuPortal = new ComponentPortal(
ThreadContextmenuComponent
);
const ref: ComponentRef<ThreadContextmenuComponent> =
postContextMenuOverlayRef.attach(postContextMenuPortal);
ref.instance.thread = event.data as Thread;
]);
const threadContextMenuOverlayRef = this.overlay.create({
positionStrategy,
});
const threadContextMenuPortal = new ComponentPortal(
ThreadContextmenuComponent
);
const ref: ComponentRef<ThreadContextmenuComponent> =
threadContextMenuOverlayRef.attach(threadContextMenuPortal);
ref.instance.thread = row as ThreadRow;
ref.instance.onThreadSelection = () => {
if (!event.data) {
return;
}
const data: ThreadDialogData = { threadId: event.data.threadId };
this.dialog.open(ThreadSelectionComponent, { data });
};
ref.instance.onThreadDelete = () => {
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
ConfirmComponent,
{
data: {
message: `Confirm removal of ${
event.api.getSelectedRows().length
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
confirmCallback: () => {
this.applicationEndpoint
.deleteThreads(event.api.getSelectedRows())
.subscribe(() => dialog.close());
},
},
}
);
};
const subscription = merge(
fromEvent<MouseEvent>(document, 'click'),
fromEvent<MouseEvent>(document, 'contextmenu')
)
.pipe(take(1))
.subscribe(() => {
subscription.unsubscribe();
postContextMenuOverlayRef?.detach();
postContextMenuOverlayRef?.dispose();
ref.destroy();
});
},
onRowDoubleClicked: (event: RowDoubleClickedEvent<Thread>) => {
if (!event.data) {
return;
}
const data: ThreadDialogData = { threadId: event.data.threadId };
this.dialog.open(ThreadSelectionComponent, { data });
},
ref.instance.close = () => {
threadContextMenuOverlayRef?.detach();
threadContextMenuOverlayRef?.dispose();
ref.destroy();
};
ref.instance.onThreadSelection = () => {
const data: ThreadDialogData = { threadId: row.threadId };
this.dialog.open(ThreadSelectionComponent, {
data,
maxWidth: '100vw',
maxHeight: '100vh',
width: '80vw',
height: '80vh',
});
};
ref.instance.onThreadDelete = () => {
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
ConfirmComponent,
{
data: {
message: `Confirm removal of ${
this.selection.selected.length
} post${this.selection.selected.length > 1 ? 's' : ''}`,
confirmCallback: () => {
this.applicationEndpoint
.deleteThreads(this.selection.selected)
.subscribe(() => dialog.close());
},
},
}
);
};
threadContextMenuOverlayRef
.outsidePointerEvents()
.pipe(take(1))
.subscribe(() => {
console.log('click away');
threadContextMenuOverlayRef?.detach();
threadContextMenuOverlayRef?.dispose();
ref.destroy();
});
}
private connect() {
this.subscriptions.push(
this.applicationEndpoint.threads$.subscribe((e: Thread[]) => {
const toAdd: Thread[] = [];
const toUpdate: Thread[] = [];
e.forEach(v => {
if (this.agGrid.api.getRowNode(String(v.threadId)) == null) {
toAdd.push(v);
} else {
toUpdate.push(v);
}
});
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
})
);
this.subscriptions.push(
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
const toRemove: any[] = [];
e.forEach(v => {
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
if (rowNode != null) {
toRemove.push(rowNode.data);
}
return;
});
this.agGrid.api.applyTransaction({ remove: toRemove });
})
);
this.subscriptions.push(
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
this.agGrid.api.setRowData([]);
})
);
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource._dataStream.value.length;
return numSelected === numRows;
}
private disconnect() {
this.subscriptions.forEach(e => e.unsubscribe());
/** Selects all rows if they are not all selected; otherwise clear selection. */
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}
this.selection.select(...this.dataSource._dataStream.value);
}
/** The label for the checkbox on the passed row */
checkboxLabel(row?: ThreadRow): string {
if (!row) {
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
}
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
row.link
}`;
}
disableForRows(event: MouseEvent) {
@@ -233,8 +221,57 @@ export class ThreadTableComponent implements OnDestroy {
event.preventDefault();
}
}
}
ngOnDestroy(): void {
this.disconnect();
class ThreadDataSource extends DataSource<Thread> {
subscriptions: Subscription[] = [];
_dataStream = new BehaviorSubject<Thread[]>([]);
constructor(private applicationEndpoint: ApplicationEndpointService) {
super();
}
connect(): Observable<Thread[]> {
this.subscriptions.push(
this.applicationEndpoint.threads$.subscribe((newThreads: Thread[]) => {
newThreads.forEach(thread => {
const rowNode = this._dataStream.value.find(
d => d.link === thread.link
);
if (rowNode == null) {
this._dataStream.next([
...this._dataStream.value,
new ThreadRow(
thread.link,
thread.title,
thread.threadId,
thread.total
),
]);
} else {
Object.assign(rowNode, thread);
}
});
})
);
this.subscriptions.push(
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
this._dataStream.next([
...this._dataStream.value.filter(
v => e.find(d => d === v.link) == null
),
]);
})
);
this.subscriptions.push(
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
this._dataStream.next([]);
})
);
return this._dataStream.asObservable();
}
disconnect(): void {
this.subscriptions.forEach(e => e.unsubscribe());
}
}
@@ -26,27 +26,38 @@
<mat-divider
[vertical]="true"
style="height: 20px; flex-grow: 0"></mat-divider>
<button
[disabled]="disableSelected()"
(click)="startSelectedClick()"
mat-icon-button
matTooltip="Start selected">
<mat-icon>play_arrow</mat-icon>
</button>
<button
[disabled]="disableSelected()"
(click)="stopSelectedClick()"
mat-icon-button
matTooltip="Stop selected">
<mat-icon>pause</mat-icon>
</button>
<button
[disabled]="disableSelected()"
(click)="removeSelectedClick()"
mat-icon-button
matTooltip="Remove selected">
<mat-icon>delete</mat-icon>
</button>
<ng-container *ngIf="handsetPortrait$ | async as handsetPortrait">
@if (!handsetPortrait.matches) {
<button
[disabled]="disableSelected()"
(click)="startSelectedClick()"
mat-icon-button
matTooltip="Start selected">
<mat-icon>play_arrow</mat-icon>
</button>
<button
[disabled]="disableSelected()"
(click)="stopSelectedClick()"
mat-icon-button
matTooltip="Stop selected">
<mat-icon>pause</mat-icon>
</button>
<button
[disabled]='disableSelected()'
(click)='renameSelectedClick()'
mat-icon-button
matTooltip='Rename selected'>
<mat-icon>edit</mat-icon>
</button>
<button
[disabled]="disableSelected()"
(click)="removeSelectedClick()"
mat-icon-button
matTooltip="Remove selected">
<mat-icon>delete</mat-icon>
</button>
}
</ng-container>
<mat-divider
[vertical]="true"
style="height: 20px; flex-grow: 0"></mat-divider>
@@ -78,9 +89,9 @@
<button mat-icon-button matTooltip="Settings" (click)="settingsClick()">
<mat-icon>settings</mat-icon>
</button>
<button mat-icon-button matTooltip="About">
<mat-icon>info</mat-icon>
</button>
<!-- <button mat-icon-button matTooltip="About">-->
<!-- <mat-icon>info</mat-icon>-->
<!-- </button>-->
</div>
</div>
</mat-toolbar-row>
@@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ScanComponent } from '../scan/scan.component';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'app-toolbar',
@@ -48,6 +49,9 @@ export class ToolbarComponent {
@Output()
deleteSelected = new EventEmitter<void>();
@Output()
renameSelected = new EventEmitter<void>();
@Output()
clearDownload = new EventEmitter<void>();
@@ -69,11 +73,21 @@ export class ToolbarComponent {
@Input({ required: true })
disableSelected!: Signal<boolean>;
constructor(public dialog: MatDialog) {}
handsetPortrait$ = this.breakpointObserver.observe(
Breakpoints.HandsetPortrait
);
constructor(
public dialog: MatDialog,
private breakpointObserver: BreakpointObserver
) {}
openScanDialog() {
this.dialog
.open<ScanComponent, never, string>(ScanComponent)
.open<ScanComponent, never, string>(ScanComponent, {
maxWidth: '100vw',
maxHeight: '100vh',
})
.afterClosed()
.subscribe(v => {
if (v) {
@@ -106,6 +120,10 @@ export class ToolbarComponent {
this.deleteSelected.next();
}
renameSelectedClick() {
this.renameSelected.next();
}
clearDownloadsClick() {
this.clearDownload.next();
}
+33
View File
@@ -19,3 +19,36 @@ export function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function statusIcon(status: string): string {
switch (status) {
case 'DOWNLOADING':
return 'download_for_offline';
case 'PENDING':
return 'pending';
case 'FINISHED':
return 'check_circle';
case 'ERROR':
return 'error';
case 'STOPPED':
return 'pause_circle';
default:
return 'question_mark';
}
}
export function progress(done: number, total: number): number {
return done === 0 || total === 0 ? 0 : (done / total) * 100;
}
export function totalFormatter(
done: number,
total: number,
downloaded: number
): string {
return `${done}/${total} (${formatBytes(downloaded)})`;
}
export function isDisplayed(columns: string[], column: string): boolean {
return columns.findIndex(v => v === column) > -1;
}
+38 -17
View File
@@ -1,6 +1,24 @@
/* You can add global styles to this file, and also import other style files */
@import 'ag-grid-community/styles/ag-grid.css';
@import 'ag-grid-community/styles/ag-theme-alpine.css';
@use '@angular/material' as mat;
@use './app/download-table/download-table.component-theme' as downloadTable;
@include mat.core();
$my-primary: mat.define-palette(mat.$indigo-palette, 500);
$my-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
$my-theme: mat.define-light-theme((
color: (
primary: $my-primary,
accent: $my-accent,
),
typography: mat.define-typography-config(
$font-family: roboto,
)
));
@include mat.all-component-themes($my-theme);
@include downloadTable.theme($my-theme);
html,
body {
@@ -17,7 +35,7 @@ body {
}
#tab-group {
height: calc(100% - 64px);
height: calc(100% - 84px);
}
.ag-theme-alpine {
@@ -53,7 +71,7 @@ body {
position: absolute;
left: 0;
top: 0;
animation: sk-chase-dot 2.0s infinite ease-in-out both;
animation: sk-chase-dot 2s infinite ease-in-out both;
}
.sk-chase-dot:before {
@@ -63,7 +81,7 @@ body {
height: 25%;
background-color: #fff;
border-radius: 100%;
animation: sk-chase-dot-before 2.0s infinite ease-in-out both;
animation: sk-chase-dot-before 2s infinite ease-in-out both;
}
.sk-chase-dot:nth-child(1) {
@@ -71,7 +89,7 @@ body {
}
.sk-chase-dot:nth-child(2) {
animation-delay: -1.0s;
animation-delay: -1s;
}
.sk-chase-dot:nth-child(3) {
@@ -95,7 +113,7 @@ body {
}
.sk-chase-dot:nth-child(2):before {
animation-delay: -1.0s;
animation-delay: -1s;
}
.sk-chase-dot:nth-child(3):before {
@@ -121,7 +139,8 @@ body {
}
@keyframes sk-chase-dot {
80%, 100% {
80%,
100% {
transform: rotate(360deg);
}
}
@@ -130,17 +149,19 @@ body {
50% {
transform: scale(0.4);
}
100%, 0% {
transform: scale(1.0);
100%,
0% {
transform: scale(1);
}
}
.status-bar {
position: fixed;
width: 100%;
bottom: 0;
left: 0;
.mat-mdc-dialog-content {
max-height: 100vh !important;
}
.truncate-cell {
text-overflow: ellipsis;
overflow: hidden;
max-width: 1px;
white-space: nowrap;
padding: 2px 0;
z-index: 9999;
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -10,7 +10,7 @@
<parent>
<groupId>me.vripper</groupId>
<artifactId>vripper-project</artifactId>
<version>5.0.0</version>
<version>${revision}</version>
</parent>
<properties>
@@ -37,12 +37,12 @@
<dependency>
<groupId>me.vripper</groupId>
<artifactId>vripper-core</artifactId>
<version>5.0.0</version>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>me.vripper</groupId>
<artifactId>vripper-web-ui</artifactId>
<version>5.0.0</version>
<version>${revision}</version>
</dependency>
</dependencies>
@@ -3,7 +3,8 @@ package me.vripper.web.restendpoints
import me.vripper.model.PostItem
import me.vripper.model.ThreadPostId
import me.vripper.services.AppEndpointService
import me.vripper.web.restendpoints.domain.*
import me.vripper.web.restendpoints.domain.RenameRequest
import me.vripper.web.restendpoints.domain.ScanRequest
import me.vripper.web.restendpoints.exceptions.BadRequestException
import me.vripper.web.restendpoints.exceptions.ServerErrorException
import org.koin.core.component.KoinComponent
@@ -98,4 +99,10 @@ class PostRestEndpoint : KoinComponent {
fun threadClear() {
appEndpointService.threadClear()
}
@PostMapping("/post/rename")
@ResponseStatus(value = HttpStatus.OK)
fun rename(@RequestBody renameRequest: RenameRequest) {
appEndpointService.rename(renameRequest.postId, renameRequest.name)
}
}
@@ -0,0 +1,3 @@
package me.vripper.web.restendpoints.domain
data class RenameRequest(val postId: Long, val name: String)
@@ -17,23 +17,14 @@ class DataBroadcast(
@PostConstruct
private fun run() {
eventBus.events.ofType(PostEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { events ->
events.map { it.delete }.flatten().also {
if (it.isNotEmpty()) {
template.convertAndSend("/topic/posts/deleted", it)
}
}
events.map { it.add }.flatten().also {
if (it.isNotEmpty()) {
template.convertAndSend("/topic/posts/new", it)
}
}
events.map { it.update }.flatten().reversed().distinct().also {
template.convertAndSend("/topic/posts/updated", it)
}
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { events ->
template.convertAndSend("/topic/posts/new", events.posts)
}
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { events ->
template.convertAndSend("/topic/posts/updated", events.posts)
}
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { events ->
template.convertAndSend("/topic/posts/deleted", events.postIds)
}
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
@@ -56,45 +47,39 @@ class DataBroadcast(
template.convertAndSend("/topic/loading", it.loading)
}
eventBus.events.ofType(ImageEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { events ->
events.map { it.images }.flatten().reversed().distinct().groupBy { it.postId }
.forEach { template.convertAndSend("/topic/images/${it.key}", it.value) }
eventBus.events.ofType(ImageEvent::class.java).buffer(Duration.ofMillis(500)).subscribe { events ->
events.reversed().map { it.images }.flatten().distinct().groupBy { it.postId }
.forEach {
template.convertAndSend("/topic/images/${it.key}", it.value)
}
}
eventBus.events.ofType(ThreadCreateEvent::class.java).map { it.thread }.distinct()
.buffer(Duration.ofMillis(125)).subscribe {
template.convertAndSend("/topic/threads", it)
eventBus.events.ofType(ThreadCreateEvent::class.java).map { listOf(it.thread) }
.subscribe { events ->
template.convertAndSend("/topic/threads", events)
}
eventBus.events.ofType(ThreadDeleteEvent::class.java).map { it.threadId }.distinct()
.buffer(Duration.ofMillis(125)).subscribe {
eventBus.events.ofType(ThreadDeleteEvent::class.java).map { listOf(it.threadId) }
.subscribe {
template.convertAndSend("/topic/threads/deleted", it)
}
eventBus.events.ofType(ThreadClearEvent::class.java).subscribe {
template.convertAndSend("/topic/threads/deletedAll", listOf(true))
eventBus.events.ofType(ThreadClearEvent::class.java).map { listOf(true) }.subscribe {
template.convertAndSend("/topic/threads/deletedAll", it)
}
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }.buffer(Duration.ofMillis(125))
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }
.subscribe { logCreateEvent ->
logCreateEvent.distinct().also {
if (it.isNotEmpty()) {
template.convertAndSend("/topic/logs/new", it)
}
}
template.convertAndSend("/topic/logs/new", listOf(logCreateEvent))
}
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }.buffer(Duration.ofMillis(125))
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }
.subscribe { logUpdateEvent ->
logUpdateEvent.distinct().also {
if (it.isNotEmpty()) {
template.convertAndSend("/topic/logs/updated", it)
}
}
template.convertAndSend("/topic/logs/updated", listOf(logUpdateEvent))
}
eventBus.events.ofType(LogDeleteEvent::class.java).subscribe {
template.convertAndSend("/topic/logs/deleted", it.deleted)
template.convertAndSend("/topic/logs/deleted", listOf(it.deleted))
}
}
}