diff --git a/js/css/main.css b/js/css/main.css index ef94cc2..26e2bd6 100644 --- a/js/css/main.css +++ b/js/css/main.css @@ -974,3 +974,38 @@ popover-content a:hover { padding: 0 0 0 0; font-size: 0; /* needed for Chrome */ } + +/* nsfw */ +.article-desc-nsfw { + font-size: 18px; + color: var(--pico-code-color); + display: none; +} + +.nsfw-blur .nsfw .article-media img { + filter: blur(20px); +} + +.nsfw-blur .nsfw .article-desc { + display: none; +} + +.nsfw-blur .nsfw .article-desc-nsfw { + display: block; +} + +.nsfw-hide .nsfw .article-media img { + display: none; +} + +.nsfw-hide .nsfw .article-desc { + display: none; +} + +.nsfw-hide .nsfw .article-desc-nsfw { + display: block; +} + +.nsfw-show .nsfw .article-desc-nsfw { + display: none; +} diff --git a/js/package-lock.json b/js/package-lock.json index 71ae809..686ba6c 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -15,7 +15,7 @@ "htmx.org": "^1.9.12", "imgs-html": "^0.0.4", "lite-youtube-embed": "^0.3.3", - "somment": "^0.0.1", + "somment": "^0.0.2", "toastify-js": "^1.12.0", "viewerjs": "^1.11.6" }, @@ -1654,9 +1654,9 @@ } }, "node_modules/somment": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/somment/-/somment-0.0.1.tgz", - "integrity": "sha512-MrDEcyqeWJvNqlH2B9PXLCC+nUWUB6AiIC9e9o2Vtg2Mt78PYuzQI6VQjE+egQ6i5EnRbN8JAaVv/hzBJ0kb6A==" + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/somment/-/somment-0.0.2.tgz", + "integrity": "sha512-L/vdKxor8ZJzcmolI0kHt6awwS0e0owIkGDsLObpgnlo9bXfCBnojWmwu2n7erwq/y8PXkol8r6tX78/1ATc7A==" }, "node_modules/source-map": { "version": "0.6.1", diff --git a/js/package.json b/js/package.json index bb5db2c..5c6a41c 100644 --- a/js/package.json +++ b/js/package.json @@ -15,7 +15,7 @@ "htmx.org": "^1.9.12", "imgs-html": "^0.0.4", "lite-youtube-embed": "^0.3.3", - "somment": "^0.0.1", + "somment": "^0.0.2", "toastify-js": "^1.12.0", "viewerjs": "^1.11.6" }, diff --git a/js/src/error-handler.js b/js/src/error-handler.js index 9423444..d81c8ff 100644 --- a/js/src/error-handler.js +++ b/js/src/error-handler.js @@ -1,7 +1,8 @@ function showErrorMessage(errMsg, details) { + const maxLength = 100; Toastify({ - text: errMsg, + text: errMsg.substring(0, maxLength), className: 'error-toast', duration: 30000, newWindow: false, diff --git a/js/src/set-theme.js b/js/src/set-theme.js index 95a9e62..799eab9 100644 --- a/js/src/set-theme.js +++ b/js/src/set-theme.js @@ -26,4 +26,11 @@ function initTheme() { setTheme(getCurrentTheme()); } +async function loadNSFWClass() { + const res = await fetch("/hx/settings/nsfw", {method: 'GET'}); + const setting = await res.text(); + return "nsfw-" + setting.toLowerCase(); +} +window.loadNSFWClass = loadNSFWClass; + initTheme(); diff --git a/src/main/protobuf/grpc-api.proto b/src/main/protobuf/grpc-api.proto index d057ef1..756d0db 100644 --- a/src/main/protobuf/grpc-api.proto +++ b/src/main/protobuf/grpc-api.proto @@ -275,6 +275,21 @@ message SourceUpdater { optional IconUrlOption iconUrl = 15; } +// Define NSFWSetting + +enum NSFWSetting { + HIDE = 0; + BLUR = 1; + SHOW = 2; +} + +// Define SearchEngine + +message SearchEngine { + optional string name = 1; + string urlPrefix = 2; +} + // Define User message User { @@ -294,6 +309,8 @@ message User { optional string activeCode = 14; int64 subscribeEndAt = 15; bool subscribed = 16; + NSFWSetting nsfwSetting = 17; + SearchEngine searchEngine = 18; } // Define UserInfo @@ -309,6 +326,8 @@ message UserInfo { optional string currentFolderID = 8; optional string currentSourceID = 9; bool subscribed = 10; + NSFWSetting nsfwSetting = 11; + SearchEngine searchEngine = 12; } // Define UserUpdater @@ -347,6 +366,8 @@ message UserUpdater { optional int64 subscribeEndAt = 11; optional bool subscribed = 12; optional string username = 13; + optional NSFWSetting nsfwSetting = 14; + optional SearchEngine searchEngine = 15; } // Define UserSession @@ -1250,6 +1271,12 @@ message SetCurrentSourceRequest { string currentSourceID = 2; } +message UpdateUserSettingsRequest { + string token = 1; + optional NSFWSetting nsfwSetting = 2; + optional SearchEngine searchEngine = 3; +} + message RemoveCurrentFolderAndSourceRequest { string token = 1; } @@ -1311,6 +1338,8 @@ message SetCurrentFolderResponse {} message SetCurrentSourceResponse {} +message UpdateUserSettingsResponse {} + message RemoveCurrentFolderAndSourceResponse {} message GetPaymentCustomersResponse { @@ -1337,6 +1366,7 @@ service UserAPI { rpc GetRedditSessions (GetRedditSessionsRequest) returns (stream GetRedditSessionsResponse); rpc SetCurrentFolder (SetCurrentFolderRequest) returns (SetCurrentFolderResponse); rpc SetCurrentSource (SetCurrentSourceRequest) returns (SetCurrentSourceResponse); + rpc UpdateUserSettings (UpdateUserSettingsRequest) returns (UpdateUserSettingsResponse); rpc RemoveCurrentFolderAndSource (RemoveCurrentFolderAndSourceRequest) returns (RemoveCurrentFolderAndSourceResponse); rpc GetPaymentCustomers (GetPaymentCustomersRequest) returns (stream GetPaymentCustomersResponse); rpc SendDeleteUserCode (SendDeleteUserCodeRequest) returns (SendDeleteUserCodeResponse); diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 3243551..0833f92 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -24,7 +24,7 @@ redis { } api { - version = "1.4.0" + version = "1.5.0" } grpc { diff --git a/src/main/scala/me/binwang/rss/cmd/GRPCAndHttpServer.scala b/src/main/scala/me/binwang/rss/cmd/GRPCAndHttpServer.scala index 08895d8..255fdf0 100644 --- a/src/main/scala/me/binwang/rss/cmd/GRPCAndHttpServer.scala +++ b/src/main/scala/me/binwang/rss/cmd/GRPCAndHttpServer.scala @@ -49,7 +49,7 @@ object GRPCAndHttpServer extends IOApp { new PaymentView(services.userService, services.stripePaymentService).routes <+> new ImportFeedView(services.sourceService, services.folderService).routes <+> new RecommendationView(services.moreLikeThisService, services.articleService, services.sourceService, - services.folderService).routes + services.folderService, services.userService).routes ))) HttpServer(httpRoute, config.getString("frontend.ip"), config.getInt("frontend.port"), logBody = false) } diff --git a/src/main/scala/me/binwang/rss/cmd/HttpServer.scala b/src/main/scala/me/binwang/rss/cmd/HttpServer.scala index 71c1ae9..b81943c 100644 --- a/src/main/scala/me/binwang/rss/cmd/HttpServer.scala +++ b/src/main/scala/me/binwang/rss/cmd/HttpServer.scala @@ -6,7 +6,7 @@ import org.http4s.HttpRoutes import org.http4s.ember.server.EmberServerBuilder import org.http4s.implicits._ import org.http4s.server.Server -import org.http4s.server.middleware.{ErrorAction, Logger} +import org.http4s.server.middleware.{ErrorAction, GZip, Logger} import org.typelevel.log4cats.LoggerFactory import org.typelevel.log4cats.slf4j.Slf4jFactory @@ -25,7 +25,7 @@ object HttpServer { )(routes) val httpService = ErrorAction.httpRoutes[IO]( - loggingService, + GZip(loggingService), (req, thr) => http4sLogger.error(thr)(s"Error when handling request: ${req.uri}") ).orNotFound diff --git a/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala index e7407b9..fd1f59a 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala @@ -14,6 +14,7 @@ import me.binwang.rss.model.EmbeddingUpdateStatus.EmbeddingUpdateStatus import me.binwang.rss.model.FetchStatus.FetchStatus import me.binwang.rss.model.ID.ID import me.binwang.rss.model.MoreLikeThisType.MoreLikeThisType +import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ import org.postgresql.util.PGobject @@ -88,7 +89,13 @@ trait BaseSqlDao { protected implicit val encodeArticleListLayout: MappedEncoding[ArticleListLayout, String] = MappedEncoding[ArticleListLayout, String](_.toString) - protected implicit val jsonbEncoder: Encoder[MediaGroups] = encoder(java.sql.Types.OTHER, (index, mediaGroups, row) => { + protected implicit val decodeNsfwSetting: MappedEncoding[String, NSFWSetting] = + MappedEncoding[String, NSFWSetting](NSFWSetting.withName) + + protected implicit val encodeNsfwSetting: MappedEncoding[NSFWSetting, String] = + MappedEncoding[NSFWSetting, String](_.toString) + + protected implicit val mediaGroupsEncoder: Encoder[MediaGroups] = encoder(java.sql.Types.OTHER, (index, mediaGroups, row) => { val value = io.circe.syntax.EncoderOps(mediaGroups).asJson.toString() val pgObj = new PGobject() pgObj.setType("jsonb") @@ -96,7 +103,7 @@ trait BaseSqlDao { row.setObject(index, pgObj) }) - protected implicit val jsonbDecoder: Decoder[MediaGroups] = decoder { (index, row, _) => + protected implicit val mediaGroupsDecoder: Decoder[MediaGroups] = decoder { (index, row, _) => val defaultMediaGroups = MediaGroups(groups = Seq()) val pgObj = row.getObject(index).asInstanceOf[PGobject] if (pgObj == null) { @@ -106,6 +113,23 @@ trait BaseSqlDao { } } + protected implicit val searchEngineEncoder: Encoder[SearchEngine] = encoder(java.sql.Types.OTHER, (index, searchEngine, row) => { + val value = io.circe.syntax.EncoderOps(searchEngine).asJson.toString() + val pgObj = new PGobject() + pgObj.setType("jsonb") + pgObj.setValue(value) + row.setObject(index, pgObj) + }) + + protected implicit val searchEngineDecoder: Decoder[SearchEngine] = decoder { (index, row, _) => + val pgObj = row.getObject(index).asInstanceOf[PGobject] + if (pgObj == null) { + SearchEngine.DEFAULT + } else { + parser.parse(pgObj.getValue).flatMap(_.as[SearchEngine]).getOrElse(SearchEngine.DEFAULT) + } + } + def createTable(): IO[Unit] diff --git a/src/main/scala/me/binwang/rss/dao/sql/ConnectionPool.scala b/src/main/scala/me/binwang/rss/dao/sql/ConnectionPool.scala index eced327..a12db79 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/ConnectionPool.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/ConnectionPool.scala @@ -6,7 +6,8 @@ import com.zaxxer.hikari.HikariConfig import doobie.hikari.HikariTransactor object ConnectionPool { - def apply(): Resource[IO, ConnectionPool] = { + + def apply(isolationLevel: String = IsolationLevel.SERIALIZABLE): Resource[IO, ConnectionPool] = { val config = ConfigFactory.load() @@ -17,6 +18,7 @@ object ConnectionPool { hikariConfig.setPassword(config.getString("db.password")) hikariConfig.setMaximumPoolSize(config.getInt("db.maxPoolSize")) hikariConfig.setMinimumIdle(config.getInt("db.minIdle")) + hikariConfig.setTransactionIsolation(isolationLevel) HikariTransactor.fromHikariConfig[IO](hikariConfig).map(ConnectionPool(_)) } diff --git a/src/main/scala/me/binwang/rss/dao/sql/IsolationLevel.scala b/src/main/scala/me/binwang/rss/dao/sql/IsolationLevel.scala new file mode 100644 index 0000000..be8aee2 --- /dev/null +++ b/src/main/scala/me/binwang/rss/dao/sql/IsolationLevel.scala @@ -0,0 +1,6 @@ +package me.binwang.rss.dao.sql + +object IsolationLevel { + val SERIALIZABLE = "TRANSACTION_SERIALIZABLE" + val READ_COMMITTED = "TRANSACTION_READ_COMMITTED" +} diff --git a/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala index 32d84b0..ae0682e 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala @@ -32,7 +32,9 @@ class UserSqlDao(implicit val connectionPool: ConnectionPool) extends UserDao wi currentSourceID char(${ID.maxLength}) default null, activeCode char($UUID_LENGTH) unique default null, subscribeEndAt timestamp not null, - subscribed boolean not null default false + subscribed boolean not null default false, + nsfwSetting varchar not null default 'BLUR', + searchEngine jsonb not null ) """) .update @@ -89,7 +91,9 @@ class UserSqlDao(implicit val connectionPool: ConnectionPool) extends UserDao wi setOpt(_.activeCode, updater.activeCode), setOpt(_.subscribeEndAt, updater.subscribeEndAt), setOpt(_.subscribed, updater.subscribed), - setOpt(_.username, updater.username) + setOpt(_.username, updater.username), + setOpt(_.nsfwSetting, updater.nsfwSetting), + setOpt(_.searchEngine, updater.searchEngine), ) run(q).transact(xa).map(_ > 0) } diff --git a/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala b/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala index 7c56144..c88d2af 100644 --- a/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala +++ b/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala @@ -8,6 +8,7 @@ import me.binwang.rss.model.ArticleOrder.ArticleOrder import me.binwang.rss.model.FetchStatus.FetchStatus import me.binwang.rss.model.ID.ID import me.binwang.rss.model.MoreLikeThisType.MoreLikeThisType +import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ import me.binwang.rss.service._ import me.binwang.rss.sourcefinder.SourceResult @@ -60,6 +61,8 @@ object GenerateGRPC extends GRPCGenerator { typeOf[FetchStatus], typeOf[Source], typeOf[SourceUpdater], + typeOf[NSFWSetting], + typeOf[SearchEngine], typeOf[User], typeOf[UserInfo], typeOf[UserUpdater], diff --git a/src/main/scala/me/binwang/rss/model/User.scala b/src/main/scala/me/binwang/rss/model/User.scala index 47da103..c810ffb 100644 --- a/src/main/scala/me/binwang/rss/model/User.scala +++ b/src/main/scala/me/binwang/rss/model/User.scala @@ -3,6 +3,29 @@ package me.binwang.rss.model import java.time.ZonedDateTime import java.util.UUID +import me.binwang.rss.model.NSFWSetting.NSFWSetting + +object NSFWSetting extends Enumeration { + type NSFWSetting = Value + val + HIDE, + BLUR, + SHOW + = Value +} + +case class SearchEngine(name: Option[String], urlPrefix: String) + +object SearchEngine { + val DUCKDUCKGO = SearchEngine(Some("DuckDuckGo"), "https://duckduckgo.com/?q=") + val GOOGLE = SearchEngine(Some("Google"), "https://www.google.com/search?q=") + val BING = SearchEngine(Some("Bing"), "https://www.bing.com/search?q=") + val KAGI = SearchEngine(Some("Kagi"), "https://kagi.com/search?q=") + + val ALL = Seq(DUCKDUCKGO, GOOGLE, BING, KAGI) + val DEFAULT = DUCKDUCKGO +} + case class User ( id: String, username: String, @@ -20,6 +43,8 @@ case class User ( activeCode: Option[String] = Some(UUID.randomUUID().toString), subscribeEndAt: ZonedDateTime, subscribed: Boolean = false, + nsfwSetting: NSFWSetting = NSFWSetting.BLUR, + searchEngine: SearchEngine = SearchEngine.DEFAULT, ) { def toInfo: UserInfo = { @@ -34,6 +59,8 @@ case class User ( isAdmin = isAdmin, subscribeEndAt = subscribeEndAt, subscribed = subscribed, + nsfwSetting = nsfwSetting, + searchEngine = searchEngine, ) } @@ -50,6 +77,8 @@ case class UserInfo ( currentFolderID: Option[String] = None, currentSourceID: Option[String] = None, subscribed: Boolean = false, + nsfwSetting: NSFWSetting = NSFWSetting.BLUR, + searchEngine: SearchEngine = SearchEngine.DUCKDUCKGO, ) case class UserUpdater ( @@ -66,4 +95,6 @@ case class UserUpdater ( subscribeEndAt: Option[ZonedDateTime] = None, subscribed: Option[Boolean] = None, username: Option[String] = None, + nsfwSetting: Option[NSFWSetting] = None, + searchEngine: Option[SearchEngine] = None, ) diff --git a/src/main/scala/me/binwang/rss/ops/cleanup/articles/ArticlesCleaner.scala b/src/main/scala/me/binwang/rss/ops/cleanup/articles/ArticlesCleaner.scala new file mode 100644 index 0000000..63861cc --- /dev/null +++ b/src/main/scala/me/binwang/rss/ops/cleanup/articles/ArticlesCleaner.scala @@ -0,0 +1,111 @@ +package me.binwang.rss.ops.cleanup.articles + +import cats.effect.IO +import com.sksamuel.elastic4s.ElasticClient +import doobie.implicits._ +import com.sksamuel.elastic4s.cats.effect.instances._ +import me.binwang.rss.dao.elasticsearch.{ArticleElasticDao, BaseElasticDao} +import me.binwang.rss.dao.sql.{BaseSqlDao, ConnectionPool} +import me.binwang.rss.model.{Article, ArticleContent, ArticleUserMarking, FetchStatus, Source} +import org.typelevel.log4cats.LoggerFactory + +import java.time.ZonedDateTime + +class ArticlesCleaner(val connectionPool: ConnectionPool, val elasticClient: ElasticClient + )(implicit val loggerFactory: LoggerFactory[IO]) extends BaseSqlDao with BaseElasticDao { + + import dbCtx._ + private implicit val articleContentSchema: dbCtx.SchemaMeta[ArticleContent] = schemaMeta[ArticleContent]("article_contents") + + private val logger = LoggerFactory.getLoggerFromClass[IO](this.getClass) + + private def deleteOldArticleMarks(fetchCompletedAtBefore: ZonedDateTime): IO[Long] = { + run(quote { + query[ArticleUserMarking] + .filter(a => oldArticleQuery(fetchCompletedAtBefore).contains(a.articleID)) + .delete + }).transact(xa) + } + + private def deleteOldArticleContent(fetchCompletedAtBefore: ZonedDateTime): IO[Long] = { + run(quote { + query[ArticleContent] + .filter(a => oldArticleQuery(fetchCompletedAtBefore).contains(a.id)) + .delete + }).transact(xa) + } + + private def oldArticleQuery(fetchCompletedAtBefore: ZonedDateTime) = quote { + sourceQuery(fetchCompletedAtBefore) + .join(query[Article]) + .on(_ == _.sourceID) + .map(_._2.id) + } + + private def deleteOldArticles(fetchCompletedAtBefore: ZonedDateTime): IO[Long] = { + run(quote { + query[Article] + .filter(a => sourceQuery(fetchCompletedAtBefore).contains(a.sourceID)) + .delete + }).transact(xa) + } + + private def sourceQuery(fetchCompletedAtBefore: ZonedDateTime) = quote { + query[Source] + .filter(_.fetchStatus == lift(FetchStatus.PAUSED)) + .filter(_.fetchCompletedAt.exists(_ < lift(fetchCompletedAtBefore))) + .map(_.id) + } + + + def deleteOld(fetchCompletedAtBefore: ZonedDateTime): IO[Unit] = { + for { + _ <- logger.info(s"Start to delete old articles ...") + deletedMarks <- deleteOldArticleMarks(fetchCompletedAtBefore) + _ <- logger.info(s"Deleted $deletedMarks article marks.") + deletedContent <- deleteOldArticleContent(fetchCompletedAtBefore) + _ <- logger.info(s"Deleted $deletedContent article content.") + deletedArticles <- deleteOldArticles(fetchCompletedAtBefore) + _ <- logger.info(s"Deleted $deletedArticles articles.") + } yield () + } + + private def deleteElasticArticles(sourceIDs: Seq[String]): IO[Long] = { + import com.sksamuel.elastic4s.ElasticDsl._ + elasticClient.execute { + deleteByQuery (ArticleElasticDao.indexName, termsQuery ("sourceID", sourceIDs.toSet)) + // .requestsPerSecond (100) + } .map(_.result.swap.toOption.get.deleted) + } + + private def cleanElasticStorage(): IO[Unit] = { + import com.sksamuel.elastic4s.ElasticDsl._ + elasticClient.execute { + forceMerge (ArticleElasticDao.indexName) + }.map(_ => ()) + } + + private def getOldSourcesIDs(fetchCompletedAtBefore: ZonedDateTime): fs2.Stream[IO, String] = { + stream(sourceQuery(fetchCompletedAtBefore)).transact(xa) + } + + def deleteBySourceIDsBatch(fetchCompletedAtBefore: ZonedDateTime, batchSize: Int): IO[Unit] = { + val sourceIDs = getOldSourcesIDs(fetchCompletedAtBefore) + sourceIDs.chunkN(batchSize).evalScan(0L) { (total, idChunk) => + for { + deleted <- deleteElasticArticles(idChunk.toList) + next = deleted + total + _ <- logger.info(s"Deleted $next articles") + _ <- logger.info(s"Start to cleanup ElasticSearch storage ...") + _ <- cleanElasticStorage() + _ <- logger.info(s"Cleaned up ElasticSearch storage") + } yield next + }.compile.drain + } + + override def table: String = "" + override protected val indexName: String = "" + override def createTable(): IO[Unit] = IO.unit + override def dropTable(): IO[Unit] = IO.unit + override def deleteAll(): IO[Unit] = IO.unit +} diff --git a/src/main/scala/me/binwang/rss/ops/cleanup/articles/Main.scala b/src/main/scala/me/binwang/rss/ops/cleanup/articles/Main.scala new file mode 100644 index 0000000..8282195 --- /dev/null +++ b/src/main/scala/me/binwang/rss/ops/cleanup/articles/Main.scala @@ -0,0 +1,27 @@ +package me.binwang.rss.ops.cleanup.articles + +import cats.effect.{ExitCode, IO, IOApp} +import me.binwang.rss.dao.elasticsearch.ElasticSearchClient +import me.binwang.rss.dao.sql.{ConnectionPool, IsolationLevel} +import org.typelevel.log4cats.LoggerFactory +import org.typelevel.log4cats.slf4j.Slf4jFactory + +import java.time.ZonedDateTime + +object Main extends IOApp { + + implicit val loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO] + + override def run(args: List[String]): IO[ExitCode] = { + // delete things older than 6 months + val deleteBefore = ZonedDateTime.now().minusMonths(6) + val esClient = ElasticSearchClient() + + ConnectionPool(IsolationLevel.READ_COMMITTED).evalMap { connPool => + val cleaner = new ArticlesCleaner(connPool, esClient) + cleaner.deleteBySourceIDsBatch(deleteBefore, 500) >> cleaner.deleteOld(deleteBefore) + }.useForever + + } + +} diff --git a/src/main/scala/me/binwang/rss/service/UserService.scala b/src/main/scala/me/binwang/rss/service/UserService.scala index ec2eec8..019c6e7 100644 --- a/src/main/scala/me/binwang/rss/service/UserService.scala +++ b/src/main/scala/me/binwang/rss/service/UserService.scala @@ -7,6 +7,7 @@ import me.binwang.archmage.core.CatsMacros.timed import me.binwang.rss.dao._ import me.binwang.rss.mail.MailSender import me.binwang.rss.metric.TimeMetrics +import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ import me.binwang.rss.service.UserService._ import org.typelevel.log4cats.LoggerFactory @@ -272,6 +273,18 @@ class UserService( } } + def updateUserSettings(token: String, + nsfwSetting: Option[NSFWSetting], searchEngine: Option[SearchEngine]): IO[Unit] = timed { + authorizer.authorize(token).flatMap { userSession => + userDao + .update(userSession.userID, UserUpdater( + nsfwSetting = nsfwSetting, + searchEngine = searchEngine, + )) + .map(_ => ()) + } + } + def removeCurrentFolderAndSource(token: String): IO[Unit] = timed { authorizer.authorize(token).flatMap { userSession => userDao.update(userSession.userID, UserUpdater(currentFolderID = Some(None), currentSourceID = Some(None))) diff --git a/src/main/scala/me/binwang/rss/webview/basic/ContentRender.scala b/src/main/scala/me/binwang/rss/webview/basic/ContentRender.scala index a326c4f..162a181 100644 --- a/src/main/scala/me/binwang/rss/webview/basic/ContentRender.scala +++ b/src/main/scala/me/binwang/rss/webview/basic/ContentRender.scala @@ -25,11 +25,12 @@ object ContentRender extends ScalatagsInstances { val smallScreenScript = "window.innerWidth <= 1280" Html( div(id := "main", - xData := s"{showMenu: $showMenuScript, smallScreen: $smallScreenScript}", + xData := s"{showMenu: $showMenuScript, smallScreen: $smallScreenScript, nsfwClass: loadNSFWClass()}", xOn("resize.window") := s"showMenu = $showMenuScript ; smallScreen = $smallScreenScript ; ", div(id := "folder-list", hxGet := "/hx/folders", hxTrigger := "load once", hxTarget := "this", hxSwap := "outerHTML"), div(id := "content-area", + xBind("class") := "nsfwClass", tag("progress")(id := "content-indicator", cls := "htmx-indicator"), div(id := "content", hxHistoryElt, div(id := "load-content", hxGet := contentUrl, hxTrigger := "load once", @@ -42,7 +43,7 @@ object ContentRender extends ScalatagsInstances { def wrapContentRaw(req: Request[IO])(content: => IO[Response[IO]]): IO[Response[IO]] = { if (req.headers.get(CIString("HX-Request")).isEmpty || - req.headers.get(CIString("X-History-Restore-Request")).isDefined) { + req.headers.get(CIString("X-History-Restore-Request")).isDefined) { Ok(apply(req.uri.toString()), `Content-Type`(MediaType.text.html)) } else { content @@ -58,4 +59,14 @@ object ContentRender extends ScalatagsInstances { } } + def wrapUrl(req: Request[IO])(urlIO: => IO[String]): IO[Response[IO]] = { + if (req.headers.get(CIString("HX-Request")).isEmpty || + req.headers.get(CIString("X-History-Restore-Request")).isDefined) { + urlIO.flatMap(url => Ok(apply(url), `Content-Type`(MediaType.text.html))) + } else { + // this means do not let hx take full redirect, just do a full redirect on this sub-page + urlIO.flatMap(url => HttpResponse.fullRedirect("", url)) + } + } + } diff --git a/src/main/scala/me/binwang/rss/webview/basic/ScalaTagAttributes.scala b/src/main/scala/me/binwang/rss/webview/basic/ScalaTagAttributes.scala index 7d2dd10..7317e8a 100644 --- a/src/main/scala/me/binwang/rss/webview/basic/ScalaTagAttributes.scala +++ b/src/main/scala/me/binwang/rss/webview/basic/ScalaTagAttributes.scala @@ -35,7 +35,7 @@ object ScalaTagAttributes { val hxParamsAll: generic.AttrPair[Builder, String] = hxParams := "*" val hxInclude: Attr = attr("hx-include") val hxSwapOob: Attr = attr("hx-swap-oob") - val hxDisableElt: Attr = attr("hx-disable-elt") + val hxDisableElt: Attr = attr("hx-disabled-elt") val hxDisableThis: generic.AttrPair[Builder, String] = hxDisableElt := "this" val hxEncoding: Attr = attr("hx-encoding") @@ -47,6 +47,7 @@ object ScalaTagAttributes { xText := s"new Date(${time.toEpochSecond * 1000L}).toLocaleString()" val xShow: Attr = attr("x-show") def xOn(event: String): Attr = attr(s"x-on:$event") + val xModel: Attr = attr("x-model") val xOnClick: Attr = xOn("click") def xBind(name: String): Attr = attr(s"x-bind:$name") val xRef: Attr = attr("x-ref") diff --git a/src/main/scala/me/binwang/rss/webview/routes/ArticleListView.scala b/src/main/scala/me/binwang/rss/webview/routes/ArticleListView.scala index c88ddda..14a443d 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/ArticleListView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/ArticleListView.scala @@ -9,7 +9,7 @@ import me.binwang.rss.model._ import me.binwang.rss.service.{ArticleService, FolderService, SourceService, UserService} import me.binwang.rss.webview.auth.CookieGetter.reqToCookieGetter import me.binwang.rss.webview.basic.ContentRender -import me.binwang.rss.webview.basic.ContentRender.{hxSwapContentAttrs, wrapContentRaw} +import me.binwang.rss.webview.basic.ContentRender.{hxSwapContentAttrs, wrapContentRaw, wrapUrl} import me.binwang.rss.webview.basic.ErrorHandler.RedirectDefaultFolderException import me.binwang.rss.webview.basic.HtmlCleaner.str2cleaner import me.binwang.rss.webview.basic.ScalaTagAttributes._ @@ -236,20 +236,17 @@ class ArticleListView(articleService: ArticleService, userService: UserService, } - case req @ GET -> Root / "sources" / sourceID / "articles" => + case req @ GET -> Root / "sources" / sourceID / "articles" => wrapUrl(req) { val token = req.authToken val folderSourceIO = req.params.get("in_folder") - .map{folderID => sourceService.getSourceInFolder(token, folderID, sourceID)} + .map { folderID => sourceService.getSourceInFolder(token, folderID, sourceID) } .getOrElse(sourceService.getSourceInUser(token, sourceID)) .recoverWith { - case err @ (_: NoPermissionOnFolder | _: SourceNotFound | _: SourceInFolderNotFoundError) => + case err@(_: NoPermissionOnFolder | _: SourceNotFound | _: SourceInFolderNotFoundError) => IO.raiseError(RedirectDefaultFolderException(err)) } - userService.setCurrentSource(token, sourceID) &> - folderSourceIO.flatMap { folderSource => - val hxLink = hxLinkFromSource(folderSource) - Ok(ContentRender(hxLink), `Content-Type`(MediaType.text.html)) - } + userService.setCurrentSource(token, sourceID) &> folderSourceIO.map(hxLinkFromSource) + } case req @ GET -> Root / "hx" / "sources" / sourceID / "articles" / "by_time" / layout => diff --git a/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala b/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala index 3ddca3c..70608c7 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala @@ -34,7 +34,7 @@ class ArticleView(articleService: ArticleService) extends Http4sView with Scalat div( id := "article-reader", div(cls := "article-title")(article.article.title), - ArticleRender.renderInfo(article.article), + ArticleRender.renderInfo(article.article, req.params.get("in_folder")), ArticleRender.mediaDom(article.article, ArticleRender.mediaRenderOptionInReader), div(cls := "article-content")(raw(article.content.validHtml)), ArticleRender.renderOps(article.article, showActionable = false), diff --git a/src/main/scala/me/binwang/rss/webview/routes/FolderListView.scala b/src/main/scala/me/binwang/rss/webview/routes/FolderListView.scala index 91137e0..c44943a 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/FolderListView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/FolderListView.scala @@ -243,7 +243,7 @@ class FolderListView(folderService: FolderService, sourceService: SourceService, label(div("Name"), input(`type` := "text", name := "name")), label(div("Description"), input(`type` := "text", name := "description")), div(cls := "button-row", - button(hxPost := s"/hx/folders", hxExt := "json-enc", + button(hxPost := s"/hx/folders", hxExt := "json-enc", hxDisableThis, hxVals := "js:{position:getNextFolderPosition()}", "Create")), ) ) diff --git a/src/main/scala/me/binwang/rss/webview/routes/ImportFeedView.scala b/src/main/scala/me/binwang/rss/webview/routes/ImportFeedView.scala index e29b0ba..25d386e 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/ImportFeedView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/ImportFeedView.scala @@ -38,7 +38,7 @@ class ImportFeedView(sourceService: SourceService, folderService: FolderService) small(cls := "import-feed-hint")("Input a RSS or Atom feed below. " + "You can also input any website URL, RSS Brain will try to find a feed for you."), input(id := "import-feed-input", `type` := "text", placeholder := "Feed or Website URL", name := "url"), - button(hxGet := "/find_feed", hxTrigger := "click", hxTarget := "#content", + button(hxGet := "/find_feed", hxTrigger := "click", hxTarget := "#content", hxDisableThis, hxIndicator := "#content-indicator", hxParamsAll, hxPushUrl := "true", hxSync := "#content:replace", hxInclude := "#import-feed-input")("Find"), ) diff --git a/src/main/scala/me/binwang/rss/webview/routes/LoginView.scala b/src/main/scala/me/binwang/rss/webview/routes/LoginView.scala index f78a11c..62bc22f 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/LoginView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/LoginView.scala @@ -29,7 +29,7 @@ class LoginView(userService: UserService) extends Http4sView with ScalatagsInsta input(`type` := "text", name := "email", id := "email", placeholder := "Email", required), input(`type` := "password", name := "password", id := "password", placeholder := "Password", required), ), - button(hxPost := "/hx/login", "Login"), + button(hxPost := "/hx/login", hxDisableThis, "Login"), div( cls := "login-hint", small("Don't have an account yet? Click ", a(href := "/signup")("here"), " to create a new account."), @@ -82,7 +82,7 @@ class LoginView(userService: UserService) extends Http4sView with ScalatagsInsta input(`type` := "password", name := "password", id := "password", placeholder := "Password", required), input(`type` := "password", name := "password2", id := "password2", placeholder := "Repeat Password", required), ), - button(hxPost := "/hx/signup", "Sign Up"), + button(hxPost := "/hx/signup", hxDisableThis, "Sign Up"), div( cls := "login-hint", small("Already have an account? Click ", a(href := "/login")("here"), " to login."), diff --git a/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala b/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala index aefa862..43454aa 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala @@ -2,7 +2,7 @@ package me.binwang.rss.webview.routes import cats.effect._ import me.binwang.rss.grpc.ModelTranslator import me.binwang.rss.model.MoreLikeThisType -import me.binwang.rss.service.{ArticleService, FolderService, MoreLikeThisService, SourceService} +import me.binwang.rss.service.{ArticleService, FolderService, MoreLikeThisService, SourceService, UserService} import me.binwang.rss.webview.auth.CookieGetter.reqToCookieGetter import me.binwang.rss.webview.basic.ContentRender.{hxSwapContentAttrs, wrapContentRaw} import me.binwang.rss.webview.basic.ScalaTagAttributes._ @@ -17,8 +17,12 @@ import org.http4s.scalatags.ScalatagsInstances import org.typelevel.log4cats.LoggerFactory import scalatags.Text.all._ +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + class RecommendationView(moreLikeThisService: MoreLikeThisService, articleService: ArticleService, - sourceService: SourceService, folderService: FolderService)(implicit val loggerFactory: LoggerFactory[IO]) + sourceService: SourceService, folderService: FolderService, userService: UserService, + )(implicit val loggerFactory: LoggerFactory[IO]) extends Http4sView with ScalatagsInstances with ScalatagsSeqInstances { private val DEFAULT_SIZE = 10 @@ -258,5 +262,14 @@ class RecommendationView(moreLikeThisService: MoreLikeThisService, articleServic Ok(dom, `Content-Type`(MediaType.text.html)) } + case req @ GET -> Root / "articles" / articleID / "external-search" => + val token = req.authToken + userService.getMyUserInfo(token).flatMap { user => + articleService.getArticleTermVector(token, articleID, 10).flatMap { terms => + val searchTerm = terms.terms.map(_.term).mkString(" ") + val url = user.searchEngine.urlPrefix + URLEncoder.encode(searchTerm, StandardCharsets.UTF_8) + HttpResponse.redirect("", url, req) + } + } } } diff --git a/src/main/scala/me/binwang/rss/webview/routes/UserView.scala b/src/main/scala/me/binwang/rss/webview/routes/UserView.scala index 1264c12..fb0a288 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/UserView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/UserView.scala @@ -1,12 +1,13 @@ package me.binwang.rss.webview.routes import cats.effect.IO +import me.binwang.rss.model.{NSFWSetting, SearchEngine} import me.binwang.rss.service.{SystemService, UserService} import me.binwang.rss.webview.auth.CookieGetter.reqToCookieGetter import me.binwang.rss.webview.basic.ContentRender.wrapContentRaw import me.binwang.rss.webview.basic.ScalaTagAttributes._ -import me.binwang.rss.webview.basic.ScalatagsSeqInstances +import me.binwang.rss.webview.basic.{ContentRender, HttpResponse, ScalatagsSeqInstances} import me.binwang.rss.webview.widgets.PageHeader -import org.http4s.{HttpRoutes, MediaType} +import org.http4s.{HttpRoutes, MediaType, UrlForm} import org.http4s.dsl.io._ import org.http4s.headers.`Content-Type` import org.http4s.scalatags.ScalatagsInstances @@ -39,6 +40,31 @@ class UserView(userService: UserService, systemService: SystemService) extends H Ok(dom, `Content-Type`(MediaType.text.html)) } + case req @ POST -> Root / "settings" => + val token = req.authToken + req.decode[UrlForm] { data => + + // `search-engine-url` is disabled when submit if there is a name, so find the engine from predefined ones. + val searchEngineName = data.values.get("search-engine-name").flatMap(_.headOption).filter(_.nonEmpty) + val searchEngine = SearchEngine.ALL.find(_.name.equals(searchEngineName)).getOrElse( + SearchEngine(name = searchEngineName, + urlPrefix = data.values.get("search-engine-url").flatMap(_.headOption).get) + ) + + userService.updateUserSettings(token = token, + nsfwSetting = data.values.get("nsfw-setting").map(s => NSFWSetting.withName(s.headOption.get)), + searchEngine = Some(searchEngine), + ).flatMap(_ => HttpResponse.redirect("", "/settings", req)) + } + + + case req @ GET -> Root / "hx" / "settings" / "nsfw" => + val token = req.authToken + userService.getMyUserInfo(token).flatMap { user => + Ok(user.nsfwSetting.toString, `Content-Type`(MediaType.text.plain)) + } + + case req @ GET -> Root / "settings" => wrapContentRaw(req) { val token = req.authToken for { @@ -55,7 +81,7 @@ class UserView(userService: UserService, systemService: SystemService) extends H div(s"Username: ${user.username}"), div(s"Email: ${user.email}"), div(s"Account created at ", span(xDate(user.createdAt))), - div(cls := "button-row", button(cls := "secondary", hxPost := "/hx/logout", "Sign Out")), + div(cls := "button-row", button(cls := "secondary", hxDisableThis, hxPost := "/hx/logout", "Sign Out")), ), hr(), if (!paymentEnabled) "" else div( @@ -66,19 +92,49 @@ class UserView(userService: UserService, systemService: SystemService) extends H a(target := "_blank", href := "/payment/stripe/portal", "Manage Subscription"), hr(), ), - div( + form( + xData :=s"""{ + searchEngineName: '${user.searchEngine.name.getOrElse("")}', + searchEngineUrl: '${user.searchEngine.urlPrefix}', + nsfwSetting: '${user.nsfwSetting.toString}', + }""", cls := "form-section", h2("System"), label("Dark Mode"), select( name := "dark-mode", - xData := "{}", xOn("change") := "setTheme($el.value)", option("Follow System Settings", xBind("selected") := "getCurrentTheme() == 'auto'", value := "auto"), option("Disable", xBind("selected") := "getCurrentTheme() == 'light'", value := "light"), option("Enable", xBind("selected") := "getCurrentTheme() == 'dark'", value := "dark"), ), + label("Search Engine"), + select( + name := "search-engine-name", + xModel := "searchEngineName", + xOn("change") := "searchEngineUrl = $el.options[$el.selectedIndex].dataset.url", + (SearchEngine.ALL :+ user.searchEngine.copy(name=None)).map { searchEngine => + option( + searchEngine.name.getOrElse[String]("Custom"), + value := searchEngine.name.getOrElse(""), + data("url") := searchEngine.urlPrefix, + ) + } + ), + label("Search Engine URL Prefix"), + input(name := "search-engine-url", xModel := "searchEngineUrl", `type` := "text", + xBind("disabled") := "searchEngineName !== ''"), + label("NSFW Content Display"), + select( + name := "nsfw-setting", + xModel := "nsfwSetting", + option("Hide", value := "HIDE"), + option("Blur", value := "BLUR"), + option("Show", value := "SHOW"), + ), a(href := "/opml-export", target := "_blank", "Export OPML"), + div(cls := "button-row", + button(hxPost := "/settings", ContentRender.hxSwapContentAttrs, hxDisableThis, "Save")), /* label(cls := "form-row", input(is := "boolean-checkbox", `type` := "checkbox", name := "enable-search"), "Auto hide left Panel"), diff --git a/src/main/scala/me/binwang/rss/webview/widgets/ArticleRender.scala b/src/main/scala/me/binwang/rss/webview/widgets/ArticleRender.scala index 189fc3a..bb72c14 100644 --- a/src/main/scala/me/binwang/rss/webview/widgets/ArticleRender.scala +++ b/src/main/scala/me/binwang/rss/webview/widgets/ArticleRender.scala @@ -3,12 +3,15 @@ package me.binwang.rss.webview.widgets import cats.effect.IO import me.binwang.rss.model.{Article, ArticleUserMarking, ArticleWithUserMarking, MediaGroup, MediaMedium} import me.binwang.rss.webview.basic.HtmlCleaner.str2cleaner -import me.binwang.rss.webview.basic.ProxyUrl +import me.binwang.rss.webview.basic.{ContentRender, ProxyUrl} import me.binwang.rss.webview.basic.ScalaTagAttributes._ import scalatags.generic import scalatags.Text.all._ import scalatags.text.Builder +import java.net.URLEncoder +import java.nio.charset.{Charset, StandardCharsets} + object ArticleRender { sealed trait ArticleRenderLayout @@ -79,12 +82,15 @@ object ArticleRender { private def downvotesDom(article: Article): Frag = articleNumDom(article.downVotes, "article-downvotes", "thumb_down") private def commentsDom(article: Article): Frag = articleNumDom(article.comments, "article-comments", "mode_comment") - def renderInfo(article: Article): Frag = { + def renderInfo(article: Article, folderIDOpt: Option[String]): Frag = { val author = if (article.author.isEmpty) "" else s" by ${article.author.get.escapeHtml}" + val sourceUrl = s"/sources/${article.sourceID}/articles${getFolderParam(folderIDOpt)}" + val sourceTitle = a(hxGet := sourceUrl, hxPushUrl := sourceUrl, ContentRender.hxSwapContentAttrs, nullHref)( + article.sourceTitle.getOrElse[String]("Unknown")) div( cls := "article-info", SourceIcon(article.sourceID), - span(s"Published in ${article.sourceTitle.getOrElse("Unknown")}$author | "), + span("Published in ", sourceTitle, s"$author | "), span(cls := "material-icons-outlined")("schedule"), span(xText := s"new Date(${article.postedAt.toEpochSecond * 1000}).toLocaleString()") ) @@ -101,6 +107,7 @@ object ArticleRender { } def renderOps(article: Article, showActionable: Boolean = true, showNonActionable: Boolean = true): Frag = { + val externalSearchPrefix = URLEncoder.encode("https://kagi.com/search?q=", StandardCharsets.UTF_8) div( cls := "article-ops", if (showActionable) Seq( @@ -119,6 +126,8 @@ object ArticleRender { a(title := "Unlike article", xShow := "bookmarked", xOnClick := "bookmarked=false", hxPost := s"/hx/articles/${article.id}/state/bookmark/false", hxSwap := "none", nullHref, iconSpan("favorite")), + a(title := "Search External", href := s"/articles/${article.id}/external-search", + target := "_blank", iconSpan("manage_search")), ) else Seq[Frag](), if (showNonActionable) Seq( upvotesDom(article), @@ -129,15 +138,16 @@ object ArticleRender { } - def render(articleWithMarking: ArticleWithUserMarking, layout: ArticleRenderLayout): Frag = { + def render(articleWithMarking: ArticleWithUserMarking, layout: ArticleRenderLayout, + folderIDOpt: Option[String]): Frag = { val article = articleWithMarking.article val readAttr = articleAttrs(articleWithMarking.userMarking, bindReadClass = true) // delay 20ms so that other requests/changes will be made before the content is swapped out val clickAttr = Seq( nullClick, - hxGet := s"/hx/articles/${article.id}", - hxPushUrl := s"/articles/${article.id}", + hxGet := s"/hx/articles/${article.id}${getFolderParam(folderIDOpt)}", + hxPushUrl := s"/articles/${article.id}${getFolderParam(folderIDOpt)}", hxTarget := "#content", hxSwap := "innerHTML show:window:top", hxIndicator := "#content-indicator", @@ -150,25 +160,29 @@ object ArticleRender { val markReadDom = div(hxPost :=s"/hx/articles/${article.id}/state/read/true", hxSwap := "none", hxTrigger := s"click from:.article-${article.id}-click") - val articleInfo = renderInfo(article) + val articleInfo = renderInfo(article, folderIDOpt) val articleOps = renderOps(article) val articleTitle = div(cls := s"article-title $clickClasses", clickAttr)(article.title) + val nsfwClass = if (article.nsfw) "nsfw" else "" if (!layout.isInstanceOf[GridLayout]) { val directionClass = if (layout.isInstanceOf[VerticalLayout]) "article-vertical" else "article-horizontal" div( id := s"article-${article.id}", - cls := s"article $directionClass", + cls := s"article $directionClass $nsfwClass", readAttr, markReadDom, articleTitle, articleInfo, mediaDom(article), div(cls := s"article-desc $clickClasses", clickAttr)(raw(article.description.validHtml)), + if (article.nsfw) { + div(cls := s"article-desc-nsfw", clickAttr)("[NSFW Content]") + } else "", articleOps, ) } else { div( - cls := "article article-grid", + cls := s"article article-grid $nsfwClass", readAttr, mediaDom(article), div( @@ -183,13 +197,13 @@ object ArticleRender { } def renderList(articles: fs2.Stream[IO, ArticleWithUserMarking], layout: ArticleRenderLayout = VerticalLayout(), - nextPageUrl: Option[Article => String] = None): fs2.Stream[IO, Frag] = { + nextPageUrl: Option[Article => String] = None, folderIDOpt: Option[String] = None): fs2.Stream[IO, Frag] = { val articlesDom = if (nextPageUrl.isEmpty) { - articles.map(a => render(a, layout)) + articles.map(a => render(a, layout ,folderIDOpt)) } else { articles.zipWithNext.flatMap { case (article, nextArticleOpt) => - val articleDom = render(article, layout) + val articleDom = render(article, layout, folderIDOpt) if (nextArticleOpt.isDefined) { fs2.Stream.emit(articleDom) } else { @@ -213,4 +227,8 @@ object ArticleRender { fs2.Stream.emit(header) ++ articlesDom ++ fs2.Stream.emit(tail) } + private def getFolderParam(folderIDOpt: Option[String]): String = { + folderIDOpt.map(f => s"?in_folder=$f").getOrElse("") + } + } diff --git a/src/main/scala/me/binwang/rss/webview/widgets/SourcesPreview.scala b/src/main/scala/me/binwang/rss/webview/widgets/SourcesPreview.scala index 4238145..fe464f5 100644 --- a/src/main/scala/me/binwang/rss/webview/widgets/SourcesPreview.scala +++ b/src/main/scala/me/binwang/rss/webview/widgets/SourcesPreview.scala @@ -27,7 +27,7 @@ object SourcesPreview { cls := "feed-preview-header", h2(cls := "feed-preview-title")(source.title.getOrElse[String]("Unknown")), button(xShow := s"$subscribedScript != null", cls := "feed-subscribe-btn", disabled)("Subscribed"), - button(id := s"feed-subscribe-btn-${source.id}", cls := "feed-subscribe-btn", + button(id := s"feed-subscribe-btn-${source.id}", cls := "feed-subscribe-btn", hxDisableThis, xShow := s"$subscribedScript == null", hxPost := "/hx/subscribe_feed", hxExt := "json-enc", hxVals := subscribeVars)("Subscribe"), hxSwap := "none"), source.description.map(d => div(cls := "feed-preview-desc", d)).getOrElse[Frag](""),