Compare commits

...

10 Commits

Author SHA1 Message Date
94919878ea fix: fix issues 2025-07-02 01:33:32 +08:00
81bf27ed28 fix: fix 2025-07-08 00:54:34 +08:00
5be5b9f634 fix: fix cron builder 2025-07-07 01:34:56 +08:00
6cdd8c27ce fix: fix typos 2025-07-06 05:05:07 +08:00
4174cea728 fix: fix cron webui 2025-07-06 02:35:55 +08:00
3aad31a36b feat: more cron webui 2025-07-05 04:08:56 +08:00
004fed9b2e feat: init cron webui 2025-07-05 02:08:55 +08:00
a1c2eeded1 temp save 2025-07-04 05:59:56 +08:00
147df00155 build: add prod build 2025-07-04 05:06:45 +08:00
5155c59293 fix: fix migrations 2025-07-04 01:25:07 +08:00
96 changed files with 5685 additions and 1397 deletions

View File

@@ -41,4 +41,4 @@
], ],
"rust-analyzer.cargo.features": "all", "rust-analyzer.cargo.features": "all",
"rust-analyzer.testExplorer": true "rust-analyzer.testExplorer": true
} }

85
Cargo.lock generated
View File

@@ -551,7 +551,7 @@ dependencies = [
"derive_builder", "derive_builder",
"diligent-date-parser", "diligent-date-parser",
"never", "never",
"quick-xml", "quick-xml 0.37.5",
"serde", "serde",
] ]
@@ -1260,9 +1260,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.40" version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -1270,9 +1270,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.40" version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -1282,9 +1282,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.40" version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -1922,6 +1922,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@@ -2332,11 +2343,12 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.14.0" version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" checksum = "d6215aee357f8c7c989ebb4b8466ca4d7dc93b3957039f2fc3ea2ade8ea5f279"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"derivative",
"regex-automata 0.4.9", "regex-automata 0.4.9",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
@@ -4394,7 +4406,7 @@ dependencies = [
"futures", "futures",
"httparse", "httparse",
"network-interface", "network-interface",
"quick-xml", "quick-xml 0.37.5",
"reqwest", "reqwest",
"serde", "serde",
"tokio", "tokio",
@@ -4846,15 +4858,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "nanoid"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [
"rand 0.8.5",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -5175,7 +5178,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"parking_lot 0.12.4", "parking_lot 0.12.4",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml 0.37.5",
"rand 0.9.1", "rand 0.9.1",
"reqwest", "reqwest",
"ring", "ring",
@@ -5228,7 +5231,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml 0.37.5",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -6514,6 +6517,16 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "quick-xml"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.8" version = "0.11.8"
@@ -6766,6 +6779,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"chrono-tz 0.10.3",
"clap", "clap",
"cocoon", "cocoon",
"color-eyre", "color-eyre",
@@ -6797,7 +6811,6 @@ dependencies = [
"mime_guess", "mime_guess",
"mockito", "mockito",
"moka", "moka",
"nanoid",
"nom 8.0.0", "nom 8.0.0",
"num-traits", "num-traits",
"num_cpus", "num_cpus",
@@ -6807,7 +6820,7 @@ dependencies = [
"paste", "paste",
"percent-encoding", "percent-encoding",
"polars", "polars",
"quick-xml", "quick-xml 0.38.0",
"quirks_path", "quirks_path",
"rand 0.9.1", "rand 0.9.1",
"regex", "regex",
@@ -6836,6 +6849,7 @@ dependencies = [
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"tracing-test",
"tracing-tree", "tracing-tree",
"ts-rs", "ts-rs",
"typed-builder 0.21.0", "typed-builder 0.21.0",
@@ -7241,7 +7255,7 @@ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
"never", "never",
"quick-xml", "quick-xml 0.37.5",
"serde", "serde",
] ]
@@ -7668,7 +7682,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "seaography" name = "seaography"
version = "1.1.4" version = "1.1.4"
source = "git+https://github.com/dumtruck/seaography.git?rev=9f7fc7c#9f7fc7cf05234abe35fd9144c895321dd2b5db62" source = "git+https://github.com/dumtruck/seaography.git?rev=292cdd2#292cdd248217fdcf81c41aa97fe1c047c9b5f4de"
dependencies = [ dependencies = [
"async-graphql", "async-graphql",
"fnv", "fnv",
@@ -9244,6 +9258,27 @@ dependencies = [
"tracing-serde", "tracing-serde",
] ]
[[package]]
name = "tracing-test"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68"
dependencies = [
"tracing-core",
"tracing-subscriber",
"tracing-test-macro",
]
[[package]]
name = "tracing-test-macro"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568"
dependencies = [
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "tracing-tree" name = "tracing-tree"
version = "0.4.0" version = "0.4.0"

View File

@@ -31,22 +31,22 @@ reqwest = { version = "0.12.20", features = [
"macos-system-configuration", "macos-system-configuration",
"cookies", "cookies",
] } ] }
moka = "0.12" moka = "0.12.10"
futures = "0.3" futures = "0.3.31"
quirks_path = "0.1" quirks_path = "0.1.1"
snafu = { version = "0.8", features = ["futures"] } snafu = { version = "0.8.0", features = ["futures"] }
testcontainers = { version = "0.24" } testcontainers = { version = "0.24.0" }
testcontainers-modules = { version = "0.12.1" } testcontainers-modules = { version = "0.12.1" }
testcontainers-ext = { version = "0.1.0", features = ["tracing"] } testcontainers-ext = { version = "0.1.0", features = ["tracing"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.45.1", features = [ tokio = { version = "1.46", features = [
"macros", "macros",
"fs", "fs",
"rt-multi-thread", "rt-multi-thread",
"signal", "signal",
] } ] }
serde_json = "1" serde_json = "1.0.140"
async-trait = "0.1" async-trait = "0.1.88"
tracing = "0.1" tracing = "0.1"
url = "2.5.2" url = "2.5.2"
anyhow = "1" anyhow = "1"
@@ -64,7 +64,7 @@ convert_case = "0.8"
color-eyre = "0.6.5" color-eyre = "0.6.5"
inquire = "0.7.5" inquire = "0.7.5"
image = "0.25.6" image = "0.25.6"
uuid = { version = "1.6.0", features = ["v4"] } uuid = { version = "1.6.0", features = ["v7"] }
maplit = "1.0.2" maplit = "1.0.2"
once_cell = "1.20.2" once_cell = "1.20.2"
rand = "0.9.1" rand = "0.9.1"
@@ -77,11 +77,12 @@ http = "1.2.0"
async-stream = "0.3.6" async-stream = "0.3.6"
serde_variant = "0.1.3" serde_variant = "0.1.3"
tracing-appender = "0.2.3" tracing-appender = "0.2.3"
clap = "4.5.40" clap = "4.5.41"
ipnetwork = "0.21.1" ipnetwork = "0.21.1"
typed-builder = "0.21.0" typed-builder = "0.21.0"
nanoid = "0.4.0" nanoid = "0.4.0"
webp = "0.3.0" webp = "0.3.0"
[patch.crates-io] [patch.crates-io]
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "9f7fc7c" } seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "292cdd2" }

View File

@@ -0,0 +1,8 @@
```x-forwarded.json
{
"X-Forwarded-Host": "konobangu.com",
"X-Forwarded-Proto": "https"
}
```
^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/$1

View File

@@ -1 +1 @@
{"filesOrder":["konobangu","mikan_doppel"],"selectedList":["konobangu","mikan_doppel"],"disabledDefalutRules":true,"defalutRules":""} {"filesOrder":["konobangu","konobangu-prod","mikan-doppel"],"selectedList":["mikan-doppel","konobangu"],"disabledDefalutRules":true,"defalutRules":""}

View File

@@ -97,7 +97,6 @@ tracing-appender = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
ipnetwork = { workspace = true } ipnetwork = { workspace = true }
typed-builder = { workspace = true } typed-builder = { workspace = true }
nanoid = { workspace = true }
webp = { workspace = true } webp = { workspace = true }
sea-orm = { version = "1.1", features = [ sea-orm = { version = "1.1", features = [
@@ -110,7 +109,7 @@ sea-orm = { version = "1.1", features = [
figment = { version = "0.10", features = ["toml", "json", "env", "yaml"] } figment = { version = "0.10", features = ["toml", "json", "env", "yaml"] }
sea-orm-migration = { version = "1.1", features = ["runtime-tokio"] } sea-orm-migration = { version = "1.1", features = ["runtime-tokio"] }
rss = { version = "2", features = ["builders", "with-serde"] } rss = { version = "2", features = ["builders", "with-serde"] }
fancy-regex = "0.14" fancy-regex = "0.15"
lightningcss = "1.0.0-alpha.66" lightningcss = "1.0.0-alpha.66"
html-escape = "0.2.13" html-escape = "0.2.13"
opendal = { version = "0.53", features = ["default", "services-fs"] } opendal = { version = "0.53", features = ["default", "services-fs"] }
@@ -161,7 +160,7 @@ polars = { version = "0.49.1", features = [
"lazy", "lazy",
"diagonal_concat", "diagonal_concat",
], optional = true } ], optional = true }
quick-xml = { version = "0.37.5", features = [ quick-xml = { version = "0.38", features = [
"serialize", "serialize",
"serde-types", "serde-types",
"serde", "serde",
@@ -170,11 +169,13 @@ croner = "2.2.0"
ts-rs = "11.0.1" ts-rs = "11.0.1"
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
paste = "1.0.15" paste = "1.0.15"
chrono-tz = "0.10.3"
[dev-dependencies] [dev-dependencies]
inquire = { workspace = true } inquire = { workspace = true }
color-eyre = { workspace = true } color-eyre = { workspace = true }
serial_test = "3" serial_test = "3"
insta = { version = "1", features = ["redactions", "toml", "filters"] } insta = { version = "1", features = ["redactions", "toml", "filters"] }
rstest = "0.25"
ctor = "0.4.0" ctor = "0.4.0"
tracing-test = "0.2.5"
rstest = "0.25"

View File

@@ -107,7 +107,7 @@ impl App {
Ok::<(), RecorderError>(()) Ok::<(), RecorderError>(())
}, },
async { async {
task.run(if graceful_shutdown { task.run_with_signal(if graceful_shutdown {
Some(Self::shutdown_signal) Some(Self::shutdown_signal)
} else { } else {
None None

View File

@@ -18,6 +18,8 @@ use crate::{
#[derive(Snafu, Debug)] #[derive(Snafu, Debug)]
#[snafu(visibility(pub(crate)))] #[snafu(visibility(pub(crate)))]
pub enum RecorderError { pub enum RecorderError {
#[snafu(transparent)]
ChronoTzParseError { source: chrono_tz::ParseError },
#[snafu(transparent)] #[snafu(transparent)]
SeaographyError { source: seaography::SeaographyError }, SeaographyError { source: seaography::SeaographyError },
#[snafu(transparent)] #[snafu(transparent)]

View File

@@ -1,38 +1,4 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use fancy_regex::Regex as FancyRegex;
use lazy_static::lazy_static;
use quirks_path::Path;
use regex::Regex;
use serde::{Deserialize, Serialize};
use snafu::{OptionExt, whatever};
use crate::{
errors::app_error::{RecorderError, RecorderResult},
extract::defs::SUBTITLE_LANG,
};
lazy_static! {
static ref TORRENT_EP_PARSE_RULES: Vec<FancyRegex> = {
vec![
FancyRegex::new(
r"(.*) - (\d{1,4}(?!\d|p)|\d{1,4}\.\d{1,2}(?!\d|p))(?:v\d{1,2})?(?: )?(?:END)?(.*)",
)
.unwrap(),
FancyRegex::new(
r"(.*)[\[\ E](\d{1,4}|\d{1,4}\.\d{1,2})(?:v\d{1,2})?(?: )?(?:END)?[\]\ ](.*)",
)
.unwrap(),
FancyRegex::new(r"(.*)\[(?:第)?(\d*\.*\d*)[话集話](?:END)?\](.*)").unwrap(),
FancyRegex::new(r"(.*)第?(\d*\.*\d*)[话話集](?:END)?(.*)").unwrap(),
FancyRegex::new(r"(.*)(?:S\d{2})?EP?(\d+)(.*)").unwrap(),
]
};
static ref GET_FANSUB_SPLIT_RE: Regex = Regex::new(r"[\[\]()【】()]").unwrap();
static ref GET_FANSUB_FULL_MATCH_RE: Regex = Regex::new(r"^\d+$").unwrap();
static ref GET_SEASON_AND_TITLE_SUB_RE: Regex = Regex::new(r"([Ss]|Season )\d{1,3}").unwrap();
static ref GET_SEASON_AND_TITLE_FIND_RE: Regex =
Regex::new(r"([Ss]|Season )(\d{1,3})").unwrap();
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct EpisodeEnclosureMeta { pub struct EpisodeEnclosureMeta {
@@ -41,293 +7,3 @@ pub struct EpisodeEnclosureMeta {
pub pub_date: Option<DateTime<Utc>>, pub pub_date: Option<DateTime<Utc>>,
pub content_length: Option<i64>, pub content_length: Option<i64>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TorrentEpisodeMediaMeta {
pub fansub: Option<String>,
pub title: String,
pub season: i32,
pub episode_index: i32,
pub extname: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TorrentEpisodeSubtitleMeta {
pub media: TorrentEpisodeMediaMeta,
pub lang: Option<String>,
}
fn get_fansub(group_and_title: &str) -> (Option<&str>, &str) {
let n = GET_FANSUB_SPLIT_RE
.split(group_and_title)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
match (n.first(), n.get(1)) {
(None, None) => (None, ""),
(Some(n0), None) => (None, *n0),
(Some(n0), Some(n1)) => {
if GET_FANSUB_FULL_MATCH_RE.is_match(n1) {
(None, group_and_title)
} else {
(Some(*n0), *n1)
}
}
_ => unreachable!("vec contains n1 must contains n0"),
}
}
fn get_season_and_title(season_and_title: &str) -> (String, i32) {
let replaced_title = GET_SEASON_AND_TITLE_SUB_RE.replace_all(season_and_title, "");
let title = replaced_title.trim().to_string();
let season = GET_SEASON_AND_TITLE_FIND_RE
.captures(season_and_title)
.map(|m| {
m.get(2)
.unwrap_or_else(|| unreachable!("season regex should have 2 groups"))
.as_str()
.parse::<i32>()
.unwrap_or_else(|_| unreachable!("season should be a number"))
})
.unwrap_or(1);
(title, season)
}
fn get_subtitle_lang(media_name: &str) -> Option<&str> {
let media_name_lower = media_name.to_lowercase();
for (lang, lang_aliases) in SUBTITLE_LANG.iter() {
if lang_aliases
.iter()
.any(|alias| media_name_lower.contains(alias))
{
return Some(lang);
}
}
None
}
pub fn parse_episode_media_meta_from_torrent(
torrent_path: &Path,
torrent_name: Option<&str>,
season: Option<i32>,
) -> RecorderResult<TorrentEpisodeMediaMeta> {
let media_name = torrent_path
.file_name()
.with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {torrent_path}")
})?;
let mut match_obj = None;
for rule in TORRENT_EP_PARSE_RULES.iter() {
match_obj = if let Some(torrent_name) = torrent_name.as_ref() {
rule.captures(torrent_name)?
} else {
rule.captures(media_name)?
};
if match_obj.is_some() {
break;
}
}
if let Some(match_obj) = match_obj {
let group_season_and_title = match_obj
.get(1)
.whatever_context::<_, RecorderError>("should have 1 group")?
.as_str();
let (fansub, season_and_title) = get_fansub(group_season_and_title);
let (title, season) = if let Some(season) = season {
let (title, _) = get_season_and_title(season_and_title);
(title, season)
} else {
get_season_and_title(season_and_title)
};
let episode_index = match_obj
.get(2)
.whatever_context::<_, RecorderError>("should have 2 group")?
.as_str()
.parse::<i32>()
.unwrap_or(1);
let extname = torrent_path
.extension()
.map(|e| format!(".{e}"))
.unwrap_or_default();
Ok(TorrentEpisodeMediaMeta {
fansub: fansub.map(|s| s.to_string()),
title,
season,
episode_index,
extname,
})
} else {
whatever!(
"failed to parse episode media meta from torrent_path='{}' torrent_name='{:?}'",
torrent_path,
torrent_name
)
}
}
pub fn parse_episode_subtitle_meta_from_torrent(
torrent_path: &Path,
torrent_name: Option<&str>,
season: Option<i32>,
) -> RecorderResult<TorrentEpisodeSubtitleMeta> {
let media_meta = parse_episode_media_meta_from_torrent(torrent_path, torrent_name, season)?;
let media_name = torrent_path
.file_name()
.with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {torrent_path}")
})?;
let lang = get_subtitle_lang(media_name);
Ok(TorrentEpisodeSubtitleMeta {
media: media_meta,
lang: lang.map(|s| s.to_string()),
})
}
#[cfg(test)]
mod tests {
use quirks_path::Path;
use super::{
TorrentEpisodeMediaMeta, TorrentEpisodeSubtitleMeta, parse_episode_media_meta_from_torrent,
parse_episode_subtitle_meta_from_torrent,
};
#[test]
fn test_lilith_raws_media() {
test_torrent_ep_parser(
r#"[Lilith-Raws] Boku no Kokoro no Yabai Yatsu - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4"#,
r#"{"fansub": "Lilith-Raws", "title": "Boku no Kokoro no Yabai Yatsu", "season": 1, "episode_index": 1, "extname": ".mp4"}"#,
);
}
#[test]
fn test_sakurato_media() {
test_torrent_ep_parser(
r#"[Sakurato] Tonikaku Kawaii S2 [03][AVC-8bit 1080p AAC][CHS].mp4"#,
r#"{"fansub": "Sakurato", "title": "Tonikaku Kawaii", "season": 2, "episode_index": 3, "extname": ".mp4"}"#,
)
}
#[test]
fn test_lolihouse_media() {
test_torrent_ep_parser(
r#"[SweetSub&LoliHouse] Heavenly Delusion - 08 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv"#,
r#"{"fansub": "SweetSub&LoliHouse", "title": "Heavenly Delusion", "season": 1, "episode_index": 8, "extname": ".mkv"}"#,
)
}
#[test]
fn test_sbsub_media() {
test_torrent_ep_parser(
r#"[SBSUB][CONAN][1082][V2][1080P][AVC_AAC][CHS_JP](C1E4E331).mp4"#,
r#"{"fansub": "SBSUB", "title": "CONAN", "season": 1, "episode_index": 1082, "extname": ".mp4"}"#,
)
}
#[test]
fn test_non_fansub_media() {
test_torrent_ep_parser(
r#"海盗战记 (2019) S04E11.mp4"#,
r#"{"title": "海盗战记 (2019)", "season": 4, "episode_index": 11, "extname": ".mp4"}"#,
)
}
#[test]
fn test_non_fansub_media_with_dirname() {
test_torrent_ep_parser(
r#"海盗战记/海盗战记 S01E01.mp4"#,
r#"{"title": "海盗战记", "season": 1, "episode_index": 1, "extname": ".mp4"}"#,
);
}
#[test]
fn test_non_fansub_tc_subtitle() {
test_torrent_ep_parser(
r#"海盗战记 S01E08.zh-tw.ass"#,
r#"{"media": { "title": "海盗战记", "season": 1, "episode_index": 8, "extname": ".ass" }, "lang": "zh-tw"}"#,
);
}
#[test]
fn test_non_fansub_sc_subtitle() {
test_torrent_ep_parser(
r#"海盗战记 S01E01.SC.srt"#,
r#"{ "media": { "title": "海盗战记", "season": 1, "episode_index": 1, "extname": ".srt" }, "lang": "zh" }"#,
)
}
#[test]
fn test_non_fansub_media_with_season_zero() {
test_torrent_ep_parser(
r#"水星的魔女(2022) S00E19.mp4"#,
r#"{"fansub": null,"title": "水星的魔女(2022)","season": 0,"episode_index": 19,"extname": ".mp4"}"#,
)
}
#[test]
fn test_shimian_fansub_media() {
test_torrent_ep_parser(
r#"【失眠搬运组】放学后失眠的你-Kimi wa Houkago Insomnia - 06 [bilibili - 1080p AVC1 CHS-JP].mp4"#,
r#"{"fansub": "失眠搬运组","title": "放学后失眠的你-Kimi wa Houkago Insomnia","season": 1,"episode_index": 6,"extname": ".mp4"}"#,
)
}
pub fn test_torrent_ep_parser(origin_name: &str, expected: &str) {
let extname = Path::new(origin_name)
.extension()
.map(|e| format!(".{e}"))
.unwrap_or_default()
.to_lowercase();
if extname == ".srt" || extname == ".ass" {
let expected: Option<TorrentEpisodeSubtitleMeta> = serde_json::from_str(expected).ok();
let found_raw =
parse_episode_subtitle_meta_from_torrent(Path::new(origin_name), None, None);
let found = found_raw.as_ref().ok().cloned();
if expected != found {
if found_raw.is_ok() {
println!(
"expected {} and found {} are not equal",
serde_json::to_string_pretty(&expected).unwrap(),
serde_json::to_string_pretty(&found).unwrap()
)
} else {
println!(
"expected {} and found {:#?} are not equal",
serde_json::to_string_pretty(&expected).unwrap(),
found_raw
)
}
}
assert_eq!(expected, found);
} else {
let expected: Option<TorrentEpisodeMediaMeta> = serde_json::from_str(expected).ok();
let found_raw =
parse_episode_media_meta_from_torrent(Path::new(origin_name), None, None);
let found = found_raw.as_ref().ok().cloned();
if expected != found {
if found_raw.is_ok() {
println!(
"expected {} and found {} are not equal",
serde_json::to_string_pretty(&expected).unwrap(),
serde_json::to_string_pretty(&found).unwrap()
)
} else {
println!(
"expected {} and found {:#?} are not equal",
serde_json::to_string_pretty(&expected).unwrap(),
found_raw
)
}
}
assert_eq!(expected, found);
}
}
}

View File

@@ -1,34 +0,0 @@
use fancy_regex::Regex as FancyRegex;
use lazy_static::lazy_static;
use regex::Regex;
const LANG_ZH_TW: &str = "zh-tw";
const LANG_ZH: &str = "zh";
const LANG_EN: &str = "en";
const LANG_JP: &str = "jp";
lazy_static! {
pub static ref SEASON_REGEX: Regex =
Regex::new(r"(S\|[Ss]eason\s+)(\d+)").expect("Invalid regex");
pub static ref TORRENT_PRASE_RULE_REGS: Vec<FancyRegex> = vec![
FancyRegex::new(
r"(.*) - (\d{1,4}(?!\d|p)|\d{1,4}\.\d{1,2}(?!\d|p))(?:v\d{1,2})?(?: )?(?:END)?(.*)"
)
.unwrap(),
FancyRegex::new(
r"(.*)[\[\ E](\d{1,4}|\d{1,4}\.\d{1,2})(?:v\d{1,2})?(?: )?(?:END)?[\]\ ](.*)"
)
.unwrap(),
FancyRegex::new(r"(.*)\[(?:第)?(\d*\.*\d*)[话集話](?:END)?\](.*)").unwrap(),
FancyRegex::new(r"(.*)第?(\d*\.*\d*)[话話集](?:END)?(.*)").unwrap(),
FancyRegex::new(r"(.*)(?:S\d{2})?EP?(\d+)(.*)").unwrap(),
];
pub static ref SUBTITLE_LANG: Vec<(&'static str, Vec<&'static str>)> = {
vec![
(LANG_ZH_TW, vec!["tc", "cht", "", "zh-tw"]),
(LANG_ZH, vec!["sc", "chs", "", "zh", "zh-cn"]),
(LANG_EN, vec!["en", "eng", ""]),
(LANG_JP, vec!["jp", "jpn", ""]),
]
};
}

View File

@@ -26,8 +26,8 @@ use crate::{
MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_FANSUB_HOMEPAGE_PATH, MIKAN_FANSUB_ID_QUERY_KEY, MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_FANSUB_HOMEPAGE_PATH, MIKAN_FANSUB_ID_QUERY_KEY,
MIKAN_POSTER_BUCKET_KEY, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SEASON_STR_QUERY_KEY, MIKAN_POSTER_BUCKET_KEY, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SEASON_STR_QUERY_KEY,
MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY, MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY,
MIKAN_YEAR_QUERY_KEY, MikanClient, build_mikan_bangumi_subscription_rss_url, MIKAN_UNKNOWN_FANSUB_ID, MIKAN_YEAR_QUERY_KEY, MikanClient,
build_mikan_subscriber_subscription_rss_url, build_mikan_bangumi_subscription_rss_url, build_mikan_subscriber_subscription_rss_url,
}, },
}, },
media::{ media::{
@@ -564,16 +564,17 @@ pub fn extract_mikan_episode_meta_from_episode_homepage_html(
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id")) RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
})?; })?;
let fansub_name = html let fansub_name = if mikan_fansub_id == MIKAN_UNKNOWN_FANSUB_ID {
.select( MIKAN_UNKNOWN_FANSUB_ID.to_string()
} else {
html.select(
&Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']") &Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']")
.unwrap(), .unwrap(),
) )
.next() .next()
.map(extract_inner_text_from_element_ref) .map(extract_inner_text_from_element_ref)
.ok_or_else(|| { .ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))?
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")) };
})?;
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| { let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
el.value() el.value()
@@ -685,6 +686,13 @@ pub fn extract_mikan_fansub_meta_from_bangumi_homepage_html(
html: &Html, html: &Html,
mikan_fansub_id: String, mikan_fansub_id: String,
) -> Option<MikanFansubMeta> { ) -> Option<MikanFansubMeta> {
if mikan_fansub_id == MIKAN_UNKNOWN_FANSUB_ID {
return Some(MikanFansubMeta {
mikan_fansub_id,
fansub: MIKAN_UNKNOWN_FANSUB_ID.to_string(),
});
}
html.select( html.select(
&Selector::parse(&format!( &Selector::parse(&format!(
"a.subgroup-name[data-anchor='#{mikan_fansub_id}']" "a.subgroup-name[data-anchor='#{mikan_fansub_id}']"

View File

@@ -1,5 +1,4 @@
pub mod bittorrent; pub mod bittorrent;
pub mod defs;
pub mod html; pub mod html;
pub mod http; pub mod http;
pub mod media; pub mod media;

View File

@@ -20,6 +20,7 @@ fn skip_columns_for_entity_input(context: &mut BuilderContext) {
cron::Column::SubscriberTaskCron cron::Column::SubscriberTaskCron
| cron::Column::SystemTaskCron | cron::Column::SystemTaskCron
| cron::Column::CronExpr | cron::Column::CronExpr
| cron::Column::CronTimezone
| cron::Column::Enabled | cron::Column::Enabled
| cron::Column::TimeoutMs | cron::Column::TimeoutMs
| cron::Column::MaxAttempts | cron::Column::MaxAttempts
@@ -30,7 +31,8 @@ fn skip_columns_for_entity_input(context: &mut BuilderContext) {
context.entity_input.insert_skips.push(entity_column_key); context.entity_input.insert_skips.push(entity_column_key);
} }
for column in cron::Column::iter() { for column in cron::Column::iter() {
if matches!(column, |cron::Column::CronExpr| cron::Column::Enabled if matches!(column, |cron::Column::CronExpr| cron::Column::CronTimezone
| cron::Column::Enabled
| cron::Column::TimeoutMs | cron::Column::TimeoutMs
| cron::Column::Priority | cron::Column::Priority
| cron::Column::MaxAttempts) | cron::Column::MaxAttempts)

View File

@@ -3,6 +3,7 @@ use std::sync::Arc;
use async_graphql::dynamic::ResolverContext; use async_graphql::dynamic::ResolverContext;
use sea_orm::Value as SeaValue; use sea_orm::Value as SeaValue;
use seaography::{Builder as SeaographyBuilder, BuilderContext, SeaResult}; use seaography::{Builder as SeaographyBuilder, BuilderContext, SeaResult};
use uuid::Uuid;
use crate::{ use crate::{
graphql::{ graphql::{
@@ -35,7 +36,9 @@ pub fn register_feeds_to_schema_context(context: &mut BuilderContext) {
if field_name == entity_create_one_mutation_field_name.as_str() if field_name == entity_create_one_mutation_field_name.as_str()
|| field_name == entity_create_batch_mutation_field_name.as_str() || field_name == entity_create_batch_mutation_field_name.as_str()
{ {
Ok(Some(SeaValue::String(Some(Box::new(nanoid::nanoid!()))))) Ok(Some(SeaValue::String(Some(Box::new(
Uuid::now_v7().to_string(),
)))))
} else { } else {
Ok(None) Ok(None)
} }

View File

@@ -178,6 +178,7 @@ pub enum Cron {
SubscriberId, SubscriberId,
SubscriptionId, SubscriptionId,
CronExpr, CronExpr,
CronTimezone,
NextRun, NextRun,
LastRun, LastRun,
LastError, LastError,
@@ -317,26 +318,6 @@ pub trait CustomSchemaManagerExt {
Ok(()) Ok(())
} }
async fn create_foreign_key_if_not_exists<
T: IntoIden + 'static + Send,
S: IntoIden + 'static + Send,
>(
&self,
from_tbl: T,
foreign_key: S,
stmt: ForeignKeyCreateStatement,
) -> Result<(), DbErr>;
async fn drop_foreign_key_if_exists<
T: IntoIden + 'static + Send,
S: IntoIden + 'static + Send,
>(
&self,
from_tbl: T,
foreign_key: S,
stmt: ForeignKeyDropStatement,
) -> Result<(), DbErr>;
async fn create_postgres_enum_for_active_enum< async fn create_postgres_enum_for_active_enum<
E: IntoTypeRef + IntoIden + Send + Clone, E: IntoTypeRef + IntoIden + Send + Clone,
I: IntoIterator<Item = String> + Send, I: IntoIterator<Item = String> + Send,
@@ -423,71 +404,6 @@ impl CustomSchemaManagerExt for SchemaManager<'_> {
Ok(()) Ok(())
} }
async fn create_foreign_key_if_not_exists<
T: IntoIden + 'static + Send,
S: IntoIden + 'static + Send,
>(
&self,
from_tbl: T,
foreign_key: S,
stmt: ForeignKeyCreateStatement,
) -> Result<(), DbErr> {
let from_tbl = from_tbl.into_iden().to_string();
let foreign_key = foreign_key.into_iden().to_string();
let db = self
.get_connection()
.query_one(Statement::from_string(
self.get_database_backend(),
format!(
"
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_NAME = '{from_tbl}' AND CONSTRAINT_NAME = '{foreign_key}'
"
),
))
.await?;
if db.is_some() {
return Ok(());
}
self.create_foreign_key(stmt).await?;
Ok(())
}
async fn drop_foreign_key_if_exists<
T: IntoIden + 'static + Send,
S: IntoIden + 'static + Send,
>(
&self,
from_tbl: T,
foreign_key: S,
stmt: ForeignKeyDropStatement,
) -> Result<(), DbErr> {
let from_tbl = from_tbl.into_iden().to_string();
let foreign_key = foreign_key.into_iden().to_string();
let db = self
.get_connection()
.query_one(Statement::from_string(
self.get_database_backend(),
format!(
"
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_NAME = '{from_tbl}' AND CONSTRAINT_NAME = '{foreign_key}'
"
),
))
.await?;
if db.is_some() {
self.drop_foreign_key(stmt).await?;
}
Ok(())
}
async fn create_postgres_enum_for_active_enum< async fn create_postgres_enum_for_active_enum<
E: IntoTypeRef + IntoIden + Send + Clone, E: IntoTypeRef + IntoIden + Send + Clone,
I: IntoIterator<Item = String> + Send, I: IntoIterator<Item = String> + Send,

View File

@@ -72,22 +72,16 @@ impl MigrationTrait for Migration {
Table::alter() Table::alter()
.table(Subscriptions::Table) .table(Subscriptions::Table)
.add_column_if_not_exists(integer_null(Subscriptions::CredentialId)) .add_column_if_not_exists(integer_null(Subscriptions::CredentialId))
.to_owned(), .add_foreign_key(
) TableForeignKey::new()
.await?; .name("fk_subscriptions_credential_id")
.from_tbl(Subscriptions::Table)
manager .from_col(Subscriptions::CredentialId)
.create_foreign_key_if_not_exists( .to_tbl(Credential3rd::Table)
Subscriptions::Table, .to_col(Credential3rd::Id)
"fk_subscriptions_credential_id", .on_update(ForeignKeyAction::Cascade)
ForeignKeyCreateStatement::new() .on_delete(ForeignKeyAction::SetNull),
.name("fk_subscriptions_credential_id") )
.from_tbl(Subscriptions::Table)
.from_col(Subscriptions::CredentialId)
.to_tbl(Credential3rd::Table)
.to_col(Credential3rd::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::SetNull)
.to_owned(), .to_owned(),
) )
.await?; .await?;
@@ -101,6 +95,7 @@ impl MigrationTrait for Migration {
Table::alter() Table::alter()
.table(Subscriptions::Table) .table(Subscriptions::Table)
.drop_column(Subscriptions::CredentialId) .drop_column(Subscriptions::CredentialId)
.drop_foreign_key("fk_subscriptions_credential_id")
.to_owned(), .to_owned(),
) )
.await?; .await?;

View File

@@ -61,7 +61,7 @@ impl MigrationTrait for Migration {
)).await?; )).await?;
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$ r#"CREATE OR REPLACE FUNCTION {apalis_schema}.{SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$
DECLARE DECLARE
new_job_subscriber_id integer; new_job_subscriber_id integer;
new_job_subscription_id integer; new_job_subscription_id integer;
@@ -70,18 +70,19 @@ impl MigrationTrait for Migration {
new_job_subscriber_id = (NEW.{job} ->> '{subscriber_id}')::integer; new_job_subscriber_id = (NEW.{job} ->> '{subscriber_id}')::integer;
new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer; new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer;
new_job_task_type = (NEW.{job} ->> '{task_type}')::text; new_job_task_type = (NEW.{job} ->> '{task_type}')::text;
IF new_job_subscriber_id != (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id != NEW.{subscriber_id} THEN IF new_job_subscriber_id IS DISTINCT FROM (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id IS DISTINCT FROM NEW.{subscriber_id} THEN
NEW.{subscriber_id} = new_job_subscriber_id; NEW.{subscriber_id} = new_job_subscriber_id;
END IF; END IF;
IF new_job_subscription_id != (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id != NEW.{subscription_id} THEN IF new_job_subscription_id IS DISTINCT FROM (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id IS DISTINCT FROM NEW.{subscription_id} THEN
NEW.{subscription_id} = new_job_subscription_id; NEW.{subscription_id} = new_job_subscription_id;
END IF; END IF;
IF new_job_task_type != (OLD.{job} ->> '{task_type}')::text AND new_job_task_type != NEW.{task_type} THEN IF new_job_task_type IS DISTINCT FROM (OLD.{job} ->> '{task_type}')::text AND new_job_task_type IS DISTINCT FROM NEW.{task_type} THEN
NEW.{task_type} = new_job_task_type; NEW.{task_type} = new_job_task_type;
END IF; END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql;"#, $$ LANGUAGE plpgsql;"#,
apalis_schema = ApalisSchema::Schema.to_string(),
job = ApalisJobs::Job.to_string(), job = ApalisJobs::Job.to_string(),
subscriber_id = ApalisJobs::SubscriberId.to_string(), subscriber_id = ApalisJobs::SubscriberId.to_string(),
subscription_id = ApalisJobs::SubscriptionId.to_string(), subscription_id = ApalisJobs::SubscriptionId.to_string(),
@@ -92,7 +93,7 @@ impl MigrationTrait for Migration {
r#"CREATE OR REPLACE TRIGGER {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_TRIGGER_NAME} r#"CREATE OR REPLACE TRIGGER {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_TRIGGER_NAME}
BEFORE INSERT OR UPDATE ON {apalis_schema}.{apalis_table} BEFORE INSERT OR UPDATE ON {apalis_schema}.{apalis_table}
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}();"#, EXECUTE FUNCTION {apalis_schema}.{SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}();"#,
apalis_schema = ApalisSchema::Schema.to_string(), apalis_schema = ApalisSchema::Schema.to_string(),
apalis_table = ApalisJobs::Table.to_string() apalis_table = ApalisJobs::Table.to_string()
)) ))
@@ -198,7 +199,8 @@ impl MigrationTrait for Migration {
)).await?; )).await?;
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
r#"DROP FUNCTION IF EXISTS {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}()"#, r#"DROP FUNCTION IF EXISTS {apalis_schema}.{SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}()"#,
apalis_schema = ApalisSchema::Schema.to_string(),
)) ))
.await?; .await?;

View File

@@ -15,6 +15,8 @@ pub struct Migration;
#[async_trait] #[async_trait]
impl MigrationTrait for Migration { impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
create_postgres_enum_for_active_enum!(manager, EpisodeTypeEnum, EpisodeType::Mikan).await?; create_postgres_enum_for_active_enum!(manager, EpisodeTypeEnum, EpisodeType::Mikan).await?;
{ {
@@ -29,11 +31,17 @@ impl MigrationTrait for Migration {
BangumiTypeEnum, BangumiTypeEnum,
BangumiType::iden_values(), BangumiType::iden_values(),
)) ))
.drop_column(Bangumi::SavePath)
.to_owned(), .to_owned(),
) )
.await?; .await?;
db.execute_unprepared(&format!(
r#"ALTER TABLE {bangumi} DROP COLUMN IF EXISTS {save_path}"#,
bangumi = Bangumi::Table.to_string(),
save_path = Bangumi::SavePath.to_string(),
))
.await?;
manager manager
.exec_stmt( .exec_stmt(
UpdateStatement::new() UpdateStatement::new()
@@ -83,11 +91,17 @@ impl MigrationTrait for Migration {
.add_column_if_not_exists(big_integer_null( .add_column_if_not_exists(big_integer_null(
Episodes::EnclosureContentLength, Episodes::EnclosureContentLength,
)) ))
.drop_column(Episodes::SavePath)
.to_owned(), .to_owned(),
) )
.await?; .await?;
db.execute_unprepared(&format!(
r#"ALTER TABLE {episodes} DROP COLUMN IF EXISTS {save_path}"#,
episodes = Episodes::Table.to_string(),
save_path = Episodes::SavePath.to_string(),
))
.await?;
manager manager
.exec_stmt( .exec_stmt(
UpdateStatement::new() UpdateStatement::new()
@@ -120,10 +134,34 @@ impl MigrationTrait for Migration {
} }
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Bangumi::Table)
.add_column_if_not_exists(text_null(Bangumi::SavePath))
.drop_column(Bangumi::BangumiType)
.to_owned(),
)
.await?;
manager manager
.drop_postgres_enum_for_active_enum(BangumiTypeEnum) .drop_postgres_enum_for_active_enum(BangumiTypeEnum)
.await?; .await?;
manager
.alter_table(
Table::alter()
.table(Episodes::Table)
.add_column_if_not_exists(text_null(Episodes::SavePath))
.drop_column(Episodes::EpisodeType)
.drop_column(Episodes::EnclosureMagnetLink)
.drop_column(Episodes::EnclosureTorrentLink)
.drop_column(Episodes::EnclosurePubDate)
.drop_column(Episodes::EnclosureContentLength)
.to_owned(),
)
.await?;
manager manager
.drop_postgres_enum_for_active_enum(EpisodeTypeEnum) .drop_postgres_enum_for_active_enum(EpisodeTypeEnum)
.await?; .await?;

View File

@@ -8,9 +8,10 @@ use crate::{
Subscriptions, table_auto_z, Subscriptions, table_auto_z,
}, },
models::cron::{ models::cron::{
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_EVENT, CronStatus, CronStatusEnum, CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT,
NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME, CronStatus, CronStatusEnum, NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME,
SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME,
SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
}, },
task::{ task::{
SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SUBSCRIBER_TASK_APALIS_NAME, SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SUBSCRIBER_TASK_APALIS_NAME,
@@ -30,7 +31,8 @@ impl MigrationTrait for Migration {
CronStatus::Pending, CronStatus::Pending,
CronStatus::Running, CronStatus::Running,
CronStatus::Completed, CronStatus::Completed,
CronStatus::Failed CronStatus::Failed,
CronStatus::Disabled
) )
.await?; .await?;
@@ -39,6 +41,7 @@ impl MigrationTrait for Migration {
table_auto_z(Cron::Table) table_auto_z(Cron::Table)
.col(pk_auto(Cron::Id)) .col(pk_auto(Cron::Id))
.col(string(Cron::CronExpr)) .col(string(Cron::CronExpr))
.col(string(Cron::CronTimezone))
.col(integer_null(Cron::SubscriberId)) .col(integer_null(Cron::SubscriberId))
.col(integer_null(Cron::SubscriptionId)) .col(integer_null(Cron::SubscriptionId))
.col(timestamp_with_time_zone_null(Cron::NextRun)) .col(timestamp_with_time_zone_null(Cron::NextRun))
@@ -47,15 +50,14 @@ impl MigrationTrait for Migration {
.col(boolean(Cron::Enabled).default(true)) .col(boolean(Cron::Enabled).default(true))
.col(string_null(Cron::LockedBy)) .col(string_null(Cron::LockedBy))
.col(timestamp_with_time_zone_null(Cron::LockedAt)) .col(timestamp_with_time_zone_null(Cron::LockedAt))
.col(integer_null(Cron::TimeoutMs)) .col(integer_null(Cron::TimeoutMs).default(5000))
.col(integer(Cron::Attempts)) .col(integer(Cron::Attempts).default(0))
.col(integer(Cron::MaxAttempts)) .col(integer(Cron::MaxAttempts).default(1))
.col(integer(Cron::Priority)) .col(integer(Cron::Priority).default(0))
.col(enumeration( .col(
Cron::Status, enumeration(Cron::Status, CronStatusEnum, CronStatus::iden_values())
CronStatusEnum, .default(CronStatus::Pending),
CronStatus::iden_values(), )
))
.col(json_binary_null(Cron::SubscriberTaskCron)) .col(json_binary_null(Cron::SubscriberTaskCron))
.col(json_binary_null(Cron::SystemTaskCron)) .col(json_binary_null(Cron::SystemTaskCron))
.foreign_key( .foreign_key(
@@ -105,13 +107,13 @@ impl MigrationTrait for Migration {
new_subscriber_task_subscriber_id = (NEW.{subscriber_task_cron} ->> 'subscriber_id')::integer; new_subscriber_task_subscriber_id = (NEW.{subscriber_task_cron} ->> 'subscriber_id')::integer;
new_subscriber_task_subscription_id = (NEW.{subscriber_task_cron} ->> 'subscription_id')::integer; new_subscriber_task_subscription_id = (NEW.{subscriber_task_cron} ->> 'subscription_id')::integer;
new_system_task_subscriber_id = (NEW.{system_task_cron} ->> 'subscriber_id')::integer; new_system_task_subscriber_id = (NEW.{system_task_cron} ->> 'subscriber_id')::integer;
IF new_subscriber_task_subscriber_id != (OLD.{subscriber_task_cron} ->> 'subscriber_id')::integer AND new_subscriber_task_subscriber_id != NEW.{subscriber_id} THEN IF new_subscriber_task_subscriber_id IS DISTINCT FROM (OLD.{subscriber_task_cron} ->> 'subscriber_id')::integer AND new_subscriber_task_subscriber_id IS DISTINCT FROM NEW.{subscriber_id} THEN
NEW.{subscriber_id} = new_subscriber_task_subscriber_id; NEW.{subscriber_id} = new_subscriber_task_subscriber_id;
END IF; END IF;
IF new_subscriber_task_subscription_id != (OLD.{subscriber_task_cron} ->> 'subscription_id')::integer AND new_subscriber_task_subscription_id != NEW.{subscription_id} THEN IF new_subscriber_task_subscription_id IS DISTINCT FROM (OLD.{subscriber_task_cron} ->> 'subscription_id')::integer AND new_subscriber_task_subscription_id IS DISTINCT FROM NEW.{subscription_id} THEN
NEW.{subscription_id} = new_subscriber_task_subscription_id; NEW.{subscription_id} = new_subscriber_task_subscription_id;
END IF; END IF;
IF new_system_task_subscriber_id != (OLD.{system_task_cron} ->> 'subscriber_id')::integer AND new_system_task_subscriber_id != NEW.{subscriber_id} THEN IF new_system_task_subscriber_id IS DISTINCT FROM (OLD.{system_task_cron} ->> 'subscriber_id')::integer AND new_system_task_subscriber_id IS DISTINCT FROM NEW.{subscriber_id} THEN
NEW.{subscriber_id} = new_system_task_subscriber_id; NEW.{subscriber_id} = new_system_task_subscriber_id;
END IF; END IF;
RETURN NEW; RETURN NEW;
@@ -139,7 +141,7 @@ impl MigrationTrait for Migration {
IF NEW.{next_run} IS NOT NULL IF NEW.{next_run} IS NOT NULL
AND NEW.{next_run} <= CURRENT_TIMESTAMP AND NEW.{next_run} <= CURRENT_TIMESTAMP
AND NEW.{enabled} = true AND NEW.{enabled} = true
AND NEW.{status} = '{pending}' AND NEW.{status} = '{pending}'::{status_type}
AND NEW.{attempts} < NEW.{max_attempts} AND NEW.{attempts} < NEW.{max_attempts}
-- Check if not locked or lock timeout -- Check if not locked or lock timeout
AND ( AND (
@@ -154,8 +156,8 @@ impl MigrationTrait for Migration {
OLD.{next_run} IS NULL OLD.{next_run} IS NULL
OR OLD.{next_run} > CURRENT_TIMESTAMP OR OLD.{next_run} > CURRENT_TIMESTAMP
OR OLD.{enabled} = false OR OLD.{enabled} = false
OR OLD.{status} != '{pending}' OR OLD.{status} IS DISTINCT FROM '{pending}'
OR OLD.{attempts} != NEW.{attempts} OR OLD.{attempts} IS DISTINCT FROM NEW.{attempts}
) )
THEN THEN
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(NEW)::text); PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(NEW)::text);
@@ -171,6 +173,7 @@ impl MigrationTrait for Migration {
pending = &CronStatus::Pending.to_value(), pending = &CronStatus::Pending.to_value(),
attempts = &Cron::Attempts.to_string(), attempts = &Cron::Attempts.to_string(),
max_attempts = &Cron::MaxAttempts.to_string(), max_attempts = &Cron::MaxAttempts.to_string(),
status_type = &CronStatus::name().to_string(),
)) ))
.await?; .await?;
@@ -194,7 +197,7 @@ impl MigrationTrait for Migration {
WHERE {next_run} IS NOT NULL WHERE {next_run} IS NOT NULL
AND {next_run} <= CURRENT_TIMESTAMP AND {next_run} <= CURRENT_TIMESTAMP
AND {enabled} = true AND {enabled} = true
AND {status} = '{pending}' AND {status} = '{pending}'::{status_type}
AND {attempts} < {max_attempts} AND {attempts} < {max_attempts}
AND ( AND (
{locked_at} IS NULL {locked_at} IS NULL
@@ -206,9 +209,12 @@ impl MigrationTrait for Migration {
ORDER BY {priority} ASC, {next_run} ASC ORDER BY {priority} ASC, {next_run} ASC
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
LOOP LOOP
-- PERFORM pg_notify('{CRON_DUE_DEBUG_EVENT}',format('Found due cron: value=%s; Now time: %s', row_to_json(cron_record)::text, CURRENT_TIMESTAMP));
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(cron_record)::text); PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(cron_record)::text);
notification_count := notification_count + 1; notification_count := notification_count + 1;
END LOOP; END LOOP;
-- PERFORM pg_notify('{CRON_DUE_DEBUG_EVENT}', format('Notification count: %I; Now time: %s', notification_count, CURRENT_TIMESTAMP));
RETURN notification_count; RETURN notification_count;
END; END;
$$ LANGUAGE plpgsql;"#, $$ LANGUAGE plpgsql;"#,
@@ -222,6 +228,7 @@ impl MigrationTrait for Migration {
priority = &Cron::Priority.to_string(), priority = &Cron::Priority.to_string(),
attempts = &Cron::Attempts.to_string(), attempts = &Cron::Attempts.to_string(),
max_attempts = &Cron::MaxAttempts.to_string(), max_attempts = &Cron::MaxAttempts.to_string(),
status_type = &CronStatus::name().to_string(),
)) ))
.await?; .await?;
@@ -237,8 +244,8 @@ impl MigrationTrait for Migration {
.from_col(ApalisJobs::CronId) .from_col(ApalisJobs::CronId)
.to_tbl(Cron::Table) .to_tbl(Cron::Table)
.to_col(Cron::Id) .to_col(Cron::Id)
.on_delete(ForeignKeyAction::NoAction) .on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::NoAction), .on_update(ForeignKeyAction::Restrict),
) )
.to_owned(), .to_owned(),
) )
@@ -341,7 +348,7 @@ impl MigrationTrait for Migration {
.await?; .await?;
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$ r#"CREATE OR REPLACE FUNCTION {apalis_schema}.{SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$
DECLARE DECLARE
new_job_subscriber_id integer; new_job_subscriber_id integer;
new_job_subscription_id integer; new_job_subscription_id integer;
@@ -352,21 +359,22 @@ impl MigrationTrait for Migration {
new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer; new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer;
new_job_cron_id = (NEW.{job} ->> '{cron_id}')::integer; new_job_cron_id = (NEW.{job} ->> '{cron_id}')::integer;
new_job_task_type = (NEW.{job} ->> '{task_type}')::text; new_job_task_type = (NEW.{job} ->> '{task_type}')::text;
IF new_job_subscriber_id != (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id != NEW.{subscriber_id} THEN IF new_job_subscriber_id IS DISTINCT FROM (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id IS DISTINCT FROM NEW.{subscriber_id} THEN
NEW.{subscriber_id} = new_job_subscriber_id; NEW.{subscriber_id} = new_job_subscriber_id;
END IF; END IF;
IF new_job_subscription_id != (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id != NEW.{subscription_id} THEN IF new_job_subscription_id IS DISTINCT FROM (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id IS DISTINCT FROM NEW.{subscription_id} THEN
NEW.{subscription_id} = new_job_subscription_id; NEW.{subscription_id} = new_job_subscription_id;
END IF; END IF;
IF new_job_cron_id != (OLD.{job} ->> '{cron_id}')::integer AND new_job_cron_id != NEW.{cron_id} THEN IF new_job_cron_id IS DISTINCT FROM (OLD.{job} ->> '{cron_id}')::integer AND new_job_cron_id IS DISTINCT FROM NEW.{cron_id} THEN
NEW.{cron_id} = new_job_cron_id; NEW.{cron_id} = new_job_cron_id;
END IF; END IF;
IF new_job_task_type != (OLD.{job} ->> '{task_type}')::text AND new_job_task_type != NEW.{task_type} THEN IF new_job_task_type IS DISTINCT FROM (OLD.{job} ->> '{task_type}')::text AND new_job_task_type IS DISTINCT FROM NEW.{task_type} THEN
NEW.{task_type} = new_job_task_type; NEW.{task_type} = new_job_task_type;
END IF; END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql;"#, $$ LANGUAGE plpgsql;"#,
apalis_schema = ApalisSchema::Schema.to_string(),
job = ApalisJobs::Job.to_string(), job = ApalisJobs::Job.to_string(),
subscriber_id = ApalisJobs::SubscriberId.to_string(), subscriber_id = ApalisJobs::SubscriberId.to_string(),
subscription_id = ApalisJobs::SubscriptionId.to_string(), subscription_id = ApalisJobs::SubscriptionId.to_string(),
@@ -381,7 +389,7 @@ impl MigrationTrait for Migration {
let db = manager.get_connection(); let db = manager.get_connection();
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$ r#"CREATE OR REPLACE FUNCTION {apalis_schema}.{SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME}() RETURNS trigger AS $$
DECLARE DECLARE
new_job_subscriber_id integer; new_job_subscriber_id integer;
new_job_subscription_id integer; new_job_subscription_id integer;
@@ -390,18 +398,19 @@ impl MigrationTrait for Migration {
new_job_subscriber_id = (NEW.{job} ->> '{subscriber_id}')::integer; new_job_subscriber_id = (NEW.{job} ->> '{subscriber_id}')::integer;
new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer; new_job_subscription_id = (NEW.{job} ->> '{subscription_id}')::integer;
new_job_task_type = (NEW.{job} ->> '{task_type}')::text; new_job_task_type = (NEW.{job} ->> '{task_type}')::text;
IF new_job_subscriber_id != (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id != NEW.{subscriber_id} THEN IF new_job_subscriber_id IS DISTINCT FROM (OLD.{job} ->> '{subscriber_id}')::integer AND new_job_subscriber_id IS DISTINCT FROM NEW.{subscriber_id} THEN
NEW.{subscriber_id} = new_job_subscriber_id; NEW.{subscriber_id} = new_job_subscriber_id;
END IF; END IF;
IF new_job_subscription_id != (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id != NEW.{subscription_id} THEN IF new_job_subscription_id IS DISTINCT FROM (OLD.{job} ->> '{subscription_id}')::integer AND new_job_subscription_id IS DISTINCT FROM NEW.{subscription_id} THEN
NEW.{subscription_id} = new_job_subscription_id; NEW.{subscription_id} = new_job_subscription_id;
END IF; END IF;
IF new_job_task_type != (OLD.{job} ->> '{task_type}')::text AND new_job_task_type != NEW.{task_type} THEN IF new_job_task_type IS DISTINCT FROM (OLD.{job} ->> '{task_type}')::text AND new_job_task_type IS DISTINCT FROM NEW.{task_type} THEN
NEW.{task_type} = new_job_task_type; NEW.{task_type} = new_job_task_type;
END IF; END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql;"#, $$ LANGUAGE plpgsql;"#,
apalis_schema = ApalisSchema::Schema.to_string(),
job = ApalisJobs::Job.to_string(), job = ApalisJobs::Job.to_string(),
subscriber_id = ApalisJobs::SubscriberId.to_string(), subscriber_id = ApalisJobs::SubscriberId.to_string(),
subscription_id = ApalisJobs::SubscriptionId.to_string(), subscription_id = ApalisJobs::SubscriptionId.to_string(),

View File

@@ -1,4 +1,5 @@
pub const CRON_DUE_EVENT: &str = "cron_due"; pub const CRON_DUE_EVENT: &str = "cron_due";
pub const CRON_DUE_DEBUG_EVENT: &str = "cron_due_debug";
pub const CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME: &str = "check_and_trigger_due_crons"; pub const CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME: &str = "check_and_trigger_due_crons";

View File

@@ -1,13 +1,14 @@
mod core; mod core;
pub use core::{ pub use core::{
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_EVENT, CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT,
NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use croner::Cron; use croner::Cron;
use sea_orm::{ use sea_orm::{
ActiveValue::{self, Set}, ActiveValue::{self, Set},
@@ -40,6 +41,8 @@ pub enum CronStatus {
Completed, Completed,
#[sea_orm(string_value = "failed")] #[sea_orm(string_value = "failed")]
Failed, Failed,
#[sea_orm(string_value = "disabled")]
Disabled,
} }
#[derive(Debug, Clone, DeriveEntityModel, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, DeriveEntityModel, PartialEq, Serialize, Deserialize)]
@@ -54,13 +57,14 @@ pub struct Model {
pub subscriber_id: Option<i32>, pub subscriber_id: Option<i32>,
pub subscription_id: Option<i32>, pub subscription_id: Option<i32>,
pub cron_expr: String, pub cron_expr: String,
pub cron_timezone: String,
pub next_run: Option<DateTimeUtc>, pub next_run: Option<DateTimeUtc>,
pub last_run: Option<DateTimeUtc>, pub last_run: Option<DateTimeUtc>,
pub last_error: Option<String>, pub last_error: Option<String>,
pub locked_by: Option<String>, pub locked_by: Option<String>,
pub locked_at: Option<DateTimeUtc>, pub locked_at: Option<DateTimeUtc>,
#[sea_orm(default_expr = "5000")] // default_expr = "5000"
pub timeout_ms: i32, pub timeout_ms: Option<i32>,
#[sea_orm(default_expr = "0")] #[sea_orm(default_expr = "0")]
pub attempts: i32, pub attempts: i32,
#[sea_orm(default_expr = "1")] #[sea_orm(default_expr = "1")]
@@ -136,20 +140,41 @@ pub enum RelatedEntity {
#[async_trait] #[async_trait]
impl ActiveModelBehavior for ActiveModel { impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(mut self, _db: &C, _insert: bool) -> Result<Self, DbErr> async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
where where
C: ConnectionTrait, C: ConnectionTrait,
{ {
if let ActiveValue::Set(ref cron_expr) = self.cron_expr match (
&& matches!( &self.cron_expr as &ActiveValue<String>,
self.next_run, &self.cron_timezone as &ActiveValue<String>,
ActiveValue::NotSet | ActiveValue::Unchanged(_) ) {
) (ActiveValue::Set(cron_expr), ActiveValue::Set(timezone)) => {
{ if matches!(
let next_run = &self.next_run,
Model::calculate_next_run(cron_expr).map_err(|e| DbErr::Custom(e.to_string()))?; ActiveValue::NotSet | ActiveValue::Unchanged(_)
self.next_run = Set(Some(next_run)); ) {
} let next_run = Model::calculate_next_run(cron_expr, timezone)
.map_err(|e| DbErr::Custom(e.to_string()))?;
self.next_run = Set(Some(next_run));
}
}
(
ActiveValue::Unchanged(_) | ActiveValue::NotSet,
ActiveValue::Unchanged(_) | ActiveValue::NotSet,
) => {}
(_, _) => {
if matches!(
self.next_run,
ActiveValue::NotSet | ActiveValue::Unchanged(_)
) {
return Err(DbErr::Custom(
"Cron expr and timezone must be insert or update at same time when next \
run is not set"
.to_string(),
));
}
}
};
if let ActiveValue::Set(Some(subscriber_id)) = self.subscriber_id if let ActiveValue::Set(Some(subscriber_id)) = self.subscriber_id
&& let ActiveValue::Set(Some(ref subscriber_task)) = self.subscriber_task_cron && let ActiveValue::Set(Some(ref subscriber_task)) = self.subscriber_task_cron
&& subscriber_task.get_subscriber_id() != subscriber_id && subscriber_task.get_subscriber_id() != subscriber_id
@@ -166,6 +191,15 @@ impl ActiveModelBehavior for ActiveModel {
"Cron subscriber_id does not match system_task_cron.subscriber_id".to_string(), "Cron subscriber_id does not match system_task_cron.subscriber_id".to_string(),
)); ));
} }
if let ActiveValue::Set(enabled) = self.enabled
&& !insert
{
if enabled {
self.status = Set(CronStatus::Pending)
} else {
self.status = Set(CronStatus::Disabled)
}
}
Ok(self) Ok(self)
} }
@@ -223,7 +257,10 @@ impl Model {
&& cron.attempts < cron.max_attempts && cron.attempts < cron.max_attempts
&& cron.status == CronStatus::Pending && cron.status == CronStatus::Pending
&& (cron.locked_at.is_none_or(|locked_at| { && (cron.locked_at.is_none_or(|locked_at| {
locked_at + chrono::Duration::milliseconds(cron.timeout_ms as i64) <= Utc::now() cron.timeout_ms.is_some_and(|cron_timeout_ms| {
locked_at + chrono::Duration::milliseconds(cron_timeout_ms as i64)
<= Utc::now()
})
})) }))
&& cron.next_run.is_some_and(|next_run| next_run <= Utc::now()) && cron.next_run.is_some_and(|next_run| next_run <= Utc::now())
{ {
@@ -269,7 +306,7 @@ impl Model {
async fn mark_cron_completed(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> { async fn mark_cron_completed(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> {
let db = ctx.db(); let db = ctx.db();
let next_run = Self::calculate_next_run(&self.cron_expr)?; let next_run = Self::calculate_next_run(&self.cron_expr, &self.cron_timezone)?;
ActiveModel { ActiveModel {
id: Set(self.id), id: Set(self.id),
@@ -307,7 +344,10 @@ impl Model {
let next_run = if should_retry { let next_run = if should_retry {
Some(Utc::now() + retry_duration) Some(Utc::now() + retry_duration)
} else { } else {
Some(Self::calculate_next_run(&self.cron_expr)?) Some(Self::calculate_next_run(
&self.cron_expr,
&self.cron_timezone,
)?)
}; };
ActiveModel { ActiveModel {
@@ -376,7 +416,15 @@ impl Model {
locked_cron locked_cron
.mark_cron_failed( .mark_cron_failed(
ctx, ctx,
format!("Cron timeout of {}ms", locked_cron.timeout_ms).as_str(), format!(
"Cron timeout of {}ms",
locked_cron
.timeout_ms
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "Infinite".to_string())
)
.as_str(),
retry_duration, retry_duration,
) )
.await?; .await?;
@@ -388,11 +436,17 @@ impl Model {
Ok(()) Ok(())
} }
pub fn calculate_next_run(cron_expr: &str) -> RecorderResult<DateTime<Utc>> { pub fn calculate_next_run(cron_expr: &str, timezone: &str) -> RecorderResult<DateTime<Utc>> {
let cron_expr = Cron::new(cron_expr).parse()?; let user_tz = timezone.parse::<Tz>()?;
let next = cron_expr.find_next_occurrence(&Utc::now(), false)?; let user_tz_now = Utc::now().with_timezone(&user_tz);
Ok(next) let cron_expr = Cron::new(cron_expr).with_seconds_optional().parse()?;
let next = cron_expr.find_next_occurrence(&user_tz_now, false)?;
let next_utc = next.with_timezone(&Utc);
Ok(next_utc)
} }
} }

View File

@@ -102,7 +102,7 @@ impl ActiveModelBehavior for ActiveModel {
C: ConnectionTrait, C: ConnectionTrait,
{ {
if insert && let ActiveValue::NotSet = self.token { if insert && let ActiveValue::NotSet = self.token {
let token = nanoid::nanoid!(10); let token = Uuid::now_v7().to_string();
self.token = ActiveValue::Set(token); self.token = ActiveValue::Set(token);
} }
Ok(self) Ok(self)

View File

@@ -52,23 +52,23 @@ pub enum Relation {
from = "Column::SubscriberId", from = "Column::SubscriberId",
to = "super::subscribers::Column::Id", to = "super::subscribers::Column::Id",
on_update = "Cascade", on_update = "Cascade",
on_delete = "NoAction" on_delete = "Restrict"
)] )]
Subscriber, Subscriber,
#[sea_orm( #[sea_orm(
belongs_to = "super::subscriptions::Entity", belongs_to = "super::subscriptions::Entity",
from = "Column::SubscriptionId", from = "Column::SubscriptionId",
to = "super::subscriptions::Column::Id", to = "super::subscriptions::Column::Id",
on_update = "NoAction", on_update = "Cascade",
on_delete = "NoAction" on_delete = "Restrict"
)] )]
Subscription, Subscription,
#[sea_orm( #[sea_orm(
belongs_to = "super::cron::Entity", belongs_to = "super::cron::Entity",
from = "Column::CronId", from = "Column::CronId",
to = "super::cron::Column::Id", to = "super::cron::Column::Id",
on_update = "NoAction", on_update = "Cascade",
on_delete = "NoAction" on_delete = "Restrict"
)] )]
Cron, Cron,
} }

View File

@@ -60,6 +60,8 @@ pub enum Relation {
Feed, Feed,
#[sea_orm(has_many = "super::subscriber_tasks::Entity")] #[sea_orm(has_many = "super::subscriber_tasks::Entity")]
SubscriberTask, SubscriberTask,
#[sea_orm(has_many = "super::cron::Entity")]
Cron,
} }
impl Related<super::subscribers::Entity> for Entity { impl Related<super::subscribers::Entity> for Entity {
@@ -126,6 +128,12 @@ impl Related<super::subscriber_tasks::Entity> for Entity {
} }
} }
impl Related<super::cron::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cron.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
pub enum RelatedEntity { pub enum RelatedEntity {
#[sea_orm(entity = "super::subscribers::Entity")] #[sea_orm(entity = "super::subscribers::Entity")]
@@ -144,6 +152,8 @@ pub enum RelatedEntity {
Feed, Feed,
#[sea_orm(entity = "super::subscriber_tasks::Entity")] #[sea_orm(entity = "super::subscriber_tasks::Entity")]
SubscriberTask, SubscriberTask,
#[sea_orm(entity = "super::cron::Entity")]
Cron,
} }
#[async_trait] #[async_trait]

View File

@@ -57,8 +57,8 @@ pub enum Relation {
belongs_to = "super::cron::Entity", belongs_to = "super::cron::Entity",
from = "Column::CronId", from = "Column::CronId",
to = "super::cron::Column::Id", to = "super::cron::Column::Id",
on_update = "NoAction", on_update = "Cascade",
on_delete = "NoAction" on_delete = "Restrict"
)] )]
Cron, Cron,
} }

View File

@@ -278,7 +278,7 @@ impl StorageService {
if let Some(mut ranges) = ranges { if let Some(mut ranges) = ranges {
if ranges.len() > 1 { if ranges.len() > 1 {
let boundary = Uuid::new_v4().to_string(); let boundary = Uuid::now_v7().to_string();
let reader = self.reader(storage_path.as_ref()).await?; let reader = self.reader(storage_path.as_ref()).await?;
let stream: impl Stream<Item = Result<Bytes, RecorderError>> = { let stream: impl Stream<Item = Result<Bytes, RecorderError>> = {
let boundary = boundary.clone(); let boundary = boundary.clone();

View File

@@ -14,6 +14,8 @@ pub struct TaskConfig {
pub system_task_reenqueue_orphaned_after: Duration, pub system_task_reenqueue_orphaned_after: Duration,
#[serde(default = "default_cron_retry_duration")] #[serde(default = "default_cron_retry_duration")]
pub cron_retry_duration: Duration, pub cron_retry_duration: Duration,
#[serde(default = "default_cron_interval_duration")]
pub cron_interval_duration: Duration,
} }
impl Default for TaskConfig { impl Default for TaskConfig {
@@ -25,6 +27,7 @@ impl Default for TaskConfig {
default_subscriber_task_reenqueue_orphaned_after(), default_subscriber_task_reenqueue_orphaned_after(),
system_task_reenqueue_orphaned_after: default_system_task_reenqueue_orphaned_after(), system_task_reenqueue_orphaned_after: default_system_task_reenqueue_orphaned_after(),
cron_retry_duration: default_cron_retry_duration(), cron_retry_duration: default_cron_retry_duration(),
cron_interval_duration: default_cron_interval_duration(),
} }
} }
} }
@@ -45,6 +48,10 @@ pub fn default_system_task_workers() -> u32 {
} }
} }
pub fn default_cron_interval_duration() -> Duration {
Duration::from_secs(30)
}
pub fn default_subscriber_task_reenqueue_orphaned_after() -> Duration { pub fn default_subscriber_task_reenqueue_orphaned_after() -> Duration {
Duration::from_secs(3600) Duration::from_secs(3600)
} }

View File

@@ -11,7 +11,7 @@ pub use core::{
pub use config::TaskConfig; pub use config::TaskConfig;
pub use registry::{ pub use registry::{
OptimizeImageTask, SubscriberTask, SubscriberTaskInput, SubscriberTaskType, EchoTask, OptimizeImageTask, SubscriberTask, SubscriberTaskInput, SubscriberTaskType,
SubscriberTaskTypeEnum, SubscriberTaskTypeVariant, SubscriberTaskTypeVariantIter, SubscriberTaskTypeEnum, SubscriberTaskTypeVariant, SubscriberTaskTypeVariantIter,
SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask, SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask,
SyncOneSubscriptionSourcesTask, SystemTask, SystemTaskInput, SystemTaskType, SyncOneSubscriptionSourcesTask, SystemTask, SystemTaskInput, SystemTaskType,

View File

@@ -9,6 +9,6 @@ pub use subscriber::{
}; };
pub(crate) use system::register_system_task_type; pub(crate) use system::register_system_task_type;
pub use system::{ pub use system::{
OptimizeImageTask, SystemTask, SystemTaskInput, SystemTaskType, SystemTaskTypeEnum, EchoTask, OptimizeImageTask, SystemTask, SystemTaskInput, SystemTaskType, SystemTaskTypeEnum,
SystemTaskTypeVariant, SystemTaskTypeVariantIter, SystemTaskTypeVariant, SystemTaskTypeVariantIter,
}; };

View File

@@ -0,0 +1,29 @@
use std::sync::Arc;
use chrono::Utc;
use crate::{
app::AppContextTrait,
errors::RecorderResult,
task::{AsyncTaskTrait, register_system_task_type},
};
register_system_task_type! {
#[derive(Debug, Clone, PartialEq)]
pub struct EchoTask {
pub task_id: String,
}
}
#[async_trait::async_trait]
impl AsyncTaskTrait for EchoTask {
async fn run_async(self, _ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
tracing::info!(
"EchoTask {} start running at {}",
self.task_id,
Utc::now().to_rfc3339()
);
Ok(())
}
}

View File

@@ -1,8 +1,10 @@
mod base; mod base;
mod media; mod media;
mod misc;
pub(crate) use base::register_system_task_type; pub(crate) use base::register_system_task_type;
pub use media::OptimizeImageTask; pub use media::OptimizeImageTask;
pub use misc::EchoTask;
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult}; use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult};
macro_rules! register_system_task_types { macro_rules! register_system_task_types {
@@ -131,30 +133,6 @@ macro_rules! register_system_task_types {
}; };
} }
#[cfg(not(any(test, feature = "test-utils")))]
register_system_task_types! {
task_type_enum: {
#[derive(
Clone,
Debug,
Copy,
DeriveActiveEnum,
DeriveDisplay,
EnumIter,
)]
pub enum SystemTaskType {
OptimizeImage => "optimize_image"
}
},
task_enum: {
#[derive(Clone, Debug, FromJsonQueryResult)]
pub enum SystemTask {
OptimizeImage(OptimizeImageTask)
}
}
}
#[cfg(any(test, feature = "test-utils"))]
register_system_task_types! { register_system_task_types! {
task_type_enum: { task_type_enum: {
#[derive( #[derive(
@@ -174,7 +152,7 @@ register_system_task_types! {
#[derive(Clone, Debug, FromJsonQueryResult)] #[derive(Clone, Debug, FromJsonQueryResult)]
pub enum SystemTask { pub enum SystemTask {
OptimizeImage(OptimizeImageTask), OptimizeImage(OptimizeImageTask),
Test(crate::test_utils::task::TestSystemTask), Echo(EchoTask),
} }
} }
} }

View File

@@ -6,13 +6,14 @@ use apalis_sql::{
context::SqlContext, context::SqlContext,
postgres::{PgListen as ApalisPgListen, PostgresStorage as ApalisPostgresStorage}, postgres::{PgListen as ApalisPgListen, PostgresStorage as ApalisPostgresStorage},
}; };
use sea_orm::sqlx::postgres::PgListener; use sea_orm::{ActiveModelTrait, sqlx::postgres::PgListener};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::{RecorderError, RecorderResult}, errors::{RecorderError, RecorderResult},
models::cron::{self, CRON_DUE_EVENT}, models::cron::{self, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT},
task::{ task::{
AsyncTaskTrait, SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask, AsyncTaskTrait, SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask,
TaskConfig, TaskConfig,
@@ -53,7 +54,7 @@ impl TaskService {
Ok(Self { Ok(Self {
config, config,
cron_worker_id: nanoid::nanoid!(), cron_worker_id: Uuid::now_v7().to_string(),
ctx, ctx,
subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)), subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)),
system_task_storage: Arc::new(RwLock::new(system_task_storage)), system_task_storage: Arc::new(RwLock::new(system_task_storage)),
@@ -136,83 +137,110 @@ impl TaskService {
Ok(task_id) Ok(task_id)
} }
pub async fn run<F, Fut>(&self, shutdown_signal: Option<F>) -> RecorderResult<()> pub async fn add_subscriber_task_cron(
&self,
cm: cron::ActiveModel,
) -> RecorderResult<cron::Model> {
let db = self.ctx.db();
let m = cm.insert(db).await?;
Ok(m)
}
pub async fn add_system_task_cron(&self, cm: cron::ActiveModel) -> RecorderResult<cron::Model> {
let db = self.ctx.db();
let m = cm.insert(db).await?;
Ok(m)
}
pub async fn run(&self) -> RecorderResult<()> {
self.run_with_signal(None::<fn() -> std::future::Ready<()>>)
.await
}
pub async fn run_with_signal<F, Fut>(&self, shutdown_signal: Option<F>) -> RecorderResult<()>
where where
F: Fn() -> Fut + Send + 'static, F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send, Fut: Future<Output = ()> + Send,
{ {
tokio::try_join!( tokio::select! {
async { _ = {
let monitor = self.setup_apalis_monitor().await?; let monitor = self.setup_apalis_monitor().await?;
if let Some(shutdown_signal) = shutdown_signal { async move {
monitor if let Some(shutdown_signal) = shutdown_signal {
.run_with_signal(async move { monitor
shutdown_signal().await; .run_with_signal(async move {
tracing::info!("apalis shutting down..."); shutdown_signal().await;
Ok(()) tracing::info!("apalis shutting down...");
}) Ok(())
.await?; })
} else { .await?;
monitor.run().await?; } else {
monitor.run().await?;
}
Ok::<_, RecorderError>(())
} }
Ok::<_, RecorderError>(()) } => {}
}, _ = {
async {
let listener = self.setup_apalis_listener().await?; let listener = self.setup_apalis_listener().await?;
tokio::task::spawn(async move { async move {
if let Err(e) = listener.listen().await { if let Err(e) = listener.listen().await {
tracing::error!("Error listening to apalis: {e}"); tracing::error!("Error listening to apalis: {e}");
} }
}); Ok::<_, RecorderError>(())
Ok::<_, RecorderError>(()) }
}, } => {},
async { _ = {
let listener = self.setup_cron_due_listening().await?; let mut listener = self.setup_cron_due_listening().await?;
let ctx = self.ctx.clone();
let cron_worker_id = self.cron_worker_id.clone(); let cron_worker_id = self.cron_worker_id.clone();
let retry_duration = chrono::Duration::milliseconds( let retry_duration =
self.config.cron_retry_duration.as_millis() as i64, chrono::Duration::milliseconds(self.config.cron_retry_duration.as_millis() as i64);
); let cron_interval_duration = self.config.cron_interval_duration;
async move {
listener.listen_all([CRON_DUE_EVENT as &str, CRON_DUE_DEBUG_EVENT as &str]).await?;
tokio::task::spawn(async move { tokio::join!(
if let Err(e) =
Self::listen_cron_due(listener, ctx, &cron_worker_id, retry_duration).await
{
tracing::error!("Error listening to cron due: {e}");
}
});
Ok::<_, RecorderError>(())
},
async {
let ctx = self.ctx.clone();
let retry_duration = chrono::Duration::milliseconds(
self.config.cron_retry_duration.as_millis() as i64,
);
tokio::task::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
if let Err(e) = cron::Model::check_and_cleanup_expired_cron_locks(
ctx.as_ref(),
retry_duration,
)
.await
{ {
tracing::error!( let ctx = self.ctx.clone();
"Error checking and cleaning up expired cron locks: {e}" async move {
); if let Err(e) =
} Self::listen_cron_due(listener, ctx, &cron_worker_id, retry_duration)
if let Err(e) = cron::Model::check_and_trigger_due_crons(ctx.as_ref()).await .await
{
tracing::error!("Error listening to cron due: {e}");
}
}
},
{ {
tracing::error!("Error checking and triggering due crons: {e}"); let ctx = self.ctx.clone();
} let mut interval = tokio::time::interval(cron_interval_duration);
} async move {
}); loop {
interval.tick().await;
if let Err(e) = cron::Model::check_and_cleanup_expired_cron_locks(
ctx.as_ref(),
retry_duration,
)
.await
{
tracing::error!(
"Error checking and cleaning up expired cron locks: {e}"
);
}
if let Err(e) =
cron::Model::check_and_trigger_due_crons(ctx.as_ref()).await
{
tracing::error!("Error checking and triggering due crons: {e}");
}
}
}
}
);
Ok::<_, RecorderError>(())
}
} => {}
};
Ok::<_, RecorderError>(())
}
)?;
Ok(()) Ok(())
} }
@@ -267,6 +295,7 @@ impl TaskService {
async fn setup_cron_due_listening(&self) -> RecorderResult<PgListener> { async fn setup_cron_due_listening(&self) -> RecorderResult<PgListener> {
let pool = self.ctx.db().get_postgres_connection_pool().clone(); let pool = self.ctx.db().get_postgres_connection_pool().clone();
let listener = PgListener::connect_with(&pool).await?; let listener = PgListener::connect_with(&pool).await?;
tracing::debug!("Cron due listener connected to postgres");
Ok(listener) Ok(listener)
} }
@@ -277,17 +306,19 @@ impl TaskService {
worker_id: &str, worker_id: &str,
retry_duration: chrono::Duration, retry_duration: chrono::Duration,
) -> RecorderResult<()> { ) -> RecorderResult<()> {
listener.listen(CRON_DUE_EVENT).await?;
loop { loop {
let notification = listener.recv().await?; let notification = listener.recv().await?;
if let Err(e) = cron::Model::handle_cron_notification( if notification.channel() == CRON_DUE_DEBUG_EVENT {
ctx.as_ref(), tracing::debug!("Received cron due debug event: {:?}", notification);
notification, continue;
worker_id, } else if notification.channel() == CRON_DUE_EVENT
retry_duration, && let Err(e) = cron::Model::handle_cron_notification(
) ctx.as_ref(),
.await notification,
worker_id,
retry_duration,
)
.await
{ {
tracing::error!("Error handling cron notification: {e}"); tracing::error!("Error handling cron notification: {e}");
} }
@@ -298,13 +329,21 @@ impl TaskService {
#[cfg(test)] #[cfg(test)]
#[allow(unused_variables)] #[allow(unused_variables)]
mod tests { mod tests {
use std::time::Duration;
use chrono::Utc;
use rstest::{fixture, rstest}; use rstest::{fixture, rstest};
use sea_orm::ActiveValue;
use tracing::Level; use tracing::Level;
use super::*; use super::*;
use crate::test_utils::{ use crate::{
// app::TestingPreset, models::cron,
tracing::try_init_testing_tracing, task::EchoTask,
test_utils::{
app::{TestingAppContextConfig, TestingPreset},
tracing::try_init_testing_tracing,
},
}; };
#[fixture] #[fixture]
@@ -314,7 +353,82 @@ mod tests {
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
async fn test_cron_due_listening(before_each: ()) -> RecorderResult<()> { #[tracing_test::traced_test]
todo!() async fn test_check_and_trigger_due_crons_with_certain_interval(
before_each: (),
) -> RecorderResult<()> {
let preset = TestingPreset::default_with_config(
TestingAppContextConfig::builder()
.task_config(TaskConfig {
cron_interval_duration: Duration::from_millis(1500),
..Default::default()
})
.build(),
)
.await?;
let app_ctx = preset.app_ctx;
let task_service = app_ctx.task();
let task_id = Uuid::now_v7().to_string();
let echo_cron = cron::ActiveModel {
cron_expr: ActiveValue::Set("*/1 * * * * *".to_string()),
cron_timezone: ActiveValue::Set("Asia/Singapore".to_string()),
system_task_cron: ActiveValue::Set(Some(
EchoTask::builder().task_id(task_id.clone()).build().into(),
)),
..Default::default()
};
task_service.add_system_task_cron(echo_cron).await?;
task_service
.run_with_signal(Some(async move || {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}))
.await?;
assert!(logs_contain(&format!(
"EchoTask {task_id} start running at"
)));
Ok(())
}
#[rstest]
#[tokio::test]
#[tracing_test::traced_test]
async fn test_trigger_due_cron_when_mutating(before_each: ()) -> RecorderResult<()> {
let preset = TestingPreset::default().await?;
let app_ctx = preset.app_ctx;
let task_service = app_ctx.task();
let task_id = Uuid::now_v7().to_string();
let echo_cron = cron::ActiveModel {
cron_expr: ActiveValue::Set("* * * */1 * *".to_string()),
cron_timezone: ActiveValue::Set("Asia/Singapore".to_string()),
next_run: ActiveValue::Set(Some(Utc::now() + chrono::Duration::seconds(-10))),
system_task_cron: ActiveValue::Set(Some(
EchoTask::builder().task_id(task_id.clone()).build().into(),
)),
..Default::default()
};
let task_runner = task_service.run_with_signal(Some(async move || {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
task_service.add_system_task_cron(echo_cron).await?;
task_runner.await?;
assert!(logs_contain(&format!(
"EchoTask {task_id} start running at"
)));
Ok(())
} }
} }

View File

@@ -6,6 +6,7 @@ use typed_builder::TypedBuilder;
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::RecorderResult, errors::RecorderResult,
task::TaskConfig,
test_utils::{ test_utils::{
crypto::build_testing_crypto_service, crypto::build_testing_crypto_service,
database::{TestingDatabaseServiceConfig, build_testing_database_service}, database::{TestingDatabaseServiceConfig, build_testing_database_service},
@@ -43,10 +44,11 @@ impl TestingAppContext {
self.task.get_or_init(|| task); self.task.get_or_init(|| task);
} }
pub async fn from_preset(preset: TestingAppContextPreset) -> RecorderResult<Arc<Self>> { pub async fn from_config(config: TestingAppContextConfig) -> RecorderResult<Arc<Self>> {
let mikan_client = build_testing_mikan_client(preset.mikan_base_url).await?; let mikan_base_url = config.mikan_base_url.expect("mikan_base_url is required");
let mikan_client = build_testing_mikan_client(mikan_base_url).await?;
let db_service = let db_service =
build_testing_database_service(preset.database_config.unwrap_or_default()).await?; build_testing_database_service(config.database_config.unwrap_or_default()).await?;
let crypto_service = build_testing_crypto_service().await?; let crypto_service = build_testing_crypto_service().await?;
let storage_service = build_testing_storage_service().await?; let storage_service = build_testing_storage_service().await?;
let media_service = build_testing_media_service().await?; let media_service = build_testing_media_service().await?;
@@ -60,7 +62,7 @@ impl TestingAppContext {
.build(), .build(),
); );
let task_service = build_testing_task_service(app_ctx.clone()).await?; let task_service = build_testing_task_service(config.task_config, app_ctx.clone()).await?;
app_ctx.set_task(task_service); app_ctx.set_task(task_service);
@@ -132,9 +134,12 @@ impl AppContextTrait for TestingAppContext {
} }
} }
pub struct TestingAppContextPreset { #[derive(TypedBuilder, Debug)]
pub mikan_base_url: String, #[builder(field_defaults(default, setter(strip_option)))]
pub struct TestingAppContextConfig {
pub mikan_base_url: Option<String>,
pub database_config: Option<TestingDatabaseServiceConfig>, pub database_config: Option<TestingDatabaseServiceConfig>,
pub task_config: Option<TaskConfig>,
} }
#[derive(TypedBuilder)] #[derive(TypedBuilder)]
@@ -144,15 +149,15 @@ pub struct TestingPreset {
} }
impl TestingPreset { impl TestingPreset {
pub async fn default() -> RecorderResult<Self> { pub async fn default_with_config(config: TestingAppContextConfig) -> RecorderResult<Self> {
let mikan_server = MikanMockServer::new().await?; let mikan_server = MikanMockServer::new().await?;
let database_config = TestingDatabaseServiceConfig::default();
let app_ctx = TestingAppContext::from_preset(TestingAppContextPreset { let mixed_config = TestingAppContextConfig {
mikan_base_url: mikan_server.base_url().to_string(), mikan_base_url: Some(mikan_server.base_url().to_string()),
database_config: Some(database_config), ..config
}) };
.await?;
let app_ctx = TestingAppContext::from_config(mixed_config).await?;
let preset = Self::builder() let preset = Self::builder()
.mikan_server(mikan_server) .mikan_server(mikan_server)
@@ -160,4 +165,13 @@ impl TestingPreset {
.build(); .build();
Ok(preset) Ok(preset)
} }
pub async fn default() -> RecorderResult<Self> {
Self::default_with_config(TestingAppContextConfig {
mikan_base_url: None,
database_config: None,
task_config: None,
})
.await
}
} }

View File

@@ -52,7 +52,7 @@ pub async fn build_testing_database_service(
uri: connection_string, uri: connection_string,
enable_logging: true, enable_logging: true,
min_connections: 1, min_connections: 1,
max_connections: 1, max_connections: 5,
connect_timeout: 5000, connect_timeout: 5000,
idle_timeout: 10000, idle_timeout: 10000,
acquire_timeout: None, acquire_timeout: None,

View File

@@ -1,42 +1,16 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc;
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::RecorderResult, errors::RecorderResult,
task::{AsyncTaskTrait, TaskConfig, TaskService, register_system_task_type}, task::{TaskConfig, TaskService},
}; };
register_system_task_type! {
#[derive(Debug, Clone, PartialEq)]
pub struct TestSystemTask {
pub task_id: String,
}
}
#[async_trait::async_trait]
impl AsyncTaskTrait for TestSystemTask {
async fn run_async(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
let storage = ctx.storage();
storage
.write(
storage.build_test_path(self.task_id),
serde_json::json!({ "exec_time": Utc::now().timestamp_millis() })
.to_string()
.into(),
)
.await?;
Ok(())
}
}
pub async fn build_testing_task_service( pub async fn build_testing_task_service(
config: Option<TaskConfig>,
ctx: Arc<dyn AppContextTrait>, ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<TaskService> { ) -> RecorderResult<TaskService> {
let config = TaskConfig::default(); let config = config.unwrap_or_default();
let task_service = TaskService::from_config_and_ctx(config, ctx).await?; let task_service = TaskService::from_config_and_ctx(config, ctx).await?;
Ok(task_service) Ok(task_service)

View File

@@ -110,7 +110,7 @@ fn make_request_id(maybe_request_id: Option<HeaderValue>) -> String {
}); });
id.filter(|s| !s.is_empty()) id.filter(|s| !s.is_empty())
}) })
.unwrap_or_else(|| Uuid::new_v4().to_string()) .unwrap_or_else(|| Uuid::now_v7().to_string())
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -12,6 +12,7 @@ const config: CodegenConfig = {
}, },
config: { config: {
enumsAsConst: true, enumsAsConst: true,
useTypeImports: true,
scalars: { scalars: {
SubscriberTaskType: { SubscriberTaskType: {
input: 'recorder/bindings/SubscriberTaskInput#SubscriberTaskInput', input: 'recorder/bindings/SubscriberTaskInput#SubscriberTaskInput',

View File

@@ -11,13 +11,14 @@
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch" "codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
}, },
"dependencies": { "dependencies": {
"recorder": "workspace:*",
"@abraham/reflection": "^0.13.0", "@abraham/reflection": "^0.13.0",
"@apollo/client": "^3.13.8", "@apollo/client": "^3.13.8",
"@codemirror/language": "6.11.1", "@codemirror/language": "6.11.1",
"@corvu/drawer": "^0.2.4", "@corvu/drawer": "^0.2.4",
"@corvu/otp-field": "^0.1.4", "@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.5", "@corvu/resizable": "^0.2.5",
"@datasert/cronjs-matcher": "^1.4.0",
"@datasert/cronjs-parser": "^1.4.0",
"@graphiql/toolkit": "^0.11.3", "@graphiql/toolkit": "^0.11.3",
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@outposts/injection-js": "^2.5.1", "@outposts/injection-js": "^2.5.1",
@@ -72,9 +73,11 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.2",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"recorder": "workspace:*",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"type-fest": "^4.41.0", "type-fest": "^4.41.0",

View File

@@ -144,4 +144,6 @@
[role="button"]:not(:disabled) { [role="button"]:not(:disabled) {
cursor: pointer; cursor: pointer;
} }
} }
@plugin "tailwind-scrollbar";

View File

@@ -1,4 +1,3 @@
import type { NavMainGroup } from '@/infra/routes/nav';
import { import {
BookOpen, BookOpen,
Folders, Folders,
@@ -9,6 +8,7 @@ import {
Telescope, Telescope,
Tv, Tv,
} from 'lucide-react'; } from 'lucide-react';
import type { NavMainGroup } from '@/infra/routes/nav';
export const AppNavMainData: NavMainGroup[] = [ export const AppNavMainData: NavMainGroup[] = [
{ {
@@ -49,13 +49,13 @@ export const AppNavMainData: NavMainGroup[] = [
{ {
title: 'Manage', title: 'Manage',
link: { link: {
to: '/bangumi/recorder', to: '/bangumi',
}, },
}, },
{ {
title: 'Feed', title: 'Feed',
link: { link: {
to: '/bangumi/feed', to: '/bangumi',
}, },
}, },
], ],
@@ -65,11 +65,17 @@ export const AppNavMainData: NavMainGroup[] = [
icon: ListTodo, icon: ListTodo,
children: [ children: [
{ {
title: 'Manage', title: 'Tasks',
link: { link: {
to: '/tasks/manage', to: '/tasks/manage',
}, },
}, },
{
title: 'Crons',
link: {
to: '/tasks/cron/manage',
},
},
], ],
}, },
{ {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMatches } from '@tanstack/react-router';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -27,13 +27,8 @@ import {
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import type { NavMainGroup, NavMainItem } from '@/infra/routes/nav'; import type { NavMainGroup, NavMainItem } from '@/infra/routes/nav';
import { useMatches } from '@tanstack/react-router';
export function NavMain({ export function NavMain({ groups }: { groups: NavMainGroup[] }) {
groups,
}: {
groups: NavMainGroup[];
}) {
const matches = useMatches(); const matches = useMatches();
const { state } = useSidebar(); const { state } = useSidebar();

View File

@@ -1,4 +1,4 @@
import { type VariantProps, cva } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@/presentation/utils"; import { cn } from "@/presentation/utils";

View File

@@ -0,0 +1,52 @@
import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { type ReactNode, memo } from "react";
import { Button } from "./button";
export interface ContainerHeaderProps {
title: string;
description: string;
defaultBackTo?: string;
actions?: ReactNode;
}
export const ContainerHeader = memo(
({ title, description, defaultBackTo, actions }: ContainerHeaderProps) => {
const navigate = useNavigate();
const router = useRouter();
const canGoBack = useCanGoBack();
const finalCanGoBack = canGoBack || !!defaultBackTo;
const handleBack = () => {
if (canGoBack) {
router.history.back();
} else {
navigate({ to: defaultBackTo });
}
};
return (
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
{finalCanGoBack && (
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<div>
<h1 className="font-bold text-2xl">{title}</h1>
<p className="mt-1 text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex gap-2">{actions}</div>
</div>
);
}
);

View File

@@ -0,0 +1,291 @@
# Cron Components
A comprehensive set of React components for creating, editing, and displaying cron expressions with TypeScript support and shadcn/ui integration.
## Features
- 🎯 **Multiple Input Modes**: Text input, visual builder, or both
- 🔍 **Real-time Validation**: Powered by `@datasert/cronjs-parser`
-**Next Run Preview**: Shows upcoming execution times with `@datasert/cronjs-matcher`
- 🌍 **Timezone Support**: Display times in different timezones
- 📱 **Responsive Design**: Works seamlessly on desktop and mobile
- 🎨 **shadcn/ui Integration**: Consistent with your existing design system
- 🔧 **TypeScript Support**: Full type definitions included
- 🚀 **Customizable**: Extensive props for customization
## Components
### `<Cron />` - Main Component
The primary component that combines all functionality.
```tsx
import { Cron } from '@/components/cron';
function MyScheduler() {
const [cronExpression, setCronExpression] = useState('0 0 9 * * 1-5');
return (
<Cron
value={cronExpression}
onChange={setCronExpression}
mode="both" // 'input' | 'builder' | 'both'
showPreview={true}
showDescription={true}
timezone="UTC"
/>
);
}
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | `''` | Current cron expression |
| `onChange` | `(value: string) => void` | - | Called when expression changes |
| `onValidate` | `(isValid: boolean) => void` | - | Called when validation state changes |
| `mode` | `'input' \| 'builder' \| 'both'` | `'both'` | Display mode |
| `disabled` | `boolean` | `false` | Disable all inputs |
| `placeholder` | `string` | `'0 0 * * * *'` | Input placeholder text |
| `showPreview` | `boolean` | `true` | Show next run times preview |
| `showDescription` | `boolean` | `true` | Show human-readable description |
| `timezone` | `string` | `'UTC'` | Timezone for preview times |
| `error` | `string` | - | External error message |
| `className` | `ClassValue` | - | Additional CSS classes |
### `<CronInput />` - Text Input Component
Simple text input with validation and help text.
```tsx
import { CronInput } from '@/components/cron';
function QuickEntry() {
const [expression, setExpression] = useState('');
const [isValid, setIsValid] = useState(false);
return (
<CronInput
value={expression}
onChange={setExpression}
onValidate={setIsValid}
placeholder="Enter cron expression..."
/>
);
}
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | - | Current expression value |
| `onChange` | `(value: string) => void` | - | Called when input changes |
| `onValidate` | `(isValid: boolean) => void` | - | Called when validation changes |
| `placeholder` | `string` | `'0 0 * * * *'` | Placeholder text |
| `disabled` | `boolean` | `false` | Disable input |
| `readOnly` | `boolean` | `false` | Make input read-only |
| `error` | `string` | - | Error message to display |
| `className` | `ClassValue` | - | Additional CSS classes |
### `<CronBuilder />` - Visual Builder Component
Visual interface for building cron expressions with presets and field editors.
```tsx
import { CronBuilder } from '@/components/cron';
function VisualScheduler() {
const [expression, setExpression] = useState('0 0 * * * *');
return (
<CronBuilder
value={expression}
onChange={setExpression}
showPreview={true}
defaultTab="daily"
allowedPeriods={['hourly', 'daily', 'weekly']}
/>
);
}
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | `'0 0 * * * *'` | Current expression |
| `onChange` | `(value: string) => void` | - | Called when expression changes |
| `disabled` | `boolean` | `false` | Disable all controls |
| `showPreview` | `boolean` | `true` | Show preview section |
| `defaultTab` | `CronPeriod` | `'hourly'` | Default active tab |
| `allowedPeriods` | `CronPeriod[]` | All periods | Which tabs to show |
| `presets` | `CronPreset[]` | Built-in presets | Custom preset list |
| `className` | `ClassValue` | - | Additional CSS classes |
### `<CronDisplay />` - Display Component
Read-only component for displaying cron expression information.
```tsx
import { CronDisplay } from '@/components/cron';
function ScheduleInfo({ schedule }) {
return (
<CronDisplay
expression={schedule.cronExpression}
showNextRuns={true}
showDescription={true}
nextRunsCount={5}
timezone={schedule.timezone}
/>
);
}
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `expression` | `string` | - | Cron expression to display |
| `showNextRuns` | `boolean` | `true` | Show upcoming run times |
| `showDescription` | `boolean` | `true` | Show human-readable description |
| `nextRunsCount` | `number` | `5` | Number of future runs to show |
| `timezone` | `string` | `'UTC'` | Timezone for times |
| `className` | `ClassValue` | - | Additional CSS classes |
## Cron Expression Format
The components support 6-field cron expressions with seconds:
```
┌─────────────── second (0-59)
│ ┌───────────── minute (0-59)
│ │ ┌─────────── hour (0-23)
│ │ │ ┌───────── day of month (1-31)
│ │ │ │ ┌─────── month (1-12)
│ │ │ │ │ ┌───── day of week (0-6, Sunday=0)
│ │ │ │ │ │
* * * * * *
```
### Special Characters
| Character | Description | Example |
|-----------|-------------|---------|
| `*` | Any value | `*` = every value |
| `,` | List separator | `1,3,5` = values 1, 3, and 5 |
| `-` | Range | `1-5` = values 1 through 5 |
| `/` | Step values | `*/5` = every 5th value |
| `?` | No specific value | Used when day/weekday conflict |
| `L` | Last | Last day of month/week |
| `W` | Weekday | Nearest weekday |
### Common Examples
| Expression | Description |
|------------|-------------|
| `0 * * * * *` | Every minute |
| `0 */5 * * * *` | Every 5 minutes |
| `0 0 * * * *` | Every hour |
| `0 0 9 * * *` | Daily at 9 AM |
| `0 30 9 * * 1-5` | Weekdays at 9:30 AM |
| `0 0 0 * * 0` | Every Sunday at midnight |
| `0 0 0 1 * *` | First day of every month |
| `0 0 0 1 1 *` | Every January 1st |
## Dependencies
- `@datasert/cronjs-parser` - For parsing and validating cron expressions
- `@datasert/cronjs-matcher` - For calculating next run times
- `@radix-ui/react-*` - UI primitives (via shadcn/ui)
- `lucide-react` - Icons
## Installation
1. Copy the component files to your project
2. Ensure you have the required dependencies:
```bash
npm install @datasert/cronjs-parser @datasert/cronjs-matcher
```
3. Import and use the components:
```tsx
import { Cron } from '@/components/cron';
```
## Customization
### Custom Presets
```tsx
const customPresets = [
{
label: 'Business Hours',
value: '0 0 9-17 * * 1-5',
description: 'Every hour during business hours',
category: 'custom'
},
// ... more presets
];
<CronBuilder presets={customPresets} />
```
### Restricted Periods
```tsx
<CronBuilder
allowedPeriods={['daily', 'weekly']}
defaultTab="daily"
/>
```
### Custom Validation
```tsx
function MyComponent() {
const [expression, setExpression] = useState('');
const [isValid, setIsValid] = useState(false);
const handleValidation = (valid: boolean) => {
setIsValid(valid);
// Custom validation logic
};
return (
<Cron
value={expression}
onChange={setExpression}
onValidate={handleValidation}
error={!isValid ? 'Invalid expression' : undefined}
/>
);
}
```
## TypeScript Support
All components include comprehensive TypeScript definitions:
```tsx
import type {
CronProps,
CronExpression,
CronValidationResult,
CronPeriod
} from '@/components/cron';
```
## Examples
See `CronExample` component for comprehensive usage examples and interactive demos.
## Browser Support
- Modern browsers with ES2015+ support
- React 16.8+ (hooks support required)
- TypeScript 4.0+ recommended

View File

@@ -0,0 +1,743 @@
import { getFutureMatches } from "@datasert/cronjs-matcher";
import { Calendar, Clock, Info, Settings, Zap } from "lucide-react";
import {
type CSSProperties,
type FC,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/presentation/utils";
import {
type CronBuilderProps,
CronField,
type CronFieldConfig,
CronPeriod,
type CronPreset,
} from "./types.js";
const CRON_PRESETS: CronPreset[] = [
{
label: "Every minute",
value: "0 * * * * *",
description: "Runs every minute",
category: "common",
},
{
label: "Every 5 minutes",
value: "0 */5 * * * *",
description: "Runs every 5 minutes",
category: "common",
},
{
label: "Every 15 minutes",
value: "0 */15 * * * *",
description: "Runs every 15 minutes",
category: "common",
},
{
label: "Every 30 minutes",
value: "0 */30 * * * *",
description: "Runs every 30 minutes",
category: "common",
},
{
label: "Every hour",
value: "0 0 * * * *",
description: "Runs at the top of every hour",
category: "common",
},
{
label: "Every 6 hours",
value: "0 0 */6 * * *",
description: "Runs every 6 hours",
category: "common",
},
{
label: "Daily at midnight",
value: "0 0 0 * * *",
description: "Runs once daily at 00:00",
category: "daily",
},
{
label: "Daily at 9 AM",
value: "0 0 9 * * *",
description: "Runs daily at 9:00 AM",
category: "daily",
},
{
label: "Weekdays at 9 AM",
value: "0 0 9 * * 1-5",
description: "Runs Monday to Friday at 9:00 AM",
category: "weekly",
},
{
label: "Every Sunday",
value: "0 0 0 * * 0",
description: "Runs every Sunday at midnight",
category: "weekly",
},
{
label: "First day of month",
value: "0 0 0 1 * *",
description: "Runs on the 1st day of every month",
category: "monthly",
},
{
label: "Every year",
value: "0 0 0 1 1 *",
description: "Runs on January 1st every year",
category: "yearly",
},
];
const FIELD_CONFIGS: Record<CronField, CronFieldConfig> = {
seconds: {
min: 0,
max: 59,
step: 1,
allowSpecial: ["*", "?"],
},
minutes: {
min: 0,
max: 59,
step: 1,
allowSpecial: ["*", "?"],
},
hours: {
min: 0,
max: 23,
step: 1,
allowSpecial: ["*", "?"],
},
dayOfMonth: {
min: 1,
max: 31,
step: 1,
allowSpecial: ["*", "?", "L", "W"],
options: [
{ label: "Any day", value: "*" },
{ label: "No specific day", value: "?" },
{ label: "Last day", value: "L" },
{ label: "Weekday", value: "W" },
],
},
month: {
min: 1,
max: 12,
step: 1,
allowSpecial: ["*"],
options: [
{ label: "January", value: 1 },
{ label: "February", value: 2 },
{ label: "March", value: 3 },
{ label: "April", value: 4 },
{ label: "May", value: 5 },
{ label: "June", value: 6 },
{ label: "July", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "October", value: 10 },
{ label: "November", value: 11 },
{ label: "December", value: 12 },
],
},
dayOfWeek: {
min: 0,
max: 6,
step: 1,
allowSpecial: ["*", "?"],
options: [
{ label: "Sunday", value: 0 },
{ label: "Monday", value: 1 },
{ label: "Tuesday", value: 2 },
{ label: "Wednesday", value: 3 },
{ label: "Thursday", value: 4 },
{ label: "Friday", value: 5 },
{ label: "Saturday", value: 6 },
],
},
year: {
min: 0,
max: 9999,
step: 1,
allowSpecial: ["*", "?"],
},
};
const PERIOD_CONFIGS = {
minute: {
label: CronPeriod.Minute,
description: "Run every minute",
template: "0 * * * * *",
fields: [CronField.Minutes],
},
hourly: {
label: CronPeriod.Hourly,
description: "Run every hour",
template: "0 0 * * * *",
fields: [CronField.Minutes, CronField.Hours],
},
daily: {
label: CronPeriod.Daily,
description: "Run every day",
template: "0 0 0 * * *",
fields: [CronField.Seconds, CronField.Minutes, CronField.Hours],
},
weekly: {
label: CronPeriod.Weekly,
description: "Run every week",
template: "0 0 0 * * 0",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfWeek,
],
},
monthly: {
label: CronPeriod.Monthly,
description: "Run every month",
template: "0 0 0 1 * *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
],
},
yearly: {
label: CronPeriod.Yearly,
description: "Run every year",
template: "0 0 0 1 1 *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
],
},
custom: {
label: CronPeriod.Custom,
description: "Custom expression",
template: "0 0 * * * *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
CronField.DayOfWeek,
],
},
} as const;
const CronBuilder: FC<CronBuilderProps> = ({
timezone = "UTC",
value = "0 0 * * * *",
onChange,
className,
disabled = false,
showPreview = true,
showPresets = true,
displayPeriods = [
CronPeriod.Custom,
CronPeriod.Minute,
CronPeriod.Hourly,
CronPeriod.Daily,
CronPeriod.Weekly,
CronPeriod.Monthly,
CronPeriod.Yearly,
],
defaultTab = CronPeriod.Custom,
presets = CRON_PRESETS,
showGeneratedExpression = true,
withCard = true,
}) => {
const [activeTab, setActiveTab] = useState<CronPeriod>(defaultTab);
const [cronFields, setCronFields] = useState(() =>
parseCronExpression(value)
);
const currentExpression = useMemo(() => {
return `${cronFields.seconds} ${cronFields.minutes} ${cronFields.hours} ${cronFields.dayOfMonth} ${cronFields.month} ${cronFields.dayOfWeek}`;
}, [cronFields]);
const nextRuns = useMemo(() => {
if (!showPreview) {
return [];
}
try {
const matches = getFutureMatches(`${currentExpression} *`, {
matchCount: 3,
timezone,
formatInTimezone: true,
hasSeconds: true,
});
return matches.map((match) => new Date(match));
} catch (error) {
console.error("Failed to get future matched runs", error);
return [];
}
}, [currentExpression, showPreview, timezone]);
useEffect(() => {
setCronFields(parseCronExpression(value));
}, [value]);
useEffect(() => {
onChange?.(currentExpression);
}, [currentExpression, onChange]);
const handlePresetSelect = useCallback((preset: CronPreset) => {
setCronFields(parseCronExpression(preset.value));
}, []);
const handleFieldChange = useCallback(
(field: CronField, newValue: string) => {
setCronFields((prev) => ({ ...prev, [field]: newValue }));
},
[]
);
const handlePeriodChange = useCallback((period: CronPeriod) => {
setActiveTab(period);
if (period !== "custom") {
const config = PERIOD_CONFIGS[period];
setCronFields(parseCronExpression(config.template));
}
}, []);
const filteredPresets = useMemo(() => {
return presets.filter((preset) => {
if (activeTab === "custom") {
return true;
}
return preset.category === activeTab;
});
}, [presets, activeTab]);
return (
<div className={cn(withCard && "space-y-6", className)}>
<Tabs
value={activeTab}
onValueChange={(v) => handlePeriodChange(v as CronPeriod)}
>
<div className="overflow-x-auto">
<TabsList
className="grid w-(--all-grids-width) grid-cols-7 whitespace-nowrap lg:w-full"
style={
{
"--my-grid-cols": `grid-template-columns: repeat(${displayPeriods.length}, minmax(0, 1fr))`,
"--all-grids-width":
displayPeriods.length > 4
? `${displayPeriods.length * 25 - 20}%`
: "100%",
} as CSSProperties
}
>
{displayPeriods.map((period) => (
<TabsTrigger
key={period}
value={period}
disabled={disabled}
className="text-xs capitalize"
>
{PERIOD_CONFIGS[period].label}
</TabsTrigger>
))}
</TabsList>
</div>
{displayPeriods.map((period) => (
<TabsContent
key={period}
value={period}
className={cn(withCard ? "space-y-4" : "px-0")}
>
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn("pb-1", !withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
<span className="capitalize">
{PERIOD_CONFIGS[period].label} Configuration
</span>
</CardTitle>
<CardDescription>
{PERIOD_CONFIGS[period].description}
</CardDescription>
</CardHeader>
<CardContent className={cn("space-y-4", !withCard && "px-0")}>
<CronFieldEditor
period={period}
fields={cronFields}
onChange={handleFieldChange}
disabled={disabled}
/>
</CardContent>
</Card>
{showPresets && filteredPresets.length > 0 && (
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4" />
Quick Presets
</CardTitle>
<CardDescription>
Common cron expressions for quick setup
</CardDescription>
</CardHeader>
<CardContent className={cn(!withCard && "px-0")}>
<div className="grid gap-3 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{filteredPresets.map((preset, index) => (
<Button
key={index}
variant="outline"
className="h-auto justify-start p-4 text-left"
onClick={() => handlePresetSelect(preset)}
disabled={disabled}
>
<div className="w-full space-y-2">
<div className="font-medium text-sm">
{preset.label}
</div>
<div className="whitespace-normal break-words text-muted-foreground text-xs leading-relaxed">
{preset.description}
</div>
<Badge
variant="secondary"
className="mt-1 break-all font-mono text-xs"
>
{preset.value}
</Badge>
</div>
</Button>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
{/* Current Expression & Preview */}
{showGeneratedExpression && (
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Generated Expression
</CardTitle>
</CardHeader>
<CardContent className={cn("space-y-4", !withCard && "px-0")}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-3 py-1 font-mono text-sm">
{currentExpression}
</Badge>
</div>
{showPreview && nextRuns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Calendar className="h-4 w-4" />
Next Runs({timezone})
</h4>
<div className="space-y-1">
{nextRuns.map((date, index) => (
<div
key={index}
className="flex items-center justify-between rounded bg-muted/50 px-3 py-2 text-sm"
>
<span className="font-medium text-muted-foreground">
#{index + 1}
</span>
<span className="font-mono">
{date.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
};
interface CronFieldEditorProps {
period: CronPeriod;
fields: Record<CronField, string>;
onChange: (field: CronField, value: string) => void;
disabled?: boolean;
}
const CronFieldEditor: FC<CronFieldEditorProps> = ({
period,
fields,
onChange,
disabled = false,
}) => {
const relevantFields = [...PERIOD_CONFIGS[period].fields] as CronField[];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{relevantFields.map((field) => {
const config = FIELD_CONFIGS[field];
const currentValue = fields[field];
return (
<CronFieldItemEditor
key={field}
config={config}
field={field}
value={currentValue}
onChange={onChange}
disabled={disabled}
/>
);
})}
</div>
);
};
const CronFieldItemAnyOrSpecificOption = {
Any: "any",
Specific: "specific",
} as const;
type CronFieldItemAnyOrSpecificOption =
(typeof CronFieldItemAnyOrSpecificOption)[keyof typeof CronFieldItemAnyOrSpecificOption];
interface CronFieldItemEditorProps {
config: CronFieldConfig;
field: CronField;
value: string;
onChange: (field: CronField, value: string) => void;
disabled?: boolean;
}
function encodeCronFieldItem(value: string): string {
if (value === "") {
return "<meta:empty>";
}
if (value.includes(" ")) {
return `<meta:contains-space:${encodeURIComponent(value)}>`;
}
return value;
}
function decodeCronFieldItem(value: string): string {
if (value.startsWith("<meta:contains")) {
return decodeURIComponent(
// biome-ignore lint/performance/useTopLevelRegex: false
value.replace(/^<meta:contains-space:([^>]+)>$/, "$1")
);
}
if (value === "<meta:empty>") {
return "";
}
return value;
}
export const CronFieldItemEditor: FC<CronFieldItemEditorProps> = memo(
({ field, value, onChange, config, disabled = false }) => {
const [innerValue, _setInnerValue] = useState(() =>
decodeCronFieldItem(value)
);
const [anyOrSpecificOption, _setAnyOrSpecificOption] =
useState<CronFieldItemAnyOrSpecificOption>(() =>
innerValue === "*"
? CronFieldItemAnyOrSpecificOption.Any
: CronFieldItemAnyOrSpecificOption.Specific
);
// biome-ignore lint/correctness/useExhaustiveDependencies: false
useEffect(() => {
const nextValue = decodeCronFieldItem(value);
if (nextValue !== innerValue) {
_setInnerValue(nextValue);
}
}, [value]);
const handleChange = useCallback(
(v: string) => {
_setInnerValue(v);
onChange(field, encodeCronFieldItem(v));
},
[field, onChange]
);
const setAnyOrSpecificOption = useCallback(
(v: CronFieldItemAnyOrSpecificOption) => {
_setAnyOrSpecificOption(v);
if (v === CronFieldItemAnyOrSpecificOption.Any) {
handleChange("*");
} else if (v === CronFieldItemAnyOrSpecificOption.Specific) {
handleChange("0");
}
},
[handleChange]
);
return (
<div className="space-y-2">
<Label className="font-medium text-sm capitalize">
{field.replace(/([A-Z])/g, " $1").toLowerCase()}
</Label>
{(field === "month" || field === "dayOfWeek") && (
<Select
value={innerValue}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="*">Any</SelectItem>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field === "dayOfMonth" && (
<div className="space-y-2">
<Select
value={innerValue}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
<SelectItem key={day} value={day.toString()}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!(
field === "month" ||
field === "dayOfWeek" ||
field === "dayOfMonth"
) && (
<div className="space-y-2">
<ToggleGroup
type="single"
value={anyOrSpecificOption}
onValueChange={setAnyOrSpecificOption}
disabled={disabled}
>
<ToggleGroupItem
value={CronFieldItemAnyOrSpecificOption.Any}
className="min-w-fit text-xs"
>
Any
</ToggleGroupItem>
<ToggleGroupItem
value={CronFieldItemAnyOrSpecificOption.Specific}
className="min-w-fit text-xs"
>
Specific
</ToggleGroupItem>
</ToggleGroup>
{anyOrSpecificOption ===
CronFieldItemAnyOrSpecificOption.Specific && (
<Input
type="text"
value={innerValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={`0-${config.max}`}
disabled={disabled}
className="font-mono text-sm"
/>
)}
<div className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Info className="h-3 w-3" />
<span>
Range: {config.min}-{config.max}
</span>
</div>
<div className="mt-1">
Supports: *, numbers, ranges (1-5), lists (1,3,5), steps (*/5)
</div>
</div>
</div>
)}
</div>
);
}
);
function parseCronExpression(expression: string): Record<CronField, string> {
const parts = expression.split(" ");
// Ensure we have 6 parts, pad with defaults if needed
while (parts.length < 6) {
parts.push("*");
}
return {
seconds: parts[0] || "0",
minutes: parts[1] || "*",
hours: parts[2] || "*",
dayOfMonth: parts[3] || "*",
month: parts[4] || "*",
dayOfWeek: parts[5] || "*",
year: parts[6] || "*",
};
}
export { CronBuilder };

View File

@@ -0,0 +1,277 @@
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/presentation/utils';
import { getFutureMatches, isTimeMatches } from '@datasert/cronjs-matcher';
import { parse } from '@datasert/cronjs-parser';
import { AlertCircle, CalendarDays, CheckCircle, Clock } from 'lucide-react';
import { type FC, useMemo } from 'react';
import type {
CronDisplayProps,
CronNextRun,
CronValidationResult,
} from './types.js';
const CronDisplay: FC<CronDisplayProps> = ({
expression,
className,
showNextRuns = true,
nextRunsCount = 5,
timezone = 'UTC',
showDescription = true,
withCard = true,
}) => {
const validationResult = useMemo((): CronValidationResult => {
if (!expression) {
return { isValid: false, error: 'No expression provided' };
}
try {
const _parsed = parse(`${expression} *`, { hasSeconds: true });
return {
isValid: true,
description: generateDescription(expression),
};
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : 'Invalid expression',
};
}
}, [expression]);
const nextRuns = useMemo((): CronNextRun[] => {
if (!expression || !validationResult.isValid || !showNextRuns) {
return [];
}
try {
const matches = getFutureMatches(`${expression} *`, {
matchCount: nextRunsCount,
timezone,
formatInTimezone: true,
hasSeconds: true,
});
return matches.map((match) => {
const date = new Date(match);
return {
date,
timestamp: date.getTime(),
formatted: date.toLocaleString(),
relative: getRelativeTime(date),
};
});
} catch (error) {
console.warn('Failed to get future matches:', error);
return [];
}
}, [
expression,
validationResult.isValid,
showNextRuns,
nextRunsCount,
timezone,
]);
const isCurrentTimeMatch = useMemo(() => {
if (!expression || !validationResult.isValid) {
return false;
}
try {
return isTimeMatches(
`${expression} *`,
new Date().toISOString(),
timezone
);
} catch (_error: unknown) {
return false;
}
}, [expression, validationResult.isValid, timezone]);
if (!expression) {
return (
<Card className={cn(className, !withCard && 'border-none shadow-none')}>
<CardContent className={cn('p-4', !withCard && 'px-0')}>
<div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">No cron expression set</span>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={cn(className, !withCard && 'border-none shadow-none')}>
<CardHeader className={cn(!withCard && 'px-0')}>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Cron Expression
{isCurrentTimeMatch && (
<Badge variant="default" className="text-xs">
<CheckCircle className="mr-1 h-3 w-3" />
Active Now
</Badge>
)}
</CardTitle>
<Badge
variant={validationResult.isValid ? 'secondary' : 'destructive'}
className="font-mono text-xs"
>
{expression}
</Badge>
</div>
{validationResult.isValid &&
showDescription &&
validationResult.description && (
<CardDescription className="text-sm">
{validationResult.description}
</CardDescription>
)}
{!validationResult.isValid && validationResult.error && (
<CardDescription className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
{validationResult.error}
</CardDescription>
)}
</CardHeader>
{validationResult.isValid && showNextRuns && nextRuns.length > 0 && (
<CardContent className={cn('pt-0', !withCard && 'px-0')}>
<div className="space-y-3">
<h4 className="flex items-center gap-2 font-medium text-sm">
<CalendarDays className="h-4 w-4" />
Next Runs
<Badge variant="outline" className="text-xs">
{timezone}
</Badge>
</h4>
<div className="space-y-2">
{nextRuns.map((run, index) => (
<div
key={index}
className="flex items-center justify-between rounded border bg-muted/50 p-2"
>
<div className="flex items-center gap-2">
<span className="w-6 font-medium text-muted-foreground text-xs">
#{index + 1}
</span>
<span className="font-mono text-sm">{run.formatted}</span>
</div>
<span className="text-muted-foreground text-xs">
{run.relative}
</span>
</div>
))}
</div>
</div>
</CardContent>
)}
</Card>
);
};
function generateDescription(expression: string): string {
// Enhanced description generator based on common patterns
const parts = expression.split(' ');
if (parts.length !== 6) {
return expression;
}
const [sec, min, hour, day, month, weekday] = parts;
// Common patterns
const patterns: Record<string, string> = {
'* * * * * *': 'Every second',
'0 * * * * *': 'Every minute',
'0 0 * * * *': 'Every hour',
'0 0 0 * * *': 'Daily at midnight',
'0 0 0 * * 0': 'Every Sunday at midnight',
'0 0 0 * * 1': 'Every Monday at midnight',
'0 0 0 * * 2': 'Every Tuesday at midnight',
'0 0 0 * * 3': 'Every Wednesday at midnight',
'0 0 0 * * 4': 'Every Thursday at midnight',
'0 0 0 * * 5': 'Every Friday at midnight',
'0 0 0 * * 6': 'Every Saturday at midnight',
'0 0 0 1 * *': 'Monthly on the 1st at midnight',
'0 0 0 1 1 *': 'Yearly on January 1st at midnight',
'0 30 9 * * 1-5': 'Weekdays at 9:30 AM',
'0 0 */6 * * *': 'Every 6 hours',
'0 */30 * * * *': 'Every 30 minutes',
'0 */15 * * * *': 'Every 15 minutes',
'0 */5 * * * *': 'Every 5 minutes',
};
if (patterns[expression]) {
return patterns[expression];
}
// Generate dynamic description
let description = 'At ';
if (sec !== '*' && sec !== '0') {
description += `second ${sec}, `;
}
if (min !== '*') {
description += `minute ${min}, `;
}
if (hour !== '*') {
description += `hour ${hour}, `;
}
if (day !== '*' && weekday !== '*') {
description += `on day ${day} and weekday ${weekday} `;
} else if (day !== '*') {
description += `on day ${day} `;
} else if (weekday !== '*') {
description += `on weekday ${weekday} `;
}
if (month !== '*') {
description += `in month ${month}`;
}
// biome-ignore lint/performance/useTopLevelRegex: <explanation>
return description.replace(/,\s*$/, '').replace(/At\s*$/, 'Every occurrence');
}
function getRelativeTime(date: Date): string {
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) {
return 'Past';
}
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return `in ${diffSec}s`;
}
if (diffMin < 60) {
return `in ${diffMin}m`;
}
if (diffHour < 24) {
return `in ${diffHour}h`;
}
if (diffDay < 7) {
return `in ${diffDay}d`;
}
return `in ${Math.floor(diffDay / 7)}w`;
}
export { CronDisplay };

View File

@@ -0,0 +1,413 @@
import { Code2, Play, Settings, Type } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Cron } from "./cron.jsx";
import { CronBuilder } from "./cron-builder.jsx";
import { CronDisplay } from "./cron-display.jsx";
import { CronInput } from "./cron-input.jsx";
const CronExample: FC = () => {
const [inputValue, setInputValue] = useState("0 30 9 * * 1-5");
const [builderValue, setBuilderValue] = useState("0 0 */6 * * *");
const [fullValue, setFullValue] = useState("0 */15 * * * *");
const [displayValue] = useState("0 0 0 * * 0");
const examples = [
{
label: "Every minute",
expression: "0 * * * * *",
description: "Runs at the start of every minute",
},
{
label: "Every 5 minutes",
expression: "0 */5 * * * *",
description: "Runs every 5 minutes",
},
{
label: "Every hour",
expression: "0 0 * * * *",
description: "Runs at the start of every hour",
},
{
label: "Daily at 9 AM",
expression: "0 0 9 * * *",
description: "Runs every day at 9:00 AM",
},
{
label: "Weekdays at 9:30 AM",
expression: "0 30 9 * * 1-5",
description: "Runs Monday through Friday at 9:30 AM",
},
{
label: "Every Sunday",
expression: "0 0 0 * * 0",
description: "Runs every Sunday at midnight",
},
{
label: "First day of month",
expression: "0 0 0 1 * *",
description: "Runs on the 1st day of every month",
},
{
label: "Every quarter",
expression: "0 0 0 1 */3 *",
description: "Runs on the 1st day of every quarter",
},
];
const handleCopyExample = useCallback(async (expression: string) => {
try {
await navigator.clipboard.writeText(expression);
} catch (error) {
console.warn("Failed to copy to clipboard:", error);
}
}, []);
return (
<div className="space-y-8">
{/* Header */}
<div className="space-y-2">
<h1 className="font-bold text-3xl">Cron Expression Components</h1>
<p className="text-lg text-muted-foreground">
A comprehensive set of components for creating and managing cron
expressions.
</p>
</div>
{/* Quick Examples */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code2 className="h-5 w-5" />
Common Examples
</CardTitle>
<CardDescription>
Click any example to copy the expression to your clipboard
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{examples.map((example, index) => (
<Button
key={index}
variant="outline"
className="h-auto flex-col items-start p-4 text-left"
onClick={() => handleCopyExample(example.expression)}
>
<div className="w-full space-y-2">
<div className="font-medium text-sm">{example.label}</div>
<Badge variant="secondary" className="font-mono text-xs">
{example.expression}
</Badge>
<div className="text-muted-foreground text-xs">
{example.description}
</div>
</div>
</Button>
))}
</div>
</CardContent>
</Card>
<Separator />
{/* Component Examples */}
<div className="space-y-8">
<div className="space-y-2">
<h2 className="font-semibold text-2xl">Component Examples</h2>
<p className="text-muted-foreground">
Interactive examples showing different ways to use the cron
components.
</p>
</div>
<Tabs defaultValue="full" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="full">Complete</TabsTrigger>
<TabsTrigger value="input">Input Only</TabsTrigger>
<TabsTrigger value="builder">Builder Only</TabsTrigger>
<TabsTrigger value="display">Display Only</TabsTrigger>
</TabsList>
<TabsContent value="full" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Complete Cron Component
</CardTitle>
<CardDescription>
Full-featured component with both input and visual builder
modes, validation, preview, and help documentation.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Cron
value={fullValue}
onChange={setFullValue}
mode="both"
showPreview={true}
showDescription={true}
timezone="UTC"
/>
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{fullValue || "No expression set"}
</Badge>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="input" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Type className="h-5 w-5" />
Text Input Component
</CardTitle>
<CardDescription>
Simple text input with validation, help text, and real-time
feedback.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<CronInput
value={inputValue}
onChange={setInputValue}
placeholder="Enter cron expression..."
/>
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{inputValue || "No expression set"}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Input-Only Mode</CardTitle>
<CardDescription>
Using the main Cron component in input-only mode with preview.
</CardDescription>
</CardHeader>
<CardContent>
<Cron
value={inputValue}
onChange={setInputValue}
mode="input"
showPreview={true}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="builder" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Visual Builder Component
</CardTitle>
<CardDescription>
Visual interface for building cron expressions with presets
and field editors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<CronBuilder
value={builderValue}
onChange={setBuilderValue}
showPreview={true}
/>
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{builderValue || "No expression set"}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Builder-Only Mode</CardTitle>
<CardDescription>
Using the main Cron component in builder-only mode.
</CardDescription>
</CardHeader>
<CardContent>
<Cron
value={builderValue}
onChange={setBuilderValue}
mode="builder"
showPreview={false}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="display" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Play className="h-5 w-5" />
Display Component
</CardTitle>
<CardDescription>
Read-only component that shows cron expression details,
description, and next run times.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<CronDisplay
expression={displayValue}
showNextRuns={true}
showDescription={true}
nextRunsCount={5}
timezone="UTC"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Multiple Timezone Display</CardTitle>
<CardDescription>
Same expression displayed in different timezones.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<h4 className="mb-2 font-medium text-sm">UTC</h4>
<CronDisplay
expression="0 0 12 * * *"
showNextRuns={true}
nextRunsCount={3}
timezone="UTC"
/>
</div>
<div>
<h4 className="mb-2 font-medium text-sm">
America/New_York
</h4>
<CronDisplay
expression="0 0 12 * * *"
showNextRuns={true}
nextRunsCount={3}
timezone="America/New_York"
/>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Usage Examples */}
<Card>
<CardHeader>
<CardTitle>Usage Examples</CardTitle>
<CardDescription>
Code examples showing how to integrate these components into your
application.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div>
<h4 className="mb-2 font-medium text-sm">Basic Usage</h4>
<div className="rounded bg-muted p-4 font-mono text-sm">
<pre>{`import { Cron } from '@/components/cron';
function MyComponent() {
const [cronExpression, setCronExpression] = useState('0 0 * * * *');
return (
<Cron
value={cronExpression}
onChange={setCronExpression}
mode="both"
showPreview={true}
/>
);
}`}</pre>
</div>
</div>
<div>
<h4 className="mb-2 font-medium text-sm">
Input Only with Validation
</h4>
<div className="rounded bg-muted p-4 font-mono text-sm">
<pre>{`import { CronInput } from '@/components/cron';
function ScheduleForm() {
const [expression, setExpression] = useState('');
const [isValid, setIsValid] = useState(false);
return (
<CronInput
value={expression}
onChange={setExpression}
onValidate={setIsValid}
placeholder="0 0 * * * *"
/>
);
}`}</pre>
</div>
</div>
<div>
<h4 className="mb-2 font-medium text-sm">
Display Schedule Information
</h4>
<div className="rounded bg-muted p-4 font-mono text-sm">
<pre>{`import { CronDisplay } from '@/components/cron';
function SchedulePreview({ schedule }) {
return (
<CronDisplay
expression={schedule.cronExpression}
showNextRuns={true}
showDescription={true}
timezone={schedule.timezone}
/>
);
}`}</pre>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export { CronExample };

View File

@@ -0,0 +1,190 @@
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { cn } from '@/presentation/utils';
import { parse } from '@datasert/cronjs-parser';
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
import {
type ChangeEvent,
forwardRef,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type { CronInputProps, CronValidationResult } from './types.js';
const CronInput = forwardRef<HTMLInputElement, CronInputProps>(
(
{
value,
onChange,
onValidate,
placeholder = '0 0 * * * *',
className,
disabled,
readOnly,
error,
...props
},
ref
) => {
const [internalValue, setInternalValue] = useState(value || '');
const [isFocused, setIsFocused] = useState(false);
const validationResult = useMemo((): CronValidationResult => {
if (!internalValue.trim()) {
return {
isValid: false,
error: 'Expression is required',
isEmpty: true,
};
}
try {
parse(`${internalValue} *`, { hasSeconds: true });
return { isValid: true };
} catch (parseError) {
return {
isValid: false,
error:
parseError instanceof Error
? parseError.message
: 'Invalid cron expression',
};
}
}, [internalValue]);
useEffect(() => {
setInternalValue(value || '');
}, [value]);
useEffect(() => {
onValidate?.(validationResult.isValid);
}, [validationResult.isValid, onValidate]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
onChange?.(newValue);
},
[onChange]
);
const handleFocus = useCallback(() => {
setIsFocused(true);
}, []);
const handleBlur = useCallback(() => {
setIsFocused(false);
}, []);
const hasError =
error || (!validationResult.isValid && internalValue.trim());
const showSuccess =
validationResult.isValid && internalValue.trim() && !isFocused;
return (
<div className="space-y-2">
<div className="relative">
<Input
ref={ref}
type="text"
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
className={cn(
'pr-10 font-mono text-sm',
hasError && 'border-destructive focus-visible:ring-destructive',
showSuccess && 'border-success focus-visible:ring-success',
className
)}
disabled={disabled}
readOnly={readOnly}
aria-invalid={hasError ? 'true' : 'false'}
{...props}
/>
{/* Status icon */}
<div className="-translate-y-1/2 absolute top-1/2 right-3">
{hasError && <AlertCircle className="h-4 w-4 text-destructive" />}
{showSuccess && <CheckCircle className="h-4 w-4 text-success" />}
</div>
</div>
{/* Error message */}
{hasError && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
<span>{error || validationResult.error}</span>
</div>
)}
{/* Help text when focused */}
{isFocused && !hasError && (
<div className="space-y-2 text-muted-foreground text-sm">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
<span>Format: second minute hour day month weekday</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1">
<Badge variant="outline" className="font-mono text-xs">
*
</Badge>
<span>any value</span>
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="font-mono text-xs">
,
</Badge>
<span>list separator</span>
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="font-mono text-xs">
-
</Badge>
<span>range</span>
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="font-mono text-xs">
/
</Badge>
<span>step value</span>
</div>
</div>
<div className="mt-2 space-y-1">
<div className="font-medium text-xs">Examples:</div>
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 * * * * *
</Badge>
<span>Every minute</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 0 * * * *
</Badge>
<span>Every hour</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 30 9 * * 1-5
</Badge>
<span>Weekdays at 9:30 AM</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
);
CronInput.displayName = 'CronInput';
export { CronInput };

View File

@@ -0,0 +1,512 @@
import { parse } from "@datasert/cronjs-parser";
import {
AlertCircle,
Bolt,
Check,
Code2,
Copy,
Settings,
Type,
} from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/presentation/utils";
import { CronBuilder } from "./cron-builder.js";
import { CronDisplay } from "./cron-display.js";
import { CronInput } from "./cron-input.js";
import {
CronMode,
type CronPrimitiveMode,
type CronProps,
type CronValidationResult,
} from "./types.js";
const PLACEHOLDER = "0 0 * * * *";
const Cron: FC<CronProps> = ({
value = "",
onChange,
activeMode = "input",
onActiveModeChange,
onValidate,
className,
mode = "both",
disabled = false,
placeholder = PLACEHOLDER,
showPreview = true,
showDescription = true,
timezone = "UTC",
error,
children,
showHelp = true,
displayPeriods,
defaultTab,
presets,
showPresets,
withCard = true,
isFirstSibling = false,
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: false
}) => {
const [internalValue, setInternalValue] = useState(value || "");
const [internalActiveMode, setInternalActiveMode] =
useState<CronPrimitiveMode>(
mode === CronMode.Both ? activeMode : (mode as CronPrimitiveMode)
);
const [copied, setCopied] = useState(false);
const validationResult = useMemo((): CronValidationResult => {
if (!internalValue.trim()) {
return { isValid: false, error: "Expression is required", isEmpty: true };
}
try {
parse(`${internalValue} *`, { hasSeconds: true });
return { isValid: true };
} catch (parseError) {
return {
isValid: false,
error:
parseError instanceof Error
? parseError.message
: "Invalid cron expression",
};
}
}, [internalValue]);
useEffect(() => {
setInternalValue(value || "");
}, [value]);
useEffect(() => {
onValidate?.(validationResult.isValid);
}, [validationResult.isValid, onValidate]);
useEffect(() => {
if (mode === "both") {
setInternalActiveMode(activeMode);
}
}, [activeMode, mode]);
const handleChange = useCallback(
(newValue: string) => {
setInternalValue(newValue);
onChange?.(newValue);
},
[onChange]
);
const handleActiveModeChange = useCallback(
(m: CronPrimitiveMode) => {
setInternalActiveMode(m);
onActiveModeChange?.(m);
},
[onActiveModeChange]
);
const handleCopy = useCallback(async () => {
if (!internalValue) {
return;
}
try {
await navigator.clipboard.writeText(internalValue);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.warn("Failed to copy to clipboard:", e);
}
}, [internalValue]);
const hasError =
!!error || !!(!validationResult.isValid && internalValue.trim());
if (mode === "input") {
return (
<div className={cn(withCard && "space-y-4", className)}>
<CronInput
value={internalValue}
onChange={handleChange}
onValidate={onValidate}
placeholder={placeholder}
disabled={disabled}
error={error}
/>
{showPreview &&
(validationResult.isValid || validationResult.isEmpty) && (
<CronDisplay
expression={
validationResult.isEmpty ? placeholder : internalValue
}
showNextRuns={true}
showDescription={showDescription}
timezone={timezone}
nextRunsCount={3}
withCard={withCard}
/>
)}
{children}
</div>
);
}
if (mode === "builder") {
return (
<div className={cn(withCard && "space-y-4", className)}>
<CronBuilder
value={internalValue}
onChange={handleChange}
disabled={disabled}
showPreview={showPreview}
displayPeriods={displayPeriods}
defaultTab={defaultTab}
presets={presets}
showPresets={showPresets}
showGeneratedExpression={true}
timezone={timezone}
withCard={withCard}
/>
{children}
</div>
);
}
return (
<div className={cn(withCard && "space-y-6", className)}>
<Card
className={cn(
!withCard && "border-none shadow-none",
!withCard && isFirstSibling && "pt-0"
)}
>
<CardHeader className={cn(!withCard && "px-0")}>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Bolt className="h-4 w-4" />
Cron Expression Builder
</CardTitle>
<CardDescription className="text-sm">
Create and validate cron expressions using visual builder or
text input
</CardDescription>
</div>
{internalValue && (
<div className="flex items-center gap-2">
<Badge
variant={
validationResult.isValid ? "secondary" : "destructive"
}
className="font-mono text-sm"
>
{internalValue}
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={!internalValue || hasError}
className="h-8 px-2"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
{hasError && (
<div className="mt-3 flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
<span>{error || validationResult.error}</span>
</div>
)}
</CardHeader>
<CardContent className={cn(!withCard && "px-0")}>
<Tabs
value={internalActiveMode}
onValueChange={(v) =>
handleActiveModeChange(v as "input" | "builder")
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger
value="input"
className="flex min-w-fit items-center gap-1"
>
<Type className="h-4 w-4" />
Text Input
</TabsTrigger>
<TabsTrigger
value="builder"
className="flex min-w-fit items-center gap-1"
>
<Settings className="h-4 w-4" />
Visual Build
</TabsTrigger>
</TabsList>
<TabsContent value="input" className="mt-6 space-y-4">
<CronInput
value={internalValue}
onChange={handleChange}
onValidate={onValidate}
placeholder={placeholder}
disabled={disabled}
error={error}
/>
</TabsContent>
<TabsContent value="builder" className="mt-6">
<CronBuilder
value={internalValue}
onChange={handleChange}
disabled={disabled}
showPreview={false}
displayPeriods={displayPeriods}
defaultTab={defaultTab}
presets={presets}
showPresets={showPresets}
showGeneratedExpression={false}
timezone={timezone}
withCard={withCard}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Preview Section */}
{showPreview &&
(validationResult.isValid || validationResult.isEmpty) && (
<>
{!withCard && <Separator />}
<CronDisplay
expression={
validationResult.isEmpty ? placeholder : internalValue
}
showNextRuns={true}
showDescription={showDescription}
timezone={timezone}
nextRunsCount={3}
withCard={withCard}
/>
</>
)}
{/* Help Section */}
{showHelp && (
<>
{!withCard && <Separator />}
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Code2 className="h-4 w-4" />
Cron Expression Format
</CardTitle>
</CardHeader>
<CardContent className={cn(!withCard && "px-0")}>
<div className="space-y-4">
<div className="grid grid-cols-6 gap-2 text-center text-sm">
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Second
</div>
<div className="text-xs">0-59</div>
</div>
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Minute
</div>
<div className="text-xs">0-59</div>
</div>
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Hour
</div>
<div className="text-xs">0-23</div>
</div>
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Day
</div>
<div className="text-xs">1-31</div>
</div>
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Month
</div>
<div className="text-xs">1-12</div>
</div>
<div className="space-y-1">
<div className="font-medium font-mono text-muted-foreground">
Weekday
</div>
<div className="text-xs">0-6</div>
</div>
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
*
</Badge>
<span className="text-sm">Any value</span>
</div>
<div className="text-muted-foreground text-xs">
Matches all possible values
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
5
</Badge>
<span className="text-sm">Specific value</span>
</div>
<div className="text-muted-foreground text-xs">
Matches exactly this value
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
1-5
</Badge>
<span className="text-sm">Range</span>
</div>
<div className="text-muted-foreground text-xs">
Matches values 1 through 5
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
1,3,5
</Badge>
<span className="text-sm">List</span>
</div>
<div className="text-muted-foreground text-xs">
Matches values 1, 3, and 5
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
*/5
</Badge>
<span className="text-sm">Step</span>
</div>
<div className="text-muted-foreground text-xs">
Every 5th value
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
0-10/2
</Badge>
<span className="text-sm">Range + Step</span>
</div>
<div className="text-muted-foreground text-xs">
Even values 0-10
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
?
</Badge>
<span className="text-sm">No specific</span>
</div>
<div className="text-muted-foreground text-xs">
Used when day/weekday conflicts
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
L
</Badge>
<span className="text-sm">Last</span>
</div>
<div className="text-muted-foreground text-xs">
Last day of month/week
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<h4 className="font-medium text-sm">Common Examples:</h4>
<div className="grid gap-2 text-sm">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 0 * * * *
</Badge>
<span className="text-muted-foreground">Every hour</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 */15 * * * *
</Badge>
<span className="text-muted-foreground">
Every 15 minutes
</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 0 0 * * *
</Badge>
<span className="text-muted-foreground">
Daily at midnight
</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="font-mono text-xs">
0 30 9 * * 1-5
</Badge>
<span className="text-muted-foreground">
Weekdays at 9:30 AM
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</>
)}
{children}
</div>
);
};
export { Cron };

View File

@@ -0,0 +1,20 @@
export { Cron } from "./cron.js";
export { CronBuilder } from "./cron-builder.js";
export { CronDisplay } from "./cron-display.js";
export { CronExample } from "./cron-example.js";
export { CronInput } from "./cron-input.js";
export {
type CronBuilderProps,
type CronDisplayProps,
type CronExpression,
CronField,
type CronFieldConfig,
type CronInputProps,
type CronNextRun,
CronPeriod,
type CronPreset,
type CronProps,
type CronValidationResult,
type PeriodConfig,
} from "./types.js";

View File

@@ -0,0 +1,163 @@
import type { ClassValue } from 'clsx';
import type { ReactNode } from 'react';
export interface CronExpression {
seconds?: string;
minutes?: string;
hours?: string;
dayOfMonth?: string;
month?: string;
dayOfWeek?: string;
year?: string;
}
export interface CronDisplayProps {
expression: string;
className?: ClassValue;
showNextRuns?: boolean;
nextRunsCount?: number;
timezone?: string;
showDescription?: boolean;
withCard?: boolean;
}
export interface CronInputProps {
value?: string;
onChange?: (value: string) => void;
onValidate?: (isValid: boolean) => void;
placeholder?: string;
className?: ClassValue;
disabled?: boolean;
readOnly?: boolean;
error?: string;
}
export interface CronBuilderProps {
value?: string;
onChange?: (value: string) => void;
className?: ClassValue;
disabled?: boolean;
showPreview?: boolean;
defaultTab?: CronPeriod;
displayPeriods?: CronPeriod[];
presets?: CronPreset[];
showPresets?: boolean;
showGeneratedExpression?: boolean;
timezone?: string;
withCard?: boolean;
}
export const CronPrimitiveMode = {
Input: 'input',
Builder: 'builder',
} as const;
export type CronPrimitiveMode =
(typeof CronPrimitiveMode)[keyof typeof CronPrimitiveMode];
export const CronMode = {
Input: 'input',
Builder: 'builder',
Both: 'both',
} as const;
export type CronMode = (typeof CronMode)[keyof typeof CronMode];
export interface CronProps {
value?: string;
onChange?: (value: string) => void;
activeMode?: CronPrimitiveMode;
onActiveModeChange?: (mode: CronPrimitiveMode) => void;
onValidate?: (isValid: boolean) => void;
className?: ClassValue;
mode?: CronMode;
disabled?: boolean;
placeholder?: string;
showPreview?: boolean;
showDescription?: boolean;
timezone?: string;
error?: string;
children?: ReactNode;
defaultTab?: CronPeriod;
displayPeriods?: CronPeriod[];
presets?: CronPreset[];
showHelp?: boolean;
showPresets?: boolean;
withCard?: boolean;
isFirstSibling?: boolean;
}
export const CronPeriod = {
Minute: 'minute',
Hourly: 'hourly',
Daily: 'daily',
Weekly: 'weekly',
Monthly: 'monthly',
Yearly: 'yearly',
Custom: 'custom',
} as const;
export type CronPeriod = (typeof CronPeriod)[keyof typeof CronPeriod];
export interface CronFieldProps {
period: CronPeriod;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: ClassValue;
}
export interface CronPreset {
label: string;
value: string;
description: string;
category?: string;
}
export interface CronValidationResult {
isValid: boolean;
error?: string;
description?: string;
isEmpty?: boolean;
}
export interface CronNextRun {
date: Date;
timestamp: number;
formatted: string;
relative: string;
}
export interface PeriodConfig {
label: string;
description: string;
defaultValue: string;
fields: {
seconds?: boolean;
minutes?: boolean;
hours?: boolean;
dayOfMonth?: boolean;
month?: boolean;
dayOfWeek?: boolean;
};
}
export const CronField = {
Seconds: 'seconds',
Minutes: 'minutes',
Hours: 'hours',
DayOfMonth: 'dayOfMonth',
Month: 'month',
DayOfWeek: 'dayOfWeek',
Year: 'year',
} as const;
export type CronField = (typeof CronField)[keyof typeof CronField];
export interface CronFieldConfig {
min: number;
max: number;
step?: number;
options?: Array<{ label: string; value: number | string }>;
allowSpecial?: string[];
}

View File

@@ -1,5 +1,5 @@
import { Card, CardContent, CardHeader } from './ui/card'; import { Card, CardContent, CardHeader } from "./card";
import { Skeleton } from './ui/skeleton'; import { Skeleton } from "./skeleton";
export function DetailCardSkeleton() { export function DetailCardSkeleton() {
return ( return (

View File

@@ -1,20 +1,32 @@
import { type LinkComponent, createLink } from "@tanstack/react-router"; import { createLink, type LinkComponentProps } from "@tanstack/react-router";
import type { AnchorHTMLAttributes, ComponentProps } from "react"; import type { AnchorHTMLAttributes } from "react";
export interface BasicLinkProps export interface BasicLinkProps
extends AnchorHTMLAttributes<HTMLAnchorElement> {} extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string;
to?: undefined;
}
const BasicLinkComponent = (props: ComponentProps<"a">) => { const BasicLinkComponent = (props: BasicLinkProps) => {
return <a {...props} />; return <a {...props} />;
}; };
const CreatedLinkComponent = createLink(BasicLinkComponent); const CreatedLinkComponent = createLink(BasicLinkComponent);
export const ProLink: LinkComponent<typeof BasicLinkComponent> = (props) => { export const ProLink = (
props: LinkComponentProps<typeof BasicLinkComponent> | BasicLinkProps
) => {
if (props.href) { if (props.href) {
return <BasicLinkComponent {...(props as any)} />; return <BasicLinkComponent {...(props as any)} />;
} }
return <CreatedLinkComponent preload={"intent"} {...props} />; return (
<CreatedLinkComponent
preload={"intent"}
{...(props as LinkComponentProps<typeof BasicLinkComponent>)}
/>
);
}; };
export type ProLinkProps = ComponentProps<typeof ProLink>; export type ProLinkProps =
| LinkComponentProps<typeof BasicLinkComponent>
| BasicLinkProps;

View File

@@ -1,6 +1,6 @@
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
export interface QueryErrorViewProps { export interface QueryErrorViewProps {
title?: string; title?: string;

View File

@@ -0,0 +1 @@
export { provideRecorder } from './context';

View File

@@ -0,0 +1,105 @@
import { gql } from '@apollo/client';
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
export const GET_CRONS = gql`
query GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {
cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {
nodes {
id
cronExpr
nextRun
lastRun
lastError
status
lockedAt
lockedBy
createdAt
updatedAt
timeoutMs
maxAttempts
priority
attempts
enabled
subscriberTaskCron
subscriberTask {
nodes {
id,
job,
taskType,
status,
attempts,
maxAttempts,
runAt,
lastError,
lockAt,
lockBy,
doneAt,
priority,
subscription {
displayName
sourceUrl
}
}
}
}
paginationInfo {
total
pages
}
}
}
`;
export type CronDto = GetCronsQuery['cron']['nodes'][number];
export const DELETE_CRONS = gql`
mutation DeleteCrons($filter: CronFilterInput!) {
cronDelete(filter: $filter)
}
`;
export const UPDATE_CRONS = gql`
mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {
cronUpdate(filter: $filter, data: $data) {
id
cronExpr
nextRun
lastRun
lastError
status
lockedAt
lockedBy
createdAt
updatedAt
timeoutMs
enabled
maxAttempts
priority
attempts
subscriberTaskCron
}
}
`;
export const INSERT_CRON = gql`
mutation InsertCron($data: CronInsertInput!) {
cronCreateOne(data: $data) {
id
cronExpr
nextRun
lastRun
lastError
status
lockedAt
lockedBy
createdAt
updatedAt
enabled
timeoutMs
maxAttempts
priority
attempts
subscriberTaskCron
}
}
`;

View File

@@ -1,16 +1,16 @@
import { gql } from '@apollo/client';
import { type } from 'arktype';
import { arkValidatorToTypeNarrower } from '@/infra/errors/arktype'; import { arkValidatorToTypeNarrower } from '@/infra/errors/arktype';
import { import {
type GetSubscriptionsQuery, type GetSubscriptionsQuery,
SubscriptionCategoryEnum, SubscriptionCategoryEnum,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import { gql } from '@apollo/client';
import { type } from 'arktype';
import { import {
extractMikanSubscriptionBangumiSourceUrl,
extractMikanSubscriptionSubscriberSourceUrl,
MikanSubscriptionBangumiSourceUrlSchema, MikanSubscriptionBangumiSourceUrlSchema,
MikanSubscriptionSeasonSourceUrlSchema, MikanSubscriptionSeasonSourceUrlSchema,
MikanSubscriptionSubscriberSourceUrlSchema, MikanSubscriptionSubscriberSourceUrlSchema,
extractMikanSubscriptionBangumiSourceUrl,
extractMikanSubscriptionSubscriberSourceUrl,
} from './mikan'; } from './mikan';
export const GET_SUBSCRIPTIONS = gql` export const GET_SUBSCRIPTIONS = gql`
@@ -83,10 +83,8 @@ export const DELETE_SUBSCRIPTIONS = gql`
`; `;
export const GET_SUBSCRIPTION_DETAIL = gql` export const GET_SUBSCRIPTION_DETAIL = gql`
query GetSubscriptionDetail ($id: Int!) { query GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {
subscriptions(filter: { id: { subscriptions(filter: $filter) {
eq: $id
} }) {
nodes { nodes {
id id
subscriberId subscriberId
@@ -106,7 +104,15 @@ query GetSubscriptionDetail ($id: Int!) {
feedSource feedSource
} }
} }
subscriberTask { subscriberTask(pagination: {
page: {
page: 0,
limit: 3,
}
},
orderBy: {
runAt: DESC,
}) {
nodes { nodes {
id id
taskType taskType
@@ -117,6 +123,34 @@ query GetSubscriptionDetail ($id: Int!) {
id id
username username
} }
cron (pagination: {
page: {
page: 0,
limit: 3,
}
},
orderBy: {
createdAt: DESC,
}) {
nodes {
id
cronExpr
nextRun
lastRun
lastError
enabled
status
lockedAt
lockedBy
createdAt
updatedAt
timeoutMs
maxAttempts
priority
attempts
subscriberTaskCron
}
}
bangumi { bangumi {
nodes { nodes {
createdAt createdAt

View File

@@ -1,5 +1,5 @@
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
export const GET_TASKS = gql` export const GET_TASKS = gql`
query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) { query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {
@@ -25,6 +25,22 @@ export const GET_TASKS = gql`
displayName displayName
sourceUrl sourceUrl
} }
cron {
id
cronExpr
nextRun
lastRun
lastError
status
lockedAt
lockedBy
createdAt
updatedAt
timeoutMs
maxAttempts
priority
attempts
}
} }
paginationInfo { paginationInfo {
total total

View File

@@ -0,0 +1,30 @@
type AllKeys<T> = T extends any ? keyof T : never;
type ToDefaultable<T> = Exclude<
T extends string | undefined
? T | ''
: T extends number | undefined
? T | number
: T extends undefined
? T | null
: T,
undefined
>;
type PickFieldFormUnion<T, K extends keyof T> = T extends any
? T[keyof T & K]
: never;
// compact more types;
export type FormDefaultValues<T> = {
-readonly [K in AllKeys<T>]-?: ToDefaultable<PickFieldFormUnion<T, K>>;
};
/**
* https://github.com/shadcn-ui/ui/issues/427
*/
export function compatFormDefaultValues<T, K extends AllKeys<T> = AllKeys<T>>(
d: FormDefaultValues<Pick<T, K>>
): T {
return d as unknown as T;
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable */ /* eslint-disable */
import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import { FragmentDefinitionNode } from 'graphql'; import type { FragmentDefinitionNode } from 'graphql';
import { Incremental } from './graphql'; import type { Incremental } from './graphql';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration< export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<

View File

@@ -1,6 +1,6 @@
/* eslint-disable */ /* eslint-disable */
import * as types from './graphql'; import * as types from './graphql';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/** /**
* Map of all GraphQL operations in the project. * Map of all GraphQL operations in the project.
@@ -20,14 +20,18 @@ type Documents = {
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": typeof types.DeleteCredential3rdDocument, "\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": typeof types.DeleteCredential3rdDocument,
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": typeof types.GetCredential3rdDetailDocument, "\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": typeof types.GetCredential3rdDetailDocument,
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": typeof types.CheckCredential3rdAvailableDocument, "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": typeof types.CheckCredential3rdAvailableDocument,
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n enabled\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetCronsDocument,
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": typeof types.DeleteCronsDocument,
"\n mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": typeof types.UpdateCronsDocument,
"\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n enabled\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": typeof types.InsertCronDocument,
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": typeof types.InsertFeedDocument, "\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": typeof types.InsertFeedDocument,
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": typeof types.DeleteFeedDocument, "\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": typeof types.DeleteFeedDocument,
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetSubscriptionsDocument, "\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetSubscriptionsDocument,
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument, "\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument,
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument, "\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument,
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": typeof types.DeleteSubscriptionsDocument, "\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": typeof types.DeleteSubscriptionsDocument,
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument, "\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument, "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument,
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": typeof types.InsertSubscriberTaskDocument, "\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": typeof types.InsertSubscriberTaskDocument,
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": typeof types.DeleteTasksDocument, "\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": typeof types.DeleteTasksDocument,
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": typeof types.RetryTasksDocument, "\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": typeof types.RetryTasksDocument,
@@ -39,14 +43,18 @@ const documents: Documents = {
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": types.DeleteCredential3rdDocument, "\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": types.DeleteCredential3rdDocument,
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": types.GetCredential3rdDetailDocument, "\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": types.GetCredential3rdDetailDocument,
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": types.CheckCredential3rdAvailableDocument, "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": types.CheckCredential3rdAvailableDocument,
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n enabled\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetCronsDocument,
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": types.DeleteCronsDocument,
"\n mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": types.UpdateCronsDocument,
"\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n enabled\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": types.InsertCronDocument,
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": types.InsertFeedDocument, "\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": types.InsertFeedDocument,
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": types.DeleteFeedDocument, "\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": types.DeleteFeedDocument,
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetSubscriptionsDocument, "\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetSubscriptionsDocument,
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument, "\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument,
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument, "\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument,
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": types.DeleteSubscriptionsDocument, "\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": types.DeleteSubscriptionsDocument,
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument, "\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument, "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument,
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": types.InsertSubscriberTaskDocument, "\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": types.InsertSubscriberTaskDocument,
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": types.DeleteTasksDocument, "\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": types.DeleteTasksDocument,
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": types.RetryTasksDocument, "\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": types.RetryTasksDocument,
@@ -90,6 +98,22 @@ export function gql(source: "\n query GetCredential3rdDetail($id: Int!) {\n
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function gql(source: "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"): (typeof documents)["\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"]; export function gql(source: "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"): (typeof documents)["\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n enabled\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n enabled\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"): (typeof documents)["\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"): (typeof documents)["\n mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n enabled\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"): (typeof documents)["\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n enabled\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"];
/** /**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
@@ -117,11 +141,11 @@ export function gql(source: "\n mutation DeleteSubscriptions($filter: Subscri
/** /**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function gql(source: "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"]; export function gql(source: "\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"];
/** /**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"]; export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"];
/** /**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider'; import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context'; import { setContext } from '@apollo/client/link/context';
import { Injectable, inject } from '@outposts/injection-js'; import { Injectable, inject } from '@outposts/injection-js';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider';
@Injectable() @Injectable()
export class GraphQLService { export class GraphQLService {
@@ -27,6 +27,8 @@ export class GraphQLService {
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'network-only', nextFetchPolicy: 'network-only',
errorPolicy: 'all', errorPolicy: 'all',
refetchWritePolicy: 'overwrite',
initialFetchPolicy: 'cache-and-network',
}, },
query: { query: {
fetchPolicy: 'network-only', fetchPolicy: 'network-only',

View File

@@ -1,2 +1,2 @@
export { GraphQLService } from './graphql.service'; export { GraphQLService } from './graphql.service';
export { provideGraphql } from './context'; export { provideGraphql, graphqlContextFromInjector } from './context';

View File

@@ -1,6 +1,10 @@
import type { Injector } from '@outposts/injection-js'; import type { Injector, Provider } from '@outposts/injection-js';
import { IntlService } from './intl.service'; import { IntlService } from './intl.service';
export function provideIntl(): Provider[] {
return [IntlService];
}
export function intlContextFromInjector(injector: Injector) { export function intlContextFromInjector(injector: Injector) {
const intlService = injector.get(IntlService); const intlService = injector.get(IntlService);

View File

@@ -1,9 +0,0 @@
import { useInjector } from 'oidc-client-rx/adapters/react';
import { useMemo } from 'react';
import { intlContextFromInjector } from './context';
export function useIntl() {
const injector = useInjector();
return useMemo(() => intlContextFromInjector(injector), [injector]);
}

View File

@@ -0,0 +1,2 @@
export { IntlService } from './intl.service';
export { intlContextFromInjector, provideIntl } from './context';

View File

@@ -4,7 +4,18 @@ import { DOCUMENT } from '../platform/injection';
export class IntlService { export class IntlService {
document = inject(DOCUMENT); document = inject(DOCUMENT);
formatTimestamp(timestamp: number, options?: Intl.DateTimeFormatOptions) { get Intl(): typeof Intl {
return this.document.defaultView?.Intl as typeof Intl;
}
get timezone() {
return this.Intl.DateTimeFormat().resolvedOptions().timeZone;
}
formatDatetimeWithTz(
timestamp: number | string | Date,
options?: Intl.DateTimeFormatOptions
) {
const defaultOptions: Intl.DateTimeFormatOptions = { const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
@@ -16,7 +27,7 @@ export class IntlService {
...options, ...options,
}; };
return new Intl.DateTimeFormat( return new this.Intl.DateTimeFormat(
this.document.defaultView?.navigator.language, this.document.defaultView?.navigator.language,
{ {
...defaultOptions, ...defaultOptions,

View File

@@ -1,6 +1,6 @@
import type { ProLinkProps } from '@/components/ui/pro-link';
import { type } from 'arktype'; import { type } from 'arktype';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import type { ProLinkProps } from '@/components/ui/pro-link';
export interface NavMainItem { export interface NavMainItem {
link?: ProLinkProps; link?: ProLinkProps;

View File

@@ -1,6 +1,6 @@
import { guardRouteIndexAsNotFound } from '@/components/layout/app-not-found'; import { guardRouteIndexAsNotFound } from '@/components/layout/app-not-found';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { Outlet } from '@tanstack/react-router'; import { Outlet, type RouteOptions } from '@tanstack/react-router';
export interface BuildVirtualBranchRouteOptions { export interface BuildVirtualBranchRouteOptions {
title: string; title: string;
@@ -8,7 +8,11 @@ export interface BuildVirtualBranchRouteOptions {
export function buildVirtualBranchRouteOptions( export function buildVirtualBranchRouteOptions(
options: BuildVirtualBranchRouteOptions options: BuildVirtualBranchRouteOptions
) { ): {
beforeLoad: RouteOptions['beforeLoad'];
staticData: RouteStateDataOption;
component: RouteOptions['component'];
} {
return { return {
beforeLoad: guardRouteIndexAsNotFound, beforeLoad: guardRouteIndexAsNotFound,
staticData: { staticData: {

View File

@@ -14,9 +14,9 @@ import {
import { Suspense } from 'react'; import { Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './app.css'; import './app.css';
import { provideRecorder } from '@/domains/recorder/context'; import { provideRecorder } from '@/domains/recorder';
import { provideGraphql } from '@/infra/graphql'; import { graphqlContextFromInjector, provideGraphql } from '@/infra/graphql';
import { graphqlContextFromInjector } from '@/infra/graphql/context'; import { provideIntl } from '@/infra/intl';
import { ApolloProvider } from '@apollo/client'; import { ApolloProvider } from '@apollo/client';
// Create a new router instance // Create a new router instance
@@ -46,6 +46,7 @@ const injector: Injector = ReflectiveInjector.resolveAndCreate([
...provideStyles(), ...provideStyles(),
...provideGraphql(), ...provideGraphql(),
...provideRecorder(), ...provideRecorder(),
...provideIntl(),
]); ]);
setupAuthContext(injector); setupAuthContext(injector);

View File

@@ -31,11 +31,15 @@ import { Route as AppCredential3rdManageRouteImport } from './routes/_app/creden
import { Route as AppCredential3rdCreateRouteImport } from './routes/_app/credential3rd/create' import { Route as AppCredential3rdCreateRouteImport } from './routes/_app/credential3rd/create'
import { Route as AppBangumiManageRouteImport } from './routes/_app/bangumi/manage' import { Route as AppBangumiManageRouteImport } from './routes/_app/bangumi/manage'
import { Route as AppExploreExploreRouteImport } from './routes/_app/_explore/explore' import { Route as AppExploreExploreRouteImport } from './routes/_app/_explore/explore'
import { Route as AppTasksCronRouteRouteImport } from './routes/_app/tasks/cron/route'
import { Route as AppTasksDetailIdRouteImport } from './routes/_app/tasks/detail.$id' import { Route as AppTasksDetailIdRouteImport } from './routes/_app/tasks/detail.$id'
import { Route as AppTasksCronManageRouteImport } from './routes/_app/tasks/cron/manage'
import { Route as AppSubscriptionsEditIdRouteImport } from './routes/_app/subscriptions/edit.$id' import { Route as AppSubscriptionsEditIdRouteImport } from './routes/_app/subscriptions/edit.$id'
import { Route as AppSubscriptionsDetailIdRouteImport } from './routes/_app/subscriptions/detail.$id' import { Route as AppSubscriptionsDetailIdRouteImport } from './routes/_app/subscriptions/detail.$id'
import { Route as AppCredential3rdEditIdRouteImport } from './routes/_app/credential3rd/edit.$id' import { Route as AppCredential3rdEditIdRouteImport } from './routes/_app/credential3rd/edit.$id'
import { Route as AppCredential3rdDetailIdRouteImport } from './routes/_app/credential3rd/detail.$id' import { Route as AppCredential3rdDetailIdRouteImport } from './routes/_app/credential3rd/detail.$id'
import { Route as AppTasksCronEditIdRouteImport } from './routes/_app/tasks/cron/edit.$id'
import { Route as AppTasksCronDetailIdRouteImport } from './routes/_app/tasks/cron/detail.$id'
const AboutRoute = AboutRouteImport.update({ const AboutRoute = AboutRouteImport.update({
id: '/about', id: '/about',
@@ -148,11 +152,21 @@ const AppExploreExploreRoute = AppExploreExploreRouteImport.update({
path: '/explore', path: '/explore',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } as any)
const AppTasksCronRouteRoute = AppTasksCronRouteRouteImport.update({
id: '/cron',
path: '/cron',
getParentRoute: () => AppTasksRouteRoute,
} as any)
const AppTasksDetailIdRoute = AppTasksDetailIdRouteImport.update({ const AppTasksDetailIdRoute = AppTasksDetailIdRouteImport.update({
id: '/detail/$id', id: '/detail/$id',
path: '/detail/$id', path: '/detail/$id',
getParentRoute: () => AppTasksRouteRoute, getParentRoute: () => AppTasksRouteRoute,
} as any) } as any)
const AppTasksCronManageRoute = AppTasksCronManageRouteImport.update({
id: '/manage',
path: '/manage',
getParentRoute: () => AppTasksCronRouteRoute,
} as any)
const AppSubscriptionsEditIdRoute = AppSubscriptionsEditIdRouteImport.update({ const AppSubscriptionsEditIdRoute = AppSubscriptionsEditIdRouteImport.update({
id: '/edit/$id', id: '/edit/$id',
path: '/edit/$id', path: '/edit/$id',
@@ -175,6 +189,16 @@ const AppCredential3rdDetailIdRoute =
path: '/detail/$id', path: '/detail/$id',
getParentRoute: () => AppCredential3rdRouteRoute, getParentRoute: () => AppCredential3rdRouteRoute,
} as any) } as any)
const AppTasksCronEditIdRoute = AppTasksCronEditIdRouteImport.update({
id: '/edit/$id',
path: '/edit/$id',
getParentRoute: () => AppTasksCronRouteRoute,
} as any)
const AppTasksCronDetailIdRoute = AppTasksCronDetailIdRouteImport.update({
id: '/detail/$id',
path: '/detail/$id',
getParentRoute: () => AppTasksCronRouteRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -189,6 +213,7 @@ export interface FileRoutesByFullPath {
'/tasks': typeof AppTasksRouteRouteWithChildren '/tasks': typeof AppTasksRouteRouteWithChildren
'/auth/sign-in': typeof AuthSignInRoute '/auth/sign-in': typeof AuthSignInRoute
'/auth/sign-up': typeof AuthSignUpRoute '/auth/sign-up': typeof AuthSignUpRoute
'/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
'/explore': typeof AppExploreExploreRoute '/explore': typeof AppExploreExploreRoute
'/bangumi/manage': typeof AppBangumiManageRoute '/bangumi/manage': typeof AppBangumiManageRoute
'/credential3rd/create': typeof AppCredential3rdCreateRoute '/credential3rd/create': typeof AppCredential3rdCreateRoute
@@ -203,7 +228,10 @@ export interface FileRoutesByFullPath {
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute '/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute '/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute '/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
'/tasks/cron/manage': typeof AppTasksCronManageRoute
'/tasks/detail/$id': typeof AppTasksDetailIdRoute '/tasks/detail/$id': typeof AppTasksDetailIdRoute
'/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
'/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -218,6 +246,7 @@ export interface FileRoutesByTo {
'/tasks': typeof AppTasksRouteRouteWithChildren '/tasks': typeof AppTasksRouteRouteWithChildren
'/auth/sign-in': typeof AuthSignInRoute '/auth/sign-in': typeof AuthSignInRoute
'/auth/sign-up': typeof AuthSignUpRoute '/auth/sign-up': typeof AuthSignUpRoute
'/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
'/explore': typeof AppExploreExploreRoute '/explore': typeof AppExploreExploreRoute
'/bangumi/manage': typeof AppBangumiManageRoute '/bangumi/manage': typeof AppBangumiManageRoute
'/credential3rd/create': typeof AppCredential3rdCreateRoute '/credential3rd/create': typeof AppCredential3rdCreateRoute
@@ -232,7 +261,10 @@ export interface FileRoutesByTo {
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute '/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute '/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute '/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
'/tasks/cron/manage': typeof AppTasksCronManageRoute
'/tasks/detail/$id': typeof AppTasksDetailIdRoute '/tasks/detail/$id': typeof AppTasksDetailIdRoute
'/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
'/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -248,6 +280,7 @@ export interface FileRoutesById {
'/_app/tasks': typeof AppTasksRouteRouteWithChildren '/_app/tasks': typeof AppTasksRouteRouteWithChildren
'/auth/sign-in': typeof AuthSignInRoute '/auth/sign-in': typeof AuthSignInRoute
'/auth/sign-up': typeof AuthSignUpRoute '/auth/sign-up': typeof AuthSignUpRoute
'/_app/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
'/_app/_explore/explore': typeof AppExploreExploreRoute '/_app/_explore/explore': typeof AppExploreExploreRoute
'/_app/bangumi/manage': typeof AppBangumiManageRoute '/_app/bangumi/manage': typeof AppBangumiManageRoute
'/_app/credential3rd/create': typeof AppCredential3rdCreateRoute '/_app/credential3rd/create': typeof AppCredential3rdCreateRoute
@@ -262,7 +295,10 @@ export interface FileRoutesById {
'/_app/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute '/_app/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
'/_app/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute '/_app/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
'/_app/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute '/_app/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
'/_app/tasks/cron/manage': typeof AppTasksCronManageRoute
'/_app/tasks/detail/$id': typeof AppTasksDetailIdRoute '/_app/tasks/detail/$id': typeof AppTasksDetailIdRoute
'/_app/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
'/_app/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -279,6 +315,7 @@ export interface FileRouteTypes {
| '/tasks' | '/tasks'
| '/auth/sign-in' | '/auth/sign-in'
| '/auth/sign-up' | '/auth/sign-up'
| '/tasks/cron'
| '/explore' | '/explore'
| '/bangumi/manage' | '/bangumi/manage'
| '/credential3rd/create' | '/credential3rd/create'
@@ -293,7 +330,10 @@ export interface FileRouteTypes {
| '/credential3rd/edit/$id' | '/credential3rd/edit/$id'
| '/subscriptions/detail/$id' | '/subscriptions/detail/$id'
| '/subscriptions/edit/$id' | '/subscriptions/edit/$id'
| '/tasks/cron/manage'
| '/tasks/detail/$id' | '/tasks/detail/$id'
| '/tasks/cron/detail/$id'
| '/tasks/cron/edit/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -308,6 +348,7 @@ export interface FileRouteTypes {
| '/tasks' | '/tasks'
| '/auth/sign-in' | '/auth/sign-in'
| '/auth/sign-up' | '/auth/sign-up'
| '/tasks/cron'
| '/explore' | '/explore'
| '/bangumi/manage' | '/bangumi/manage'
| '/credential3rd/create' | '/credential3rd/create'
@@ -322,7 +363,10 @@ export interface FileRouteTypes {
| '/credential3rd/edit/$id' | '/credential3rd/edit/$id'
| '/subscriptions/detail/$id' | '/subscriptions/detail/$id'
| '/subscriptions/edit/$id' | '/subscriptions/edit/$id'
| '/tasks/cron/manage'
| '/tasks/detail/$id' | '/tasks/detail/$id'
| '/tasks/cron/detail/$id'
| '/tasks/cron/edit/$id'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -337,6 +381,7 @@ export interface FileRouteTypes {
| '/_app/tasks' | '/_app/tasks'
| '/auth/sign-in' | '/auth/sign-in'
| '/auth/sign-up' | '/auth/sign-up'
| '/_app/tasks/cron'
| '/_app/_explore/explore' | '/_app/_explore/explore'
| '/_app/bangumi/manage' | '/_app/bangumi/manage'
| '/_app/credential3rd/create' | '/_app/credential3rd/create'
@@ -351,7 +396,10 @@ export interface FileRouteTypes {
| '/_app/credential3rd/edit/$id' | '/_app/credential3rd/edit/$id'
| '/_app/subscriptions/detail/$id' | '/_app/subscriptions/detail/$id'
| '/_app/subscriptions/edit/$id' | '/_app/subscriptions/edit/$id'
| '/_app/tasks/cron/manage'
| '/_app/tasks/detail/$id' | '/_app/tasks/detail/$id'
| '/_app/tasks/cron/detail/$id'
| '/_app/tasks/cron/edit/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -520,6 +568,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppExploreExploreRouteImport preLoaderRoute: typeof AppExploreExploreRouteImport
parentRoute: typeof AppRouteRoute parentRoute: typeof AppRouteRoute
} }
'/_app/tasks/cron': {
id: '/_app/tasks/cron'
path: '/cron'
fullPath: '/tasks/cron'
preLoaderRoute: typeof AppTasksCronRouteRouteImport
parentRoute: typeof AppTasksRouteRoute
}
'/_app/tasks/detail/$id': { '/_app/tasks/detail/$id': {
id: '/_app/tasks/detail/$id' id: '/_app/tasks/detail/$id'
path: '/detail/$id' path: '/detail/$id'
@@ -527,6 +582,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppTasksDetailIdRouteImport preLoaderRoute: typeof AppTasksDetailIdRouteImport
parentRoute: typeof AppTasksRouteRoute parentRoute: typeof AppTasksRouteRoute
} }
'/_app/tasks/cron/manage': {
id: '/_app/tasks/cron/manage'
path: '/manage'
fullPath: '/tasks/cron/manage'
preLoaderRoute: typeof AppTasksCronManageRouteImport
parentRoute: typeof AppTasksCronRouteRoute
}
'/_app/subscriptions/edit/$id': { '/_app/subscriptions/edit/$id': {
id: '/_app/subscriptions/edit/$id' id: '/_app/subscriptions/edit/$id'
path: '/edit/$id' path: '/edit/$id'
@@ -555,6 +617,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppCredential3rdDetailIdRouteImport preLoaderRoute: typeof AppCredential3rdDetailIdRouteImport
parentRoute: typeof AppCredential3rdRouteRoute parentRoute: typeof AppCredential3rdRouteRoute
} }
'/_app/tasks/cron/edit/$id': {
id: '/_app/tasks/cron/edit/$id'
path: '/edit/$id'
fullPath: '/tasks/cron/edit/$id'
preLoaderRoute: typeof AppTasksCronEditIdRouteImport
parentRoute: typeof AppTasksCronRouteRoute
}
'/_app/tasks/cron/detail/$id': {
id: '/_app/tasks/cron/detail/$id'
path: '/detail/$id'
fullPath: '/tasks/cron/detail/$id'
preLoaderRoute: typeof AppTasksCronDetailIdRouteImport
parentRoute: typeof AppTasksCronRouteRoute
}
} }
} }
@@ -630,12 +706,29 @@ const AppSubscriptionsRouteRouteWithChildren =
AppSubscriptionsRouteRouteChildren, AppSubscriptionsRouteRouteChildren,
) )
interface AppTasksCronRouteRouteChildren {
AppTasksCronManageRoute: typeof AppTasksCronManageRoute
AppTasksCronDetailIdRoute: typeof AppTasksCronDetailIdRoute
AppTasksCronEditIdRoute: typeof AppTasksCronEditIdRoute
}
const AppTasksCronRouteRouteChildren: AppTasksCronRouteRouteChildren = {
AppTasksCronManageRoute: AppTasksCronManageRoute,
AppTasksCronDetailIdRoute: AppTasksCronDetailIdRoute,
AppTasksCronEditIdRoute: AppTasksCronEditIdRoute,
}
const AppTasksCronRouteRouteWithChildren =
AppTasksCronRouteRoute._addFileChildren(AppTasksCronRouteRouteChildren)
interface AppTasksRouteRouteChildren { interface AppTasksRouteRouteChildren {
AppTasksCronRouteRoute: typeof AppTasksCronRouteRouteWithChildren
AppTasksManageRoute: typeof AppTasksManageRoute AppTasksManageRoute: typeof AppTasksManageRoute
AppTasksDetailIdRoute: typeof AppTasksDetailIdRoute AppTasksDetailIdRoute: typeof AppTasksDetailIdRoute
} }
const AppTasksRouteRouteChildren: AppTasksRouteRouteChildren = { const AppTasksRouteRouteChildren: AppTasksRouteRouteChildren = {
AppTasksCronRouteRoute: AppTasksCronRouteRouteWithChildren,
AppTasksManageRoute: AppTasksManageRoute, AppTasksManageRoute: AppTasksManageRoute,
AppTasksDetailIdRoute: AppTasksDetailIdRoute, AppTasksDetailIdRoute: AppTasksDetailIdRoute,
} }

View File

@@ -1,3 +1,13 @@
import { useMutation } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { type } from 'arktype';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -6,6 +16,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { FormFieldErrors } from '@/components/ui/form-field-errors'; import { FormFieldErrors } from '@/components/ui/form-field-errors';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -23,7 +34,9 @@ import {
INSERT_CREDENTIAL_3RD, INSERT_CREDENTIAL_3RD,
} from '@/domains/recorder/schema/credential3rd'; } from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject'; import { useInject } from '@/infra/di/inject';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import { import {
type Credential3rdInsertInput,
Credential3rdTypeEnum, Credential3rdTypeEnum,
type InsertCredential3rdMutation, type InsertCredential3rdMutation,
type InsertCredential3rdMutationVariables, type InsertCredential3rdMutationVariables,
@@ -34,16 +47,6 @@ import {
CreateCompleteActionSchema, CreateCompleteActionSchema,
} from '@/infra/routes/nav'; } from '@/infra/routes/nav';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { type } from 'arktype';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
const RouteSearchSchema = type({ const RouteSearchSchema = type({
completeAction: CreateCompleteActionSchema.optional(), completeAction: CreateCompleteActionSchema.optional(),
@@ -97,21 +100,24 @@ function CredentialCreateRouteComponent() {
}); });
const form = useAppForm({ const form = useAppForm({
defaultValues: { defaultValues: compatFormDefaultValues<
Credential3rdInsertInput,
'credentialType' | 'username' | 'password' | 'userAgent'
>({
credentialType: Credential3rdTypeEnum.Mikan, credentialType: Credential3rdTypeEnum.Mikan,
username: '', username: '',
password: '', password: '',
userAgent: '', userAgent: '',
}, }),
validators: { validators: {
onChangeAsync: Credential3rdInsertSchema, onChangeAsync: Credential3rdInsertSchema,
onChangeAsyncDebounceMs: 300, onChangeAsyncDebounceMs: 300,
onSubmit: Credential3rdInsertSchema, onSubmit: Credential3rdInsertSchema,
}, },
onSubmit: async (form) => { onSubmit: async (submittedForm) => {
const value = { const value = {
...form.value, ...submittedForm.value,
userAgent: form.value.userAgent || platformService.userAgent, userAgent: submittedForm.value.userAgent || platformService.userAgent,
}; };
await insertCredential3rd({ await insertCredential3rd({
variables: { variables: {
@@ -123,14 +129,11 @@ function CredentialCreateRouteComponent() {
return ( return (
<div className="container mx-auto max-w-2xl py-6"> <div className="container mx-auto max-w-2xl py-6">
<div className="mb-6 flex items-center gap-4"> <ContainerHeader
<div> title="Create third-party credential"
<h1 className="font-bold text-2xl">Create third-party credential</h1> description="Add new third-party login credential"
<p className="mt-1 text-muted-foreground"> defaultBackTo="/credential3rd/manage"
Add new third-party login credential />
</p>
</div>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -185,7 +188,7 @@ function CredentialCreateRouteComponent() {
<Input <Input
id={field.name} id={field.name}
name={field.name} name={field.name}
value={field.state.value} value={field.state.value ?? ''}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter username" placeholder="Please enter username"
@@ -209,7 +212,7 @@ function CredentialCreateRouteComponent() {
id={field.name} id={field.name}
name={field.name} name={field.name}
type="password" type="password"
value={field.state.value} value={field.state.value ?? ''}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter password" placeholder="Please enter password"

View File

@@ -1,4 +1,7 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton'; import { useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -8,24 +11,18 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Dialog, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view'; import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { GET_CREDENTIAL_3RD_DETAIL } from '@/domains/recorder/schema/credential3rd'; import { GET_CREDENTIAL_3RD_DETAIL } from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject';
import type { GetCredential3rdDetailQuery } from '@/infra/graphql/gql/graphql'; import type { GetCredential3rdDetailQuery } from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useQuery } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { format } from 'date-fns/format';
import { ArrowLeft, CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
import { useState } from 'react';
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available'; import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
export const Route = createFileRoute('/_app/credential3rd/detail/$id')({ export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
@@ -38,26 +35,15 @@ export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
function Credential3rdDetailRouteComponent() { function Credential3rdDetailRouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const intlService = useInject(IntlService);
const canGoBack = useCanGoBack();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const handleBack = () => {
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/credential3rd/manage',
});
}
};
const { loading, error, data } = useQuery<GetCredential3rdDetailQuery>( const { loading, error, data } = useQuery<GetCredential3rdDetailQuery>(
GET_CREDENTIAL_3RD_DETAIL, GET_CREDENTIAL_3RD_DETAIL,
{ {
variables: { variables: {
id: Number.parseInt(id), id: Number.parseInt(id, 10),
}, },
} }
); );
@@ -91,31 +77,17 @@ function Credential3rdDetailRouteComponent() {
return ( return (
<div className="container mx-auto max-w-4xl py-6"> <div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between"> <ContainerHeader
<div className="flex items-center gap-4"> title="Credential Detail"
<Button description={`View credential #${credential.id}`}
variant="ghost" defaultBackTo="/credential3rd/manage"
size="sm" actions={
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">Credential detail</h1>
<p className="mt-1 text-muted-foreground">
View credential #{credential.id}
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleEnterEditMode}> <Button onClick={handleEnterEditMode}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
</Button> </Button>
</div> }
</div> />
<Card> <Card>
<CardHeader> <CardHeader>
@@ -207,10 +179,7 @@ function Credential3rdDetailRouteComponent() {
<Label className="font-medium text-sm">Created at</Label> <Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{format( {intlService.formatDatetimeWithTz(credential.createdAt)}
new Date(credential.createdAt),
'yyyy-MM-dd HH:mm:ss'
)}
</span> </span>
</div> </div>
</div> </div>
@@ -219,10 +188,7 @@ function Credential3rdDetailRouteComponent() {
<Label className="font-medium text-sm">Updated at</Label> <Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{format( {intlService.formatDatetimeWithTz(credential.updatedAt)}
new Date(credential.updatedAt),
'yyyy-MM-dd HH:mm:ss'
)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,8 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton'; import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Eye, EyeOff, Save } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -8,6 +12,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -31,23 +37,15 @@ import {
apolloErrorToMessage, apolloErrorToMessage,
getApolloQueryError, getApolloQueryError,
} from '@/infra/errors/apollo'; } from '@/infra/errors/apollo';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import type { import type {
Credential3rdTypeEnum, Credential3rdTypeEnum,
Credential3rdUpdateInput,
GetCredential3rdDetailQuery, GetCredential3rdDetailQuery,
UpdateCredential3rdMutation, UpdateCredential3rdMutation,
UpdateCredential3rdMutationVariables, UpdateCredential3rdMutationVariables,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { ArrowLeft, Eye, EyeOff, Save, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
export const Route = createFileRoute('/_app/credential3rd/edit/$id')({ export const Route = createFileRoute('/_app/credential3rd/edit/$id')({
component: Credential3rdEditRouteComponent, component: Credential3rdEditRouteComponent,
@@ -63,23 +61,10 @@ function FormView({
credential: Credential3rdDetailDto; credential: Credential3rdDetailDto;
onCompleted: VoidFunction; onCompleted: VoidFunction;
}) { }) {
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const togglePasswordVisibility = () => { const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}; };
const router = useRouter();
const canGoBack = useCanGoBack();
const handleBack = () => {
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/credential3rd/manage',
});
}
};
const [updateCredential, { loading: updating }] = useMutation< const [updateCredential, { loading: updating }] = useMutation<
UpdateCredential3rdMutation, UpdateCredential3rdMutation,
@@ -94,18 +79,21 @@ function FormView({
}); });
const form = useAppForm({ const form = useAppForm({
defaultValues: { defaultValues: compatFormDefaultValues<
Credential3rdUpdateInput,
'credentialType' | 'username' | 'password' | 'userAgent'
>({
credentialType: credential.credentialType, credentialType: credential.credentialType,
username: credential.username, username: credential.username ?? '',
password: credential.password, password: credential.password ?? '',
userAgent: credential.userAgent, userAgent: credential.userAgent ?? '',
}, }),
validators: { validators: {
onBlur: Credential3rdUpdateSchema, onBlur: Credential3rdUpdateSchema,
onSubmit: Credential3rdUpdateSchema, onSubmit: Credential3rdUpdateSchema,
}, },
onSubmit: (form) => { onSubmit: (submittedForm) => {
const value = form.value; const value = submittedForm.value;
updateCredential({ updateCredential({
variables: { variables: {
data: value, data: value,
@@ -121,35 +109,17 @@ function FormView({
return ( return (
<div className="container mx-auto max-w-4xl py-6"> <div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between"> <ContainerHeader
<div className="flex items-center gap-4"> title="Credential Edit"
<Button description={`Edit credential #${credential.id}`}
variant="ghost" defaultBackTo={`/credential3rd/detail/${credential.id}`}
size="sm" actions={
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">Credential edit</h1>
<p className="mt-1 text-muted-foreground">
Edit credential #{credential.id}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack} disabled={updating}>
<X className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={() => form.handleSubmit()} disabled={updating}> <Button onClick={() => form.handleSubmit()} disabled={updating}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{updating ? 'Saving...' : 'Save'} {updating ? 'Saving...' : 'Save'}
</Button> </Button>
</div> }
</div> />
<Card> <Card>
<CardHeader> <CardHeader>
@@ -273,7 +243,7 @@ function Credential3rdEditRouteComponent() {
const { loading, error, data, refetch } = const { loading, error, data, refetch } =
useQuery<GetCredential3rdDetailQuery>(GET_CREDENTIAL_3RD_DETAIL, { useQuery<GetCredential3rdDetailQuery>(GET_CREDENTIAL_3RD_DETAIL, {
variables: { variables: {
id: Number.parseInt(id), id: Number.parseInt(id, 10),
}, },
}); });
@@ -281,10 +251,10 @@ function Credential3rdEditRouteComponent() {
const onCompleted = useCallback(async () => { const onCompleted = useCallback(async () => {
const refetchResult = await refetch(); const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult); const _error = getApolloQueryError(refetchResult);
if (error) { if (_error) {
toast.error('Update credential failed', { toast.error('Update credential failed', {
description: apolloErrorToMessage(error), description: apolloErrorToMessage(_error),
}); });
} else { } else {
toast.success('Update credential successfully'); toast.success('Update credential successfully');

View File

@@ -1,5 +1,23 @@
import { useMutation, useQuery } from '@apollo/client';
import { Dialog } from '@radix-ui/react-dialog';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type Row,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { Eye, EyeOff, Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination'; import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { DialogTrigger } from '@/components/ui/dialog'; import { DialogTrigger } from '@/components/ui/dialog';
@@ -20,33 +38,17 @@ import {
DELETE_CREDENTIAL_3RD, DELETE_CREDENTIAL_3RD,
GET_CREDENTIAL_3RD, GET_CREDENTIAL_3RD,
} from '@/domains/recorder/schema/credential3rd'; } from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject';
import { import {
apolloErrorToMessage, apolloErrorToMessage,
getApolloQueryError, getApolloQueryError,
} from '@/infra/errors/apollo'; } from '@/infra/errors/apollo';
import type { GetCredential3rdQuery } from '@/infra/graphql/gql/graphql'; import type { GetCredential3rdQuery } from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton'; import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useEvent } from '@/presentation/hooks/use-event'; import { useEvent } from '@/presentation/hooks/use-event';
import { cn } from '@/presentation/utils'; import { cn } from '@/presentation/utils';
import { useMutation, useQuery } from '@apollo/client';
import { Dialog } from '@radix-ui/react-dialog';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type Row,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { Eye, EyeOff, Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available'; import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
export const Route = createFileRoute('/_app/credential3rd/manage')({ export const Route = createFileRoute('/_app/credential3rd/manage')({
@@ -58,6 +60,7 @@ export const Route = createFileRoute('/_app/credential3rd/manage')({
function CredentialManageRouteComponent() { function CredentialManageRouteComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
createdAt: false, createdAt: false,
@@ -93,18 +96,18 @@ function CredentialManageRouteComponent() {
const [deleteCredential] = useMutation(DELETE_CREDENTIAL_3RD, { const [deleteCredential] = useMutation(DELETE_CREDENTIAL_3RD, {
onCompleted: async () => { onCompleted: async () => {
const refetchResult = await refetch(); const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult); const e = getApolloQueryError(refetchResult);
if (error) { if (e) {
toast.error('Failed to delete credential', { toast.error('Failed to delete credential', {
description: apolloErrorToMessage(error), description: apolloErrorToMessage(e),
}); });
return; return;
} }
toast.success('Credential deleted'); toast.success('Credential deleted');
}, },
onError: (error) => { onError: (e) => {
toast.error('Failed to delete credential', { toast.error('Failed to delete credential', {
description: error.message, description: e.message,
}); });
}, },
}); });
@@ -211,7 +214,7 @@ function CredentialManageRouteComponent() {
const createdAt = row.original.createdAt; const createdAt = row.original.createdAt;
return ( return (
<div className="text-sm"> <div className="text-sm">
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')} {intlService.formatDatetimeWithTz(createdAt)}
</div> </div>
); );
}, },
@@ -223,7 +226,7 @@ function CredentialManageRouteComponent() {
const updatedAt = row.original.updatedAt; const updatedAt = row.original.updatedAt;
return ( return (
<div className="text-sm"> <div className="text-sm">
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')} {intlService.formatDatetimeWithTz(updatedAt)}
</div> </div>
); );
}, },
@@ -265,7 +268,13 @@ function CredentialManageRouteComponent() {
}, },
]; ];
return cs; return cs;
}, [handleDeleteRecord, navigate, showPasswords, togglePasswordVisibility]); }, [
handleDeleteRecord,
navigate,
showPasswords,
togglePasswordVisibility,
intlService.formatDatetimeWithTz,
]);
const table = useReactTable({ const table = useReactTable({
data: useMemo(() => credentials?.nodes ?? [], [credentials]), data: useMemo(() => credentials?.nodes ?? [], [credentials]),
@@ -297,18 +306,16 @@ function CredentialManageRouteComponent() {
return ( return (
<div className="container mx-auto space-y-4 rounded-md"> <div className="container mx-auto space-y-4 rounded-md">
<div className="flex items-center justify-between pt-4"> <ContainerHeader
<div> title="Credential 3rd Management"
<h1 className="font-bold text-2xl">Credential 3rd Management</h1> description="Manage your third-party platform login credentials"
<p className="text-muted-foreground"> actions={
Manage your third-party platform login credentials <Button onClick={() => navigate({ to: '/credential3rd/create' })}>
</p> <Plus className="mr-2 h-4 w-4" />
</div> Add Credential
<Button onClick={() => navigate({ to: '/credential3rd/create' })}> </Button>
<Plus className="mr-2 h-4 w-4" /> }
Add Credential />
</Button>
</div>
<div className="flex items-center py-2"> <div className="flex items-center py-2">
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
</div> </div>

View File

@@ -0,0 +1,258 @@
import { useMutation } from '@apollo/client';
import { useNavigate } from '@tanstack/react-router';
import { CalendarIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Cron } from '@/components/ui/cron';
import { CronMode } from '@/components/ui/cron/types';
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { INSERT_CRON } from '@/domains/recorder/schema/cron';
import { useInject } from '@/infra/di/inject';
import {
type InsertCronMutation,
type InsertCronMutationVariables,
SubscriberTaskTypeEnum,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
const SUBSCRIPTION_TASK_CRON_PRESETS = [
{
label: 'Every hour',
value: '0 0 * * * *',
description: 'Runs at the top of every hour',
category: 'common',
},
{
label: 'Daily at midnight',
value: '0 0 0 * * *',
description: 'Runs once daily at 00:00',
category: 'daily',
},
{
label: 'Daily at 9 AM',
value: '0 0 9 * * *',
description: 'Runs daily at 9:00 AM',
category: 'daily',
},
{
label: 'Every Sunday',
value: '0 0 0 * * 0',
description: 'Runs every Sunday at midnight',
category: 'weekly',
},
{
label: 'First day of month',
value: '0 0 0 1 * *',
description: 'Runs on the 1st day of every month',
category: 'monthly',
},
{
label: 'Every year',
value: '0 0 0 1 1 *',
description: 'Runs on January 1st every year',
category: 'yearly',
},
];
const CRON_TABS = [
{
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionSources,
label: 'Sync sources',
description: 'Syncs subscription sources',
},
{
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental,
label: 'Feeds incremental',
description: 'Syncs incremental subscription feeds',
},
{
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull,
label: 'Feeds full',
description: 'Syncs all subscription feeds',
},
];
export type SubscriptionCronCreationViewCompletePayload = {
id: number;
};
export interface SubscriptionCronCreationViewProps {
subscriptionId: number;
onComplete: (payload: SubscriptionCronCreationViewCompletePayload) => void;
}
export interface SubscriptionCronFormProps {
tab: (typeof CRON_TABS)[number];
timezone: string;
onComplete: (payload: SubscriptionCronFormPayload) => void;
}
export interface SubscriptionCronFormPayload {
cronExpr: string;
}
export const SubscriptionCronForm = memo(
({ tab, timezone, onComplete }: SubscriptionCronFormProps) => {
const [cronExpr, setCronExpr] = useState<string>('');
return (
<Card className="overflow-y-auto">
<CardHeader>
<CardTitle>{tab.label}</CardTitle>
<CardDescription>{tab.description}</CardDescription>
<CardAction>
<Button variant="default" onClick={() => onComplete({ cronExpr })}>
<CalendarIcon className="size-4" />
Create
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Separator />
<Cron
mode={CronMode.Both}
withCard={false}
showPresets={false}
presets={SUBSCRIPTION_TASK_CRON_PRESETS}
timezone={timezone}
onChange={setCronExpr}
value={cronExpr}
/>
</CardContent>
</Card>
);
}
);
export const SubscriptionCronCreationView = memo(
({ subscriptionId, onComplete }: SubscriptionCronCreationViewProps) => {
const intlService = useInject(IntlService);
const [insertCron, { loading: loadingInsert }] = useMutation<
InsertCronMutation,
InsertCronMutationVariables
>(INSERT_CRON, {
onCompleted: (data) => {
toast.success('Cron created');
onComplete(data.cronCreateOne);
},
onError: (error) => {
toast.error('Failed to sync subscription', {
description: error.message,
});
},
});
const loading = loadingInsert;
return (
<Tabs
defaultValue={CRON_TABS[0].tab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex w-full shrink-0 overflow-x-auto">
<TabsList className="flex items-center justify-center whitespace-nowrap">
{CRON_TABS.map((tab) => (
<TabsTrigger key={tab.tab} value={tab.tab} className="w-fit px-4">
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{CRON_TABS.map((tab) => (
<TabsContent
key={tab.tab}
value={tab.tab}
className="flex-1 space-y-2"
asChild
>
<SubscriptionCronForm
tab={tab}
onComplete={(payload) => {
insertCron({
variables: {
data: {
cronExpr: payload.cronExpr,
cronTimezone: intlService.timezone,
subscriberTaskCron: {
subscriptionId,
taskType: tab.tab,
},
},
},
});
}}
timezone={intlService.timezone}
/>
</TabsContent>
))}
{loading && (
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
<Spinner variant="circle-filled" size="16" />
<span>Creating cron...</span>
</div>
)}
</Tabs>
);
}
);
export interface SubscriptionCronCreationDialogContentProps {
subscriptionId: number;
onCancel?: VoidFunction;
}
export const SubscriptionCronCreationDialogContent = memo(
({
subscriptionId,
onCancel,
}: SubscriptionCronCreationDialogContentProps) => {
const navigate = useNavigate();
const handleCreationComplete = useCallback(
(payload: SubscriptionCronCreationViewCompletePayload) => {
navigate({
to: '/tasks/cron/detail/$id',
params: {
id: `${payload.id}`,
},
});
},
[navigate]
);
return (
<DialogContent
onAbort={onCancel}
className="flex max-h-[80vh] flex-col xl:max-w-2xl"
>
<DialogHeader>
<DialogTitle>Create Cron</DialogTitle>
<DialogDescription>
Create a cron to execute the subscription.
</DialogDescription>
</DialogHeader>
<SubscriptionCronCreationView
subscriptionId={subscriptionId}
onComplete={handleCreationComplete}
/>
</DialogContent>
);
}
);

View File

@@ -18,17 +18,17 @@ import { RefreshCcwIcon } from 'lucide-react';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export type SubscriptionSyncViewCompletePayload = { export type SubscriptionTaskCreationViewCompletePayload = {
id: string; id: string;
}; };
export interface SubscriptionSyncViewProps { export interface SubscriptionTaskCreationViewProps {
id: number; subscriptionId: number;
onComplete: (payload: SubscriptionSyncViewCompletePayload) => void; onComplete: (payload: SubscriptionTaskCreationViewCompletePayload) => void;
} }
export const SubscriptionSyncView = memo( export const SubscriptionTaskCreationView = memo(
({ id, onComplete }: SubscriptionSyncViewProps) => { ({ subscriptionId, onComplete }: SubscriptionTaskCreationViewProps) => {
const [insertSubscriberTask, { loading: loadingInsert }] = useMutation< const [insertSubscriberTask, { loading: loadingInsert }] = useMutation<
InsertSubscriberTaskMutation, InsertSubscriberTaskMutation,
InsertSubscriberTaskMutationVariables InsertSubscriberTaskMutationVariables
@@ -56,7 +56,7 @@ export const SubscriptionSyncView = memo(
variables: { variables: {
data: { data: {
job: { job: {
subscriptionId: id, subscriptionId: subscriptionId,
taskType: SubscriberTaskTypeEnum.SyncOneSubscriptionSources, taskType: SubscriberTaskTypeEnum.SyncOneSubscriptionSources,
}, },
}, },
@@ -75,7 +75,7 @@ export const SubscriptionSyncView = memo(
variables: { variables: {
data: { data: {
job: { job: {
subscriptionId: id, subscriptionId: subscriptionId,
taskType: taskType:
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental, SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental,
}, },
@@ -95,7 +95,7 @@ export const SubscriptionSyncView = memo(
variables: { variables: {
data: { data: {
job: { job: {
subscriptionId: id, subscriptionId: subscriptionId,
taskType: taskType:
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull, SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull,
}, },
@@ -111,7 +111,7 @@ export const SubscriptionSyncView = memo(
{loading && ( {loading && (
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2"> <div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
<Spinner variant="circle-filled" size="16" /> <Spinner variant="circle-filled" size="16" />
<span>Syncing...</span> <span>Running...</span>
</div> </div>
)} )}
</div> </div>
@@ -119,17 +119,20 @@ export const SubscriptionSyncView = memo(
} }
); );
export interface SubscriptionSyncDialogContentProps { export interface SubscriptionTaskCreationDialogContentProps {
id: number; subscriptionId: number;
onCancel?: VoidFunction; onCancel?: VoidFunction;
} }
export const SubscriptionSyncDialogContent = memo( export const SubscriptionTaskCreationDialogContent = memo(
({ id, onCancel }: SubscriptionSyncDialogContentProps) => { ({
subscriptionId,
onCancel,
}: SubscriptionTaskCreationDialogContentProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleSyncComplete = useCallback( const handleCreationComplete = useCallback(
(payload: SubscriptionSyncViewCompletePayload) => { (payload: SubscriptionTaskCreationViewCompletePayload) => {
navigate({ navigate({
to: '/tasks/detail/$id', to: '/tasks/detail/$id',
params: { params: {
@@ -143,12 +146,15 @@ export const SubscriptionSyncDialogContent = memo(
return ( return (
<DialogContent onAbort={onCancel}> <DialogContent onAbort={onCancel}>
<DialogHeader> <DialogHeader>
<DialogTitle>Sync Subscription</DialogTitle> <DialogTitle>Run Task</DialogTitle>
<DialogDescription> <DialogDescription>
Sync the subscription with sources and feeds. Run the task for the subscription.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<SubscriptionSyncView id={id} onComplete={handleSyncComplete} /> <SubscriptionTaskCreationView
subscriptionId={subscriptionId}
onComplete={handleCreationComplete}
/>
</DialogContent> </DialogContent>
); );
} }

View File

@@ -1,3 +1,7 @@
import { useMutation } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -6,6 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { FormFieldErrors } from '@/components/ui/form-field-errors'; import { FormFieldErrors } from '@/components/ui/form-field-errors';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -26,6 +31,7 @@ import {
} from '@/domains/recorder/schema/subscriptions'; } from '@/domains/recorder/schema/subscriptions';
import { SubscriptionService } from '@/domains/recorder/services/subscription.service'; import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
import { useInject } from '@/infra/di/inject'; import { useInject } from '@/infra/di/inject';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import { import {
Credential3rdTypeEnum, Credential3rdTypeEnum,
type InsertSubscriptionMutation, type InsertSubscriptionMutation,
@@ -33,11 +39,6 @@ import {
SubscriptionCategoryEnum, SubscriptionCategoryEnum,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Credential3rdSelectContent } from './-credential3rd-select'; import { Credential3rdSelectContent } from './-credential3rd-select';
export const Route = createFileRoute('/_app/subscriptions/create')({ export const Route = createFileRoute('/_app/subscriptions/create')({
@@ -70,22 +71,24 @@ function SubscriptionCreateRouteComponent() {
}); });
const form = useAppForm({ const form = useAppForm({
defaultValues: { defaultValues: compatFormDefaultValues<SubscriptionForm>({
displayName: '', displayName: '',
category: undefined, category: '',
enabled: true, enabled: true,
sourceUrl: '', sourceUrl: '',
credentialId: '', credentialId: Number.NaN,
year: undefined, year: Number.NaN,
seasonStr: '', seasonStr: '',
} as unknown as SubscriptionForm, }),
validators: { validators: {
onChangeAsync: SubscriptionFormSchema, onChangeAsync: SubscriptionFormSchema,
onChangeAsyncDebounceMs: 300, onChangeAsyncDebounceMs: 300,
onSubmit: SubscriptionFormSchema, onSubmit: SubscriptionFormSchema,
}, },
onSubmit: async (form) => { onSubmit: async (submittedForm) => {
const input = subscriptionService.transformInsertFormToInput(form.value); const input = subscriptionService.transformInsertFormToInput(
submittedForm.value
);
await insertSubscription({ await insertSubscription({
variables: { variables: {
data: input, data: input,
@@ -96,14 +99,11 @@ function SubscriptionCreateRouteComponent() {
return ( return (
<div className="container mx-auto max-w-2xl py-6"> <div className="container mx-auto max-w-2xl py-6">
<div className="mb-6 flex items-center gap-4"> <ContainerHeader
<div> title="Create Bangumi Subscription"
<h1 className="font-bold text-2xl">Create Bangumi Subscription</h1> description="Add a new bangumi subscription source"
<p className="mt-1 text-muted-foreground"> defaultBackTo="/subscriptions/manage"
Add a new bangumi subscription source />
</p>
</div>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -121,30 +121,6 @@ function SubscriptionCreateRouteComponent() {
}} }}
className="space-y-6" className="space-y-6"
> >
<form.Field name="displayName">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Display Name *</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter display name"
autoComplete="off"
/>
{field.state.meta.errors && (
<FormFieldErrors
errors={field.state.meta.errors}
isDirty={field.state.meta.isDirty}
submissionAttempts={form.state.submissionAttempts}
/>
)}
</div>
)}
</form.Field>
<form.Field name="category"> <form.Field name="category">
{(field) => ( {(field) => (
<div className="space-y-2"> <div className="space-y-2">
@@ -194,7 +170,7 @@ function SubscriptionCreateRouteComponent() {
<Select <Select
value={field.state.value.toString()} value={field.state.value.toString()}
onValueChange={(value) => onValueChange={(value) =>
field.handleChange(Number.parseInt(value)) field.handleChange(Number.parseInt(value, 10))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -229,7 +205,7 @@ function SubscriptionCreateRouteComponent() {
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange( field.handleChange(
Number.parseInt(e.target.value) Number.parseInt(e.target.value, 10)
) )
} }
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`} placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
@@ -317,6 +293,29 @@ function SubscriptionCreateRouteComponent() {
); );
}} }}
</form.Subscribe> </form.Subscribe>
<form.Field name="displayName">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Display Name *</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter display name"
autoComplete="off"
/>
{field.state.meta.errors && (
<FormFieldErrors
errors={field.state.meta.errors}
isDirty={field.state.meta.isDirty}
submissionAttempts={form.state.submissionAttempts}
/>
)}
</div>
)}
</form.Field>
<form.Field name="enabled"> <form.Field name="enabled">
{(field) => ( {(field) => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,4 +1,17 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton'; import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
Edit,
ExternalLink,
ListIcon,
Pause,
Play,
PlusIcon,
RefreshCcwIcon,
Trash2,
} from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -8,14 +21,19 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Dialog, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogTrigger } from '@/components/ui/dialog';
import { Img } from '@/components/ui/img'; import { Img } from '@/components/ui/img';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { ProLink } from '@/components/ui/pro-link';
import { QueryErrorView } from '@/components/ui/query-error-view'; import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { UPDATE_CRONS } from '@/domains/recorder/schema/cron';
import { DELETE_FEED, INSERT_FEED } from '@/domains/recorder/schema/feeds'; import { DELETE_FEED, INSERT_FEED } from '@/domains/recorder/schema/feeds';
import { GET_SUBSCRIPTION_DETAIL } from '@/domains/recorder/schema/subscriptions'; import { GET_SUBSCRIPTION_DETAIL } from '@/domains/recorder/schema/subscriptions';
import { DELETE_TASKS } from '@/domains/recorder/schema/tasks';
import { SubscriptionService } from '@/domains/recorder/services/subscription.service'; import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
import { useInject } from '@/infra/di/inject'; import { useInject } from '@/infra/di/inject';
import { import {
@@ -25,34 +43,22 @@ import {
import { import {
type DeleteFeedMutation, type DeleteFeedMutation,
type DeleteFeedMutationVariables, type DeleteFeedMutationVariables,
type DeleteTasksMutation,
type DeleteTasksMutationVariables,
FeedSourceEnum, FeedSourceEnum,
FeedTypeEnum, FeedTypeEnum,
type GetSubscriptionDetailQuery, type GetSubscriptionDetailQuery,
type GetSubscriptionDetailQueryVariables,
type InsertFeedMutation, type InsertFeedMutation,
type InsertFeedMutationVariables, type InsertFeedMutationVariables,
SubscriptionCategoryEnum, SubscriptionCategoryEnum,
type UpdateCronsMutation,
type UpdateCronsMutationVariables,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import { useMutation, useQuery } from '@apollo/client'; import { IntlService } from '@/infra/intl/intl.service';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { format } from 'date-fns';
import {
ArrowLeft,
Edit,
ExternalLink,
ListIcon,
PlusIcon,
RefreshCcwIcon,
Trash2,
} from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { prettyTaskType } from '../tasks/-pretty-task-type'; import { prettyTaskType } from '../tasks/-pretty-task-type';
import { SubscriptionSyncDialogContent } from './-sync'; import { SubscriptionCronCreationDialogContent } from './-cron-creation';
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
export const Route = createFileRoute('/_app/subscriptions/detail/$id')({ export const Route = createFileRoute('/_app/subscriptions/detail/$id')({
component: SubscriptionDetailRouteComponent, component: SubscriptionDetailRouteComponent,
@@ -61,19 +67,8 @@ export const Route = createFileRoute('/_app/subscriptions/detail/$id')({
function SubscriptionDetailRouteComponent() { function SubscriptionDetailRouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter();
const canGoBack = useCanGoBack();
const subscriptionService = useInject(SubscriptionService); const subscriptionService = useInject(SubscriptionService);
const intlService = useInject(IntlService);
const handleBack = () => {
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/subscriptions/manage',
});
}
};
const handleReload = async () => { const handleReload = async () => {
const result = await refetch(); const result = await refetch();
@@ -85,12 +80,23 @@ function SubscriptionDetailRouteComponent() {
} }
}; };
const { data, loading, error, refetch } = const {
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, { data,
loading,
error: subscriptionError,
refetch,
} = useQuery<GetSubscriptionDetailQuery, GetSubscriptionDetailQueryVariables>(
GET_SUBSCRIPTION_DETAIL,
{
variables: { variables: {
id: Number.parseInt(id), filter: {
id: {
eq: Number.parseInt(id, 10),
},
},
}, },
}); }
);
const handleEnterEditMode = () => { const handleEnterEditMode = () => {
navigate({ navigate({
@@ -145,6 +151,50 @@ function SubscriptionDetailRouteComponent() {
}, },
}); });
const [deleteTask] = useMutation<
DeleteTasksMutation,
DeleteTasksMutationVariables
>(DELETE_TASKS, {
onCompleted: async () => {
const result = await refetch();
const error = getApolloQueryError(result);
if (error) {
toast.error('Failed to delete task', {
description: apolloErrorToMessage(error),
});
return;
}
toast.success('Task deleted');
},
onError: (error) => {
toast.error('Failed to delete task', {
description: apolloErrorToMessage(error),
});
},
});
const [updateCron] = useMutation<
UpdateCronsMutation,
UpdateCronsMutationVariables
>(UPDATE_CRONS, {
onCompleted: async () => {
const result = await refetch();
const error = getApolloQueryError(result);
if (error) {
toast.error('Failed to update cron', {
description: apolloErrorToMessage(error),
});
return;
}
toast.success('Cron updated');
},
onError: (error) => {
toast.error('Failed to update cron', {
description: apolloErrorToMessage(error),
});
},
});
const subscription = data?.subscriptions?.nodes?.[0]; const subscription = data?.subscriptions?.nodes?.[0];
const sourceUrlMeta = useMemo( const sourceUrlMeta = useMemo(
@@ -167,8 +217,8 @@ function SubscriptionDetailRouteComponent() {
return <DetailCardSkeleton />; return <DetailCardSkeleton />;
} }
if (error) { if (subscriptionError) {
return <QueryErrorView message={error.message} />; return <QueryErrorView message={subscriptionError.message} />;
} }
if (!subscription) { if (!subscription) {
@@ -177,31 +227,16 @@ function SubscriptionDetailRouteComponent() {
return ( return (
<div className="container mx-auto max-w-4xl py-6"> <div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between"> <ContainerHeader
<div className="flex items-center gap-4"> title="Subscription Detail"
<Button description={`View subscription #${subscription.id}`}
variant="ghost" actions={
size="sm"
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">Subscription detail</h1>
<p className="mt-1 text-muted-foreground">
View subscription #{subscription.id}
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleEnterEditMode}> <Button onClick={handleEnterEditMode}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
</Button>{' '} </Button>
</div> }
</div> />
<Card> <Card>
<CardHeader> <CardHeader>
@@ -321,10 +356,7 @@ function SubscriptionDetailRouteComponent() {
<Label className="font-medium text-sm">Created at</Label> <Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{format( {intlService.formatDatetimeWithTz(subscription.createdAt)}
new Date(subscription.createdAt),
'yyyy-MM-dd HH:mm:ss'
)}
</span> </span>
</div> </div>
</div> </div>
@@ -333,10 +365,7 @@ function SubscriptionDetailRouteComponent() {
<Label className="font-medium text-sm">Updated at</Label> <Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{format( {intlService.formatDatetimeWithTz(subscription.updatedAt)}
new Date(subscription.updatedAt),
'yyyy-MM-dd HH:mm:ss'
)}
</span> </span>
</div> </div>
</div> </div>
@@ -353,7 +382,7 @@ function SubscriptionDetailRouteComponent() {
insertFeed({ insertFeed({
variables: { variables: {
data: { data: {
subscriptionId: Number.parseInt(id), subscriptionId: Number.parseInt(id, 10),
feedType: FeedTypeEnum.Rss, feedType: FeedTypeEnum.Rss,
feedSource: FeedSourceEnum.SubscriptionEpisode, feedSource: FeedSourceEnum.SubscriptionEpisode,
}, },
@@ -408,7 +437,7 @@ function SubscriptionDetailRouteComponent() {
</code> </code>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{format(new Date(feed.createdAt), 'MM-dd HH:mm')} {intlService.formatDatetimeWithTz(feed.createdAt)}
</div> </div>
</div> </div>
</Card> </Card>
@@ -421,6 +450,104 @@ function SubscriptionDetailRouteComponent() {
</div> </div>
</div> </div>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="font-medium text-sm">Associated Crons</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate({
to: '/tasks/cron/manage',
})
}
>
<ListIcon className="h-4 w-4" />
More
</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<PlusIcon className="h-4 w-4" />
Add Cron
</Button>
</DialogTrigger>
<SubscriptionCronCreationDialogContent
subscriptionId={subscription.id}
onCancel={handleReload}
/>
</Dialog>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{subscription.cron?.nodes &&
subscription.cron.nodes.length > 0 ? (
subscription.cron.nodes.map((cron) => (
<Card
key={cron.id}
className="group relative cursor-pointer p-4 transition-colors hover:bg-accent/50"
onClick={() =>
navigate({
to: '/tasks/cron/detail/$id',
params: {
id: cron.id.toString(),
},
})
}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<Label className="font-medium text-sm capitalize">
<span>{cron.cronExpr}</span>
</Label>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateCron({
variables: {
filter: {
id: {
eq: cron.id,
},
},
data: {
enabled: !cron.enabled,
},
},
});
}}
>
{cron.enabled ? (
<Pause className="h-3 w-3 text-destructive" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</div>
<code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
{cron.id}
</code>
<div className="text-muted-foreground text-xs">
{cron.status}
</div>
</div>
</Card>
))
) : (
<div className="col-span-full py-8 text-center text-muted-foreground">
No associated crons now
</div>
)}
</div>
</div>
<Separator /> <Separator />
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -436,17 +563,17 @@ function SubscriptionDetailRouteComponent() {
} }
> >
<ListIcon className="h-4 w-4" /> <ListIcon className="h-4 w-4" />
Tasks More
</Button> </Button>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<RefreshCcwIcon className="h-4 w-4" /> <RefreshCcwIcon className="h-4 w-4" />
Sync Run Task
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<SubscriptionSyncDialogContent <SubscriptionTaskCreationDialogContent
id={subscription.id} subscriptionId={subscription.id}
onCancel={handleReload} onCancel={handleReload}
/> />
</Dialog> </Dialog>
@@ -473,6 +600,25 @@ function SubscriptionDetailRouteComponent() {
<Label className="font-medium text-sm capitalize"> <Label className="font-medium text-sm capitalize">
<span>{prettyTaskType(task.taskType)} Task</span> <span>{prettyTaskType(task.taskType)} Task</span>
</Label> </Label>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteTask({
variables: {
filter: {
id: {
eq: task.id,
},
},
},
});
}}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div> </div>
<code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs"> <code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
@@ -546,24 +692,22 @@ function SubscriptionDetailRouteComponent() {
Updated At Updated At
</Label> </Label>
<div className="font-mono text-sm"> <div className="font-mono text-sm">
{format( {intlService.formatDatetimeWithTz(
new Date(bangumi.updatedAt), bangumi.updatedAt
'yyyy-MM-dd'
)} )}
</div> </div>
</div> </div>
</div> </div>
{bangumi.homepage && ( {bangumi.homepage && (
<div className="mt-3 border-t pt-3"> <div className="mt-3 border-t pt-3">
<Button <Button variant="outline" size="sm" asChild>
variant="outline" <ProLink
size="sm" href={bangumi.homepage}
onClick={() => target="_blank"
window.open(bangumi.homepage!, '_blank') >
} <ExternalLink className="mr-2 h-3 w-3" />
> Homepage
<ExternalLink className="mr-2 h-3 w-3" /> </ProLink>
Homepage
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1,4 +1,8 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton'; import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Save } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -8,6 +12,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { FormFieldErrors } from '@/components/ui/form-field-errors'; import { FormFieldErrors } from '@/components/ui/form-field-errors';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -35,6 +41,7 @@ import {
apolloErrorToMessage, apolloErrorToMessage,
getApolloQueryError, getApolloQueryError,
} from '@/infra/errors/apollo'; } from '@/infra/errors/apollo';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import { import {
Credential3rdTypeEnum, Credential3rdTypeEnum,
type GetSubscriptionDetailQuery, type GetSubscriptionDetailQuery,
@@ -43,11 +50,6 @@ import {
type UpdateSubscriptionsMutationVariables, type UpdateSubscriptionsMutationVariables,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ArrowLeft, Save, X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Credential3rdSelectContent } from './-credential3rd-select'; import { Credential3rdSelectContent } from './-credential3rd-select';
export const Route = createFileRoute('/_app/subscriptions/edit/$id')({ export const Route = createFileRoute('/_app/subscriptions/edit/$id')({
@@ -68,16 +70,8 @@ function FormView({
subscription: SubscriptionDetailDto; subscription: SubscriptionDetailDto;
onCompleted: VoidFunction; onCompleted: VoidFunction;
}) { }) {
const navigate = useNavigate();
const subscriptionService = useInject(SubscriptionService); const subscriptionService = useInject(SubscriptionService);
const handleBack = () => {
navigate({
to: '/subscriptions/detail/$id',
params: { id: subscription.id.toString() },
});
};
const [updateSubscription, { loading: updating }] = useMutation< const [updateSubscription, { loading: updating }] = useMutation<
UpdateSubscriptionsMutation, UpdateSubscriptionsMutation,
UpdateSubscriptionsMutationVariables UpdateSubscriptionsMutationVariables
@@ -107,7 +101,9 @@ function FormView({
category: subscription.category, category: subscription.category,
enabled: subscription.enabled, enabled: subscription.enabled,
sourceUrl: subscription.sourceUrl, sourceUrl: subscription.sourceUrl,
credentialId: subscription.credential3rd?.id || '', credentialId: subscription.credential3rd?.id ?? Number.NaN,
year: Number.NaN,
seasonStr: '',
}; };
if ( if (
@@ -125,14 +121,16 @@ function FormView({
}, [subscription, sourceUrlMeta]); }, [subscription, sourceUrlMeta]);
const form = useAppForm({ const form = useAppForm({
defaultValues: defaultValues as unknown as SubscriptionForm, defaultValues: compatFormDefaultValues<SubscriptionForm>(defaultValues),
validators: { validators: {
onChangeAsync: SubscriptionFormSchema, onChangeAsync: SubscriptionFormSchema,
onChangeAsyncDebounceMs: 300, onChangeAsyncDebounceMs: 300,
onSubmit: SubscriptionFormSchema, onSubmit: SubscriptionFormSchema,
}, },
onSubmit: async (form) => { onSubmit: async (submittedForm) => {
const input = subscriptionService.transformInsertFormToInput(form.value); const input = subscriptionService.transformInsertFormToInput(
submittedForm.value
);
await updateSubscription({ await updateSubscription({
variables: { variables: {
@@ -149,35 +147,17 @@ function FormView({
return ( return (
<div className="container mx-auto max-w-4xl py-6"> <div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between"> <ContainerHeader
<div className="flex items-center gap-4"> title="Subscription Edit"
<Button description={`Edit subscription #${subscription.id}`}
variant="ghost" defaultBackTo={`/subscriptions/detail/${subscription.id}`}
size="sm" actions={
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">Subscription edit</h1>
<p className="mt-1 text-muted-foreground">
Edit subscription #{subscription.id}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack} disabled={updating}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={() => form.handleSubmit()} disabled={updating}> <Button onClick={() => form.handleSubmit()} disabled={updating}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{updating ? 'Saving...' : 'Save'} {updating ? 'Saving...' : 'Save'}
</Button> </Button>
</div> }
</div> />
<Card> <Card>
<CardHeader> <CardHeader>
@@ -242,7 +222,7 @@ function FormView({
<Select <Select
value={field.state.value.toString()} value={field.state.value.toString()}
onValueChange={(value) => onValueChange={(value) =>
field.handleChange(Number.parseInt(value)) field.handleChange(Number.parseInt(value, 10))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -274,7 +254,9 @@ function FormView({
min={1970} min={1970}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange(Number.parseInt(e.target.value)) field.handleChange(
Number.parseInt(e.target.value, 10)
)
} }
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`} placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
autoComplete="off" autoComplete="off"
@@ -384,7 +366,7 @@ function SubscriptionEditRouteComponent() {
const { loading, error, data, refetch } = const { loading, error, data, refetch } =
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, { useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
variables: { variables: {
id: Number.parseInt(id), id: Number.parseInt(id, 10),
}, },
}); });
@@ -392,10 +374,10 @@ function SubscriptionEditRouteComponent() {
const onCompleted = useCallback(async () => { const onCompleted = useCallback(async () => {
const refetchResult = await refetch(); const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult); const _error = getApolloQueryError(refetchResult);
if (error) { if (_error) {
toast.error('Update subscription failed', { toast.error('Update subscription failed', {
description: apolloErrorToMessage(error), description: apolloErrorToMessage(_error),
}); });
} else { } else {
toast.success('Update subscription successfully'); toast.success('Update subscription successfully');

View File

@@ -1,4 +1,20 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination'; import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { Dialog, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogTrigger } from '@/components/ui/dialog';
@@ -21,32 +37,20 @@ import {
type SubscriptionDto, type SubscriptionDto,
UPDATE_SUBSCRIPTIONS, UPDATE_SUBSCRIPTIONS,
} from '@/domains/recorder/schema/subscriptions'; } from '@/domains/recorder/schema/subscriptions';
import { useInject } from '@/infra/di/inject';
import { import {
apolloErrorToMessage, apolloErrorToMessage,
getApolloQueryError, getApolloQueryError,
} from '@/infra/errors/apollo'; } from '@/infra/errors/apollo';
import type { GetSubscriptionsQuery } from '@/infra/graphql/gql/graphql'; import type {
GetSubscriptionsQuery,
GetSubscriptionsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton'; import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { cn } from '@/presentation/utils'; import { cn } from '@/presentation/utils';
import { useMutation, useQuery } from '@apollo/client'; import { SubscriptionTaskCreationDialogContent } from './-task-creation';
import { createFileRoute } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { SubscriptionSyncDialogContent } from './-sync';
export const Route = createFileRoute('/_app/subscriptions/manage')({ export const Route = createFileRoute('/_app/subscriptions/manage')({
component: SubscriptionManageRouteComponent, component: SubscriptionManageRouteComponent,
@@ -57,6 +61,7 @@ export const Route = createFileRoute('/_app/subscriptions/manage')({
function SubscriptionManageRouteComponent() { function SubscriptionManageRouteComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
createdAt: false, createdAt: false,
@@ -68,7 +73,12 @@ function SubscriptionManageRouteComponent() {
pageSize: 10, pageSize: 10,
}); });
const { loading, error, data, refetch } = useQuery<GetSubscriptionsQuery>( const {
loading,
error: subscriptionsError,
data,
refetch,
} = useQuery<GetSubscriptionsQuery, GetSubscriptionsQueryVariables>(
GET_SUBSCRIPTIONS, GET_SUBSCRIPTIONS,
{ {
variables: { variables: {
@@ -137,11 +147,11 @@ function SubscriptionManageRouteComponent() {
<div className="px-1"> <div className="px-1">
<Switch <Switch
checked={enabled} checked={enabled}
onCheckedChange={(enabled) => onCheckedChange={(checked) =>
updateSubscription({ updateSubscription({
variables: { variables: {
data: { data: {
enabled, enabled: checked,
}, },
filter: { filter: {
id: { id: {
@@ -188,7 +198,7 @@ function SubscriptionManageRouteComponent() {
const createdAt = row.original.createdAt; const createdAt = row.original.createdAt;
return ( return (
<div className="text-sm"> <div className="text-sm">
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')} {intlService.formatDatetimeWithTz(createdAt)}
</div> </div>
); );
}, },
@@ -200,7 +210,7 @@ function SubscriptionManageRouteComponent() {
const updatedAt = row.original.updatedAt; const updatedAt = row.original.updatedAt;
return ( return (
<div className="text-sm"> <div className="text-sm">
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')} {intlService.formatDatetimeWithTz(updatedAt)}
</div> </div>
); );
}, },
@@ -237,14 +247,21 @@ function SubscriptionManageRouteComponent() {
Sync Sync
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<SubscriptionSyncDialogContent id={row.original.id} /> <SubscriptionTaskCreationDialogContent
subscriptionId={row.original.id}
/>
</Dialog> </Dialog>
</DropdownMenuActions> </DropdownMenuActions>
), ),
}, },
]; ];
return cs; return cs;
}, [updateSubscription, deleteSubscription, navigate]); }, [
updateSubscription,
deleteSubscription,
navigate,
intlService.formatDatetimeWithTz,
]);
const table = useReactTable({ const table = useReactTable({
data: useMemo(() => subscriptions?.nodes ?? [], [subscriptions]), data: useMemo(() => subscriptions?.nodes ?? [], [subscriptions]),
@@ -271,22 +288,24 @@ function SubscriptionManageRouteComponent() {
}, },
}); });
if (error) { if (subscriptionsError) {
return <QueryErrorView message={error.message} onRetry={refetch} />; return (
<QueryErrorView message={subscriptionsError.message} onRetry={refetch} />
);
} }
return ( return (
<div className="container mx-auto space-y-4 rounded-md"> <div className="container mx-auto space-y-4 rounded-md">
<div className="flex items-center justify-between pt-4"> <ContainerHeader
<div> title="Subscription Management"
<h1 className="font-bold text-2xl">Subscription Management</h1> description="Manage your subscription"
<p className="text-muted-foreground">Manage your subscription</p> actions={
</div> <Button onClick={() => navigate({ to: '/subscriptions/create' })}>
<Button onClick={() => navigate({ to: '/subscriptions/create' })}> <Plus className="mr-2 h-4 w-4" />
<Plus className="mr-2 h-4 w-4" /> Add Subscription
Add Subscription </Button>
</Button> }
</div> />
<div className="flex items-center py-2"> <div className="flex items-center py-2">
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
</div> </div>

View File

@@ -0,0 +1,42 @@
import { Badge } from '@/components/ui/badge';
import { CronStatusEnum } from '@/infra/graphql/gql/graphql';
import { AlertCircle, CheckCircle, Clock, Loader2 } from 'lucide-react';
export function getStatusBadge(status: CronStatusEnum) {
switch (status) {
case CronStatusEnum.Completed:
return (
<Badge variant="secondary" className="bg-green-100 text-green-800">
<CheckCircle className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
case CronStatusEnum.Running:
return (
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
<Loader2 className="mr-1 h-3 w-3 animate-spin capitalize" />
{status}
</Badge>
);
case CronStatusEnum.Failed:
return (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
case CronStatusEnum.Pending:
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
<Clock className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
default:
return (
<Badge variant="outline" className="capitalize">
{status}
</Badge>
);
}
}

View File

@@ -0,0 +1,314 @@
import { useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { CronDisplay } from '@/components/ui/cron';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { GET_CRONS } from '@/domains/recorder/schema/cron';
import { useInject } from '@/infra/di/inject';
import {
CronStatusEnum,
type GetCronsQuery,
type GetCronsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/cron/detail/$id')({
component: CronDetailRouteComponent,
staticData: {
breadcrumb: { label: 'Detail' },
} satisfies RouteStateDataOption,
});
function CronDetailRouteComponent() {
const { id } = Route.useParams();
const intlService = useInject(IntlService);
const { data, loading, error, refetch } = useQuery<
GetCronsQuery,
GetCronsQueryVariables
>(GET_CRONS, {
variables: {
filter: {
id: {
eq: Number.parseInt(id, 10),
},
},
pagination: {
page: {
page: 0,
limit: 1,
},
},
orderBy: {},
},
pollInterval: 5000, // Auto-refresh every 5 seconds for running crons
});
const cron = data?.cron?.nodes?.[0];
const subscriberTaskCron = useMemo(() => {
if (!cron) {
return null;
}
return cron.subscriberTaskCron;
}, [cron]);
if (loading) {
return <DetailCardSkeleton />;
}
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
}
if (!cron) {
return <DetailEmptyView message="Not found Cron task" />;
}
return (
<div className="container mx-auto max-w-4xl py-6">
<ContainerHeader
title="Cron task detail"
description={`View Cron task #${cron.id}`}
defaultBackTo="/tasks/cron/manage"
actions={
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
}
/>
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Cron task information</CardTitle>
<CardDescription className="mt-2">
View Cron task execution details
</CardDescription>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(cron.status)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="font-medium text-sm">ID</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{cron.id}</code>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Priority</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">{cron.priority}</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Attemps</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.attempts} / {cron.maxAttempts}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Enabled</Label>
<div className="rounded-md bg-muted p-3">
<Badge variant={cron.enabled ? 'default' : 'secondary'}>
{cron.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Next run time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.nextRun
? intlService.formatDatetimeWithTz(cron.nextRun)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Last run time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.lastRun
? intlService.formatDatetimeWithTz(cron.lastRun)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Locked time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.lockedAt
? intlService.formatDatetimeWithTz(cron.lockedAt)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Locked by</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{cron.lockedBy || '-'}</code>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Timeout</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.timeoutMs ? `${cron.timeoutMs}ms` : 'No limit'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{intlService.formatDatetimeWithTz(cron.createdAt)}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{intlService.formatDatetimeWithTz(cron.updatedAt)}
</span>
</div>
</div>
</div>
{/* Subscriber Task Details */}
{subscriberTaskCron && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Subscriber task details
</Label>
<div className="rounded-md bg-muted p-3">
<pre className="overflow-x-auto whitespace-pre-wrap text-sm">
<code>
{JSON.stringify(subscriberTaskCron, null, 2)}
</code>
</pre>
</div>
</div>
</>
)}
{/* Cron Expression Display */}
{cron.cronExpr && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Cron expression
</Label>
<CronDisplay
expression={cron.cronExpr}
timezone="UTC"
showDescription={true}
showNextRuns={true}
withCard={false}
/>
</div>
</>
)}
{/* Related Subscriber Tasks */}
{cron.subscriberTask?.nodes &&
cron.subscriberTask.nodes.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Associated tasks
</Label>
<div className="space-y-2">
{cron.subscriberTask.nodes.map((task, index) => (
<div
key={`${task.id}-${index}`}
className="rounded-md border bg-muted/50 p-3"
>
<div className="flex items-center justify-between">
<code className="text-sm">{task.id}</code>
<Badge variant="outline">{task.status}</Badge>
</div>
<div className="mt-2 text-muted-foreground text-sm">
Priority: {task.priority} | Attempts:{' '}
{task.attempts}/{task.maxAttempts}
</div>
{task.subscription && (
<div className="mt-2 text-sm">
<span className="font-medium">
Subscription:
</span>{' '}
{task.subscription.displayName}
</div>
)}
</div>
))}
</div>
</div>
</>
)}
{/* Error Information */}
{cron.status === CronStatusEnum.Failed && cron.lastError && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm"></Label>
<div className="rounded-md bg-destructive/10 p-3">
<p className="text-destructive text-sm">
{cron.lastError}
</p>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_app/tasks/cron/edit/$id')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_app/tasks/cron/edit/$id"!</div>
}

View File

@@ -0,0 +1,297 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
import {
type CronDto,
DELETE_CRONS,
GET_CRONS,
} from '@/domains/recorder/schema/cron';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import {
CronStatusEnum,
type DeleteCronsMutation,
type DeleteCronsMutationVariables,
type GetCronsQuery,
type GetCronsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/cron/manage')({
component: TaskCronManageRouteComponent,
staticData: {
breadcrumb: { label: 'Manage' },
} satisfies RouteStateDataOption,
});
function TaskCronManageRouteComponent() {
const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { loading, error, data, refetch } = useQuery<
GetCronsQuery,
GetCronsQueryVariables
>(GET_CRONS, {
variables: {
pagination: {
page: {
page: pagination.pageIndex,
limit: pagination.pageSize,
},
},
filter: {},
orderBy: {
nextRun: 'DESC',
},
},
pollInterval: 5000, // Auto-refresh every 5 seconds
});
const { showSkeleton } = useDebouncedSkeleton({ loading });
const crons = data?.cron;
const [deleteCron] = useMutation<
DeleteCronsMutation,
DeleteCronsMutationVariables
>(DELETE_CRONS, {
onCompleted: async () => {
const refetchResult = await refetch();
const errorResult = getApolloQueryError(refetchResult);
if (errorResult) {
toast.error('Failed to delete tasks', {
description: apolloErrorToMessage(errorResult),
});
return;
}
toast.success('Tasks deleted');
},
onError: (mutationError) => {
toast.error('Failed to delete tasks', {
description: mutationError.message,
});
},
});
const columns = useMemo(() => {
const cs: ColumnDef<CronDto>[] = [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => {
return (
<div
className="max-w-[200px] truncate font-mono text-sm"
title={row.original.id.toString()}
>
{row.original.id}
</div>
);
},
},
];
return cs;
}, []);
const table = useReactTable({
data: useMemo(() => (crons?.nodes ?? []) as CronDto[], [crons]),
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
pageCount: crons?.paginationInfo?.pages,
rowCount: crons?.paginationInfo?.total,
enableColumnPinning: true,
autoResetPageIndex: true,
manualPagination: true,
state: {
pagination,
sorting,
columnVisibility,
},
initialState: {
columnPinning: {
right: ['actions'],
},
},
});
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
}
return (
<div className="container mx-auto max-w-4xl space-y-4 px-4">
<ContainerHeader
title="Crons Management"
description="Manage your crons"
actions={
<Button onClick={() => refetch()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
</Button>
}
/>
<div className="space-y-3">
{showSkeleton &&
Array.from(new Array(10)).map((_, index) => (
<Skeleton key={`skeleton-${index}`} className="h-32 w-full" />
))}
{!showSkeleton && table.getRowModel().rows?.length > 0 ? (
table.getRowModel().rows.map((row) => {
const cron = row.original;
return (
<div
className="space-y-3 rounded-lg border bg-card p-4"
key={cron.id}
>
{/* Header with status and priority */}
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-muted-foreground text-xs">
# {cron.id}
</div>
<div className="flex gap-2">
<Badge variant="outline" className="capitalize">
{cron.cronExpr}
</Badge>
</div>
</div>
<div className="mt-1 flex items-center gap-2">
{getStatusBadge(cron.status)}
<Badge variant="outline">Priority: {cron.priority}</Badge>
<div className="mr-0 ml-auto">
<DropdownMenuActions
id={cron.id}
showDetail
onDetail={() => {
navigate({
to: '/tasks/cron/detail/$id',
params: { id: cron.id.toString() },
});
}}
showDelete
onDelete={() =>
deleteCron({
variables: {
filter: {
id: {
eq: cron.id,
},
},
},
})
}
/>
</div>
</div>
{/* Time info */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Next run: </span>
<span>
{cron.nextRun
? intlService.formatDatetimeWithTz(cron.nextRun)
: '-'}
</span>
</div>
<div>
<span className="text-muted-foreground">Last run: </span>
<span>
{cron.lastRun
? intlService.formatDatetimeWithTz(cron.lastRun)
: '-'}
</span>
</div>
{/* Attempts */}
<div className="text-sm">
<span className="text-muted-foreground">Attempts: </span>
<span>
{cron.attempts} / {cron.maxAttempts}
</span>
</div>
{/* Lock at */}
<div className="text-sm">
<span className="text-muted-foreground">Lock at: </span>
<span>
{cron.lockedAt
? intlService.formatDatetimeWithTz(cron.lockedAt)
: '-'}
</span>
</div>
</div>
{/* Subscriber task cron */}
{cron.subscriberTaskCron && (
<div className="text-sm">
<span className="text-muted-foreground">Task:</span>
<br />
<span
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
cron.subscriberTaskCron,
null,
2
),
}}
/>
</div>
)}
{/* Error if exists */}
{cron.status === CronStatusEnum.Failed && cron.lastError && (
<div className="rounded bg-destructive/10 p-2 text-destructive text-sm">
{cron.lastError}
</div>
)}
</div>
);
})
) : (
<DetailEmptyView message="No tasks found" fullWidth />
)}
</div>
<DataTablePagination table={table} showSelectedRowCount={false} />
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { buildVirtualBranchRouteOptions } from '@/infra/routes/utils';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/tasks/cron')(
buildVirtualBranchRouteOptions({
title: 'Cron',
})
);

View File

@@ -1,4 +1,8 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton'; import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -8,13 +12,18 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view'; import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { GET_TASKS, RETRY_TASKS } from '@/domains/recorder/schema/tasks'; import { GET_TASKS, RETRY_TASKS } from '@/domains/recorder/schema/tasks';
import { getApolloQueryError } from '@/infra/errors/apollo'; import { useInject } from '@/infra/di/inject';
import { apolloErrorToMessage } from '@/infra/errors/apollo'; import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import { import {
type GetTasksQuery, type GetTasksQuery,
type GetTasksQueryVariables, type GetTasksQueryVariables,
@@ -22,18 +31,8 @@ import {
type RetryTasksMutationVariables, type RetryTasksMutationVariables,
SubscriberTaskStatusEnum, SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql'; } from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits'; import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { format } from 'date-fns';
import { ArrowLeft, RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { prettyTaskType } from './-pretty-task-type'; import { prettyTaskType } from './-pretty-task-type';
import { getStatusBadge } from './-status-badge'; import { getStatusBadge } from './-status-badge';
@@ -46,24 +45,15 @@ export const Route = createFileRoute('/_app/tasks/detail/$id')({
function TaskDetailRouteComponent() { function TaskDetailRouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const navigate = useNavigate();
const router = useRouter();
const canGoBack = useCanGoBack();
const handleBack = () => { const intlService = useInject(IntlService);
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/tasks/manage',
});
}
};
const { data, loading, error, refetch } = useQuery< const {
GetTasksQuery, data,
GetTasksQueryVariables loading,
>(GET_TASKS, { error: taskError,
refetch,
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
variables: { variables: {
filter: { filter: {
id: { id: {
@@ -119,8 +109,8 @@ function TaskDetailRouteComponent() {
return <DetailCardSkeleton />; return <DetailCardSkeleton />;
} }
if (error) { if (taskError) {
return <QueryErrorView message={error.message} onRetry={refetch} />; return <QueryErrorView message={taskError.message} onRetry={refetch} />;
} }
if (!task) { if (!task) {
@@ -129,27 +119,17 @@ function TaskDetailRouteComponent() {
return ( return (
<div className="container mx-auto max-w-4xl py-6"> <div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between"> <ContainerHeader
<div className="flex items-center gap-4"> title="Task Detail"
<Button description={`View task #${task.id}`}
variant="ghost" defaultBackTo="/tasks/manage"
size="sm" actions={
onClick={handleBack} <Button variant="outline" size="sm" onClick={() => refetch()}>
className="h-8 w-8 p-0" <RefreshCw className="h-4 w-4" />
> Refresh
<ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> }
<h1 className="font-bold text-2xl">Task Detail</h1> />
<p className="mt-1 text-muted-foreground">View task #{task.id}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -222,7 +202,7 @@ function TaskDetailRouteComponent() {
</Label> </Label>
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{format(new Date(task.runAt), 'yyyy-MM-dd HH:mm:ss')} {intlService.formatDatetimeWithTz(task.runAt)}
</span> </span>
</div> </div>
</div> </div>
@@ -232,7 +212,7 @@ function TaskDetailRouteComponent() {
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{task.doneAt {task.doneAt
? format(new Date(task.doneAt), 'yyyy-MM-dd HH:mm:ss') ? intlService.formatDatetimeWithTz(task.doneAt)
: '-'} : '-'}
</span> </span>
</div> </div>
@@ -243,7 +223,7 @@ function TaskDetailRouteComponent() {
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3">
<span className="text-sm"> <span className="text-sm">
{task.lockAt {task.lockAt
? format(new Date(task.lockAt), 'yyyy-MM-dd HH:mm:ss') ? intlService.formatDatetimeWithTz(task.lockAt)
: '-'} : '-'}
</span> </span>
</div> </div>

View File

@@ -1,7 +1,23 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination'; import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DetailEmptyView } from '@/components/ui/detail-empty-view'; import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions'; import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view'; import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -11,37 +27,23 @@ import {
RETRY_TASKS, RETRY_TASKS,
type TaskDto, type TaskDto,
} from '@/domains/recorder/schema/tasks'; } from '@/domains/recorder/schema/tasks';
import { import { useInject } from '@/infra/di/inject';
type DeleteTasksMutation,
type DeleteTasksMutationVariables,
type GetTasksQuery,
type RetryTasksMutation,
type RetryTasksMutationVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { RefreshCw } from 'lucide-react';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { import {
apolloErrorToMessage, apolloErrorToMessage,
getApolloQueryError, getApolloQueryError,
} from '@/infra/errors/apollo'; } from '@/infra/errors/apollo';
import { useMemo, useState } from 'react'; import {
import { toast } from 'sonner'; type DeleteTasksMutation,
type DeleteTasksMutationVariables,
type GetTasksQuery,
type GetTasksQueryVariables,
type RetryTasksMutation,
type RetryTasksMutationVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { prettyTaskType } from './-pretty-task-type'; import { prettyTaskType } from './-pretty-task-type';
import { getStatusBadge } from './-status-badge'; import { getStatusBadge } from './-status-badge';
@@ -55,18 +57,21 @@ export const Route = createFileRoute('/_app/tasks/manage')({
function TaskManageRouteComponent() { function TaskManageRouteComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
lockAt: false,
lockBy: false,
attempts: false,
});
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const { loading, error, data, refetch } = useQuery<GetTasksQuery>(GET_TASKS, { const intlService = useInject(IntlService);
const {
loading,
error: tasksError,
data,
refetch,
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
variables: { variables: {
pagination: { pagination: {
page: { page: {
@@ -167,21 +172,21 @@ function TaskManageRouteComponent() {
}, },
}); });
if (error) { if (tasksError) {
return <QueryErrorView message={error.message} onRetry={refetch} />; return <QueryErrorView message={tasksError.message} onRetry={refetch} />;
} }
return ( return (
<div className="container mx-auto space-y-4 px-4"> <div className="container mx-auto max-w-4xl space-y-4 px-4">
<div className="flex items-center justify-between pt-4"> <ContainerHeader
<div> title="Tasks Management"
<h1 className="font-bold text-2xl">Tasks Management</h1> description="Manage your tasks"
<p className="text-muted-foreground">Manage your tasks</p> actions={
</div> <Button onClick={() => refetch()} variant="outline" size="sm">
<Button onClick={() => refetch()} variant="outline" size="sm"> <RefreshCw className="h-4 w-4" />
<RefreshCw className="h-4 w-4" /> </Button>
</Button> }
</div> />
<div className="space-y-3"> <div className="space-y-3">
{showSkeleton && {showSkeleton &&
@@ -261,14 +266,14 @@ function TaskManageRouteComponent() {
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div> <div>
<span className="text-muted-foreground">Run at: </span> <span className="text-muted-foreground">Run at: </span>
<span>{format(new Date(task.runAt), 'MM/dd HH:mm')}</span> <span>{intlService.formatDatetimeWithTz(task.runAt)}</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Done: </span> <span className="text-muted-foreground">Done: </span>
<span> <span>
{task.doneAt {task.doneAt
? format(new Date(task.doneAt), 'MM/dd HH:mm') ? intlService.formatDatetimeWithTz(task.doneAt)
: '-'} : '-'}
</span> </span>
</div> </div>
@@ -286,7 +291,7 @@ function TaskManageRouteComponent() {
<span className="text-muted-foreground">Lock at: </span> <span className="text-muted-foreground">Lock at: </span>
<span> <span>
{task.lockAt {task.lockAt
? format(new Date(task.lockAt), 'MM/dd HH:mm') ? intlService.formatDatetimeWithTz(task.lockAt)
: '-'} : '-'}
</span> </span>
</div> </div>

View File

@@ -1,31 +1,39 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
"extends": ["ultracite"], "extends": ["ultracite"],
"javascript": { "javascript": {
"globals": ["Liveblocks"] "globals": ["Liveblocks"]
}, },
"assist": {
"actions": {
"source": {
"useSortedAttributes": "off"
}
}
},
"linter": { "linter": {
"rules": { "rules": {
"nursery": { "nursery": {},
"noEnum": "off"
},
"style": { "style": {
"noParameterProperties": "off", "noParameterProperties": "off",
"noNonNullAssertion": "off" "noNonNullAssertion": "off",
"noEnum": "off"
}, },
"security": { "security": {
"noDangerouslySetInnerHtml": "off" "noDangerouslySetInnerHtml": "off"
}, },
"suspicious": { "suspicious": {
"noArrayIndexKey": "off",
"noEmptyBlockStatements": "off", "noEmptyBlockStatements": "off",
"noExplicitAny": "off", "noExplicitAny": "off",
"noConsole": "off", "noConsole": "off"
"noConsoleLog": "off"
}, },
"a11y": { "a11y": {
"noSvgWithoutTitle": "off" "noSvgWithoutTitle": "off"
}, },
"performance": {
"noNamespaceImport": "off"
},
"complexity": { "complexity": {
"noExcessiveCognitiveComplexity": { "noExcessiveCognitiveComplexity": {
"level": "warn", "level": "warn",
@@ -36,20 +44,38 @@
"noBannedTypes": "off" "noBannedTypes": "off"
}, },
"correctness": { "correctness": {
"noUnusedVariables": {
"fix": "none",
"level": "error"
},
"noUnusedImports": { "noUnusedImports": {
"fix": "none", "fix": "none",
"level": "warn" "level": "error"
} }
} }
} }
}, },
"overrides": [ "overrides": [
{ {
"include": ["apps/webui/src/infra/graphql/gql/**/*"], "includes": ["**/tsconfig.json", "**/tsconfig.*.json"],
"json": {
"parser": {
"allowComments": true
}
}
},
{
"includes": ["**/apps/webui/src/infra/graphql/gql/**/*"],
"assist": {
"actions": {
"source": {
"organizeImports": "off"
}
}
},
"linter": { "linter": {
"rules": { "rules": {
"style": { "style": {
"useShorthandArrayType": "off",
"useConsistentArrayType": "off", "useConsistentArrayType": "off",
"useImportType": "off" "useImportType": "off"
} }
@@ -57,7 +83,7 @@
} }
}, },
{ {
"include": ["apps/webui/src/components/ui/**/*"], "includes": ["**/apps/webui/src/components/ui/**/*"],
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double" "quoteStyle": "double"
@@ -67,10 +93,10 @@
"rules": { "rules": {
"style": { "style": {
"useBlockStatements": "off", "useBlockStatements": "off",
"useImportType": "off" "useImportType": "off",
"noNestedTernary": "off"
}, },
"nursery": { "nursery": {
"noNestedTernary": "off",
"useSortedClasses": "off" "useSortedClasses": "off"
}, },
"a11y": { "a11y": {
@@ -86,6 +112,6 @@
} }
], ],
"files": { "files": {
"ignore": [".vscode/*.json"] "includes": ["**", "!**/.vscode/**/*.json"]
} }
} }

View File

@@ -8,7 +8,7 @@ clean-cargo-incremental:
prepare-dev: prepare-dev:
cargo install cargo-binstall cargo install cargo-binstall
cargo binstall sea-orm-cli cargo-llvm-cov cargo-nextest cargo binstall sea-orm-cli cargo-llvm-cov cargo-nextest
# <package-manager> install watchexec just zellij nasm libjxl netcat # <package-manager> install watchexec just zellij nasm libjxl netcat heaptrack
prepare-dev-testcontainers: prepare-dev-testcontainers:
docker pull linuxserver/qbittorrent:latest docker pull linuxserver/qbittorrent:latest
@@ -36,6 +36,10 @@ dev-recorder:
prod-recorder: prod-webui prod-recorder: prod-webui
cargo run --release -p recorder --bin recorder_cli -- --environment=production --working-dir=apps/recorder --graceful-shutdown=false cargo run --release -p recorder --bin recorder_cli -- --environment=production --working-dir=apps/recorder --graceful-shutdown=false
prod-recorder-heaptrack: prod-webui
cargo build --release -p recorder --bin recorder_cli
heaptrack target/release/recorder_cli --environment=production --working-dir=apps/recorder --graceful-shutdown=false
dev-recorder-migrate-down: dev-recorder-migrate-down:
cargo run -p recorder --bin migrate_down -- --environment development cargo run -p recorder --bin migrate_down -- --environment development

View File

@@ -18,22 +18,23 @@
"bump-deps": "npx --yes npm-check-updates --deep -u && pnpm install", "bump-deps": "npx --yes npm-check-updates --deep -u && pnpm install",
"clean": "git clean -xdf node_modules" "clean": "git clean -xdf node_modules"
}, },
"packageManager": "pnpm@10.12.1", "packageManager": "pnpm@10.12.4",
"engines": { "engines": {
"node": ">=22" "node": ">=24"
}, },
"dependencies": { "dependencies": {
"@typescript/native-preview": "7.0.0-dev.20250712.1",
"es-toolkit": "^1.39.6" "es-toolkit": "^1.39.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "2.1.1",
"@types/node": "^24.0.10", "@types/node": "^24.0.10",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"kill-port": "^2.0.1", "kill-port": "^2.0.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"ultracite": "^4.2.13" "ultracite": "^5.0.32"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {

447
pnpm-lock.yaml generated
View File

@@ -11,13 +11,16 @@ importers:
.: .:
dependencies: dependencies:
'@typescript/native-preview':
specifier: 7.0.0-dev.20250712.1
version: 7.0.0-dev.20250712.1
es-toolkit: es-toolkit:
specifier: ^1.39.6 specifier: ^1.39.6
version: 1.39.6 version: 1.39.6
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 1.9.4 specifier: 2.1.1
version: 1.9.4 version: 2.1.1
'@types/node': '@types/node':
specifier: ^24.0.10 specifier: ^24.0.10
version: 24.0.10 version: 24.0.10
@@ -37,8 +40,8 @@ importers:
specifier: ^5.8.3 specifier: ^5.8.3
version: 5.8.3 version: 5.8.3
ultracite: ultracite:
specifier: ^4.2.13 specifier: ^5.0.32
version: 4.2.13 version: 5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
apps/docs: {} apps/docs: {}
@@ -89,6 +92,12 @@ importers:
'@corvu/resizable': '@corvu/resizable':
specifier: ^0.2.5 specifier: ^0.2.5
version: 0.2.5(solid-js@1.9.7) version: 0.2.5(solid-js@1.9.7)
'@datasert/cronjs-matcher':
specifier: ^1.4.0
version: 1.4.0
'@datasert/cronjs-parser':
specifier: ^1.4.0
version: 1.4.0
'@graphiql/toolkit': '@graphiql/toolkit':
specifier: ^0.11.3 specifier: ^0.11.3
version: 0.11.3(@types/node@24.0.10)(graphql-ws@6.0.4(graphql@16.11.0)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(graphql@16.11.0) version: 0.11.3(@types/node@24.0.10)(graphql-ws@6.0.4(graphql@16.11.0)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(graphql@16.11.0)
@@ -263,6 +272,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
tailwind-scrollbar:
specifier: ^4.0.2
version: 4.0.2(react@19.1.0)(tailwindcss@4.1.10)
tailwindcss: tailwindcss:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10 version: 4.1.10
@@ -592,59 +604,65 @@ packages:
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==} resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@biomejs/biome@1.9.4': '@biomejs/biome@2.1.1':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
hasBin: true hasBin: true
'@biomejs/cli-darwin-arm64@1.9.4': '@biomejs/cli-darwin-arm64@2.1.1':
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} resolution: {integrity: sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@biomejs/cli-darwin-x64@1.9.4': '@biomejs/cli-darwin-x64@2.1.1':
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} resolution: {integrity: sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@biomejs/cli-linux-arm64-musl@1.9.4': '@biomejs/cli-linux-arm64-musl@2.1.1':
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} resolution: {integrity: sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@biomejs/cli-linux-arm64@1.9.4': '@biomejs/cli-linux-arm64@2.1.1':
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} resolution: {integrity: sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@biomejs/cli-linux-x64-musl@1.9.4': '@biomejs/cli-linux-x64-musl@2.1.1':
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} resolution: {integrity: sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@biomejs/cli-linux-x64@1.9.4': '@biomejs/cli-linux-x64@2.1.1':
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} resolution: {integrity: sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@biomejs/cli-win32-arm64@1.9.4': '@biomejs/cli-win32-arm64@2.1.1':
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} resolution: {integrity: sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@biomejs/cli-win32-x64@1.9.4': '@biomejs/cli-win32-x64@2.1.1':
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} resolution: {integrity: sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@clack/core@0.5.0':
resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@codemirror/language@6.11.1': '@codemirror/language@6.11.1':
resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==} resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==}
@@ -712,6 +730,12 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@datasert/cronjs-matcher@1.4.0':
resolution: {integrity: sha512-5wAAKYfClZQDWjOeGReEnGLlBKds5K0CitnTv17sH32X4PSuck1dysX71zzCgrm0JCSpobDNg4b292ewhoy6ww==}
'@datasert/cronjs-parser@1.4.0':
resolution: {integrity: sha512-zHGlrWanS4Zjgf0aMi/sp/HTSa2xWDEtXW9xshhlGf/jPx3zTIqfX14PZnoFF7XVOwzC49Zy0SFWG90rlRY36Q==}
'@date-fns/tz@1.2.0': '@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
@@ -3167,6 +3191,9 @@ packages:
'@types/node@24.0.10': '@types/node@24.0.10':
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/react-dom@19.1.6': '@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies: peerDependencies:
@@ -3184,6 +3211,53 @@ packages:
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-h5MlpLXr6I6zrKZKZOjyKdUhzUuO2+kKLEYmrR0HWS/U6dlcwvNuS2wUo1lRNgUTCblHJuOKmyWx3Sz+JG8Oxw==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-72SPfl0/U2ra4KlkzLWgA86zLKUIimBPYE1NqZpJHLMoXkC5XtS9aXT8p6ivxkK+p1VV3fIWKM8BbxOUY3af0A==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-LqIRXAn1xC5amD+ypd7xVTyMhgbbhb9XLzLM32Gr8ogJUCvcLBbd8KCHsKnHSR8nc+3b9FKnYdK7YL6a2IqavA==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-XedHV/oRfLKrfU7XE5/Fz8Lf+eKCaQyNINCikQQlAuQWgT4pf8gW9FPkZYmeNvl7y+++43Wr2YJklmwXMrIDiA==}
engines: {node: '>=20.6.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-dCIb4GTXvetTpeVJRsKf0lNnq1udfjkhDmyUX15yWV41mPg+PJUiLeZty2GOwFovSfEUSK+pQSP2iaA6ITbLtw==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-+Wze9OFlre7YxOh/2LePfh6TmdwMJkSyiFD6XRtmm1hkwoB8jk5h1Q5aC5P4a3LTYx6jie6eYSVSYUXtaWZqMw==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-PwLTlosngLgI4O41qjIFFanl5Q+G8bzUIvFdT0yk2vPAPnPyT0N2gLmGbAJouM3nSe7e+id2+iqdY+QCPvh0uA==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-A8/aOsMpG6H8IcSIKYJSuHzbNkVr8dJOxbb4LMrSfOZ/JWayHQ4O5UJ9mSaKtyPwR6fInE5B8yMt7BYQOz77kA==}
engines: {node: '>=20.6.0'}
hasBin: true
'@vitejs/plugin-react@4.5.2': '@vitejs/plugin-react@4.5.2':
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==} resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@@ -3193,6 +3267,9 @@ packages:
'@vitest/expect@3.2.3': '@vitest/expect@3.2.3':
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==} resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.3': '@vitest/mocker@3.2.3':
resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==} resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==}
peerDependencies: peerDependencies:
@@ -3204,21 +3281,47 @@ packages:
vite: vite:
optional: true optional: true
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.3': '@vitest/pretty-format@3.2.3':
resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==} resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==}
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.3': '@vitest/runner@3.2.3':
resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==} resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.3': '@vitest/snapshot@3.2.3':
resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==} resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.3': '@vitest/spy@3.2.3':
resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==} resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.3': '@vitest/utils@3.2.3':
resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==} resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -4858,6 +4961,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsonfile@2.4.0: jsonfile@2.4.0:
resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==} resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==}
@@ -4984,6 +5090,9 @@ packages:
loupe@3.1.3: loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
loupe@3.1.4:
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
lower-case-first@2.0.2: lower-case-first@2.0.2:
resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==}
@@ -5008,6 +5117,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
magic-string@0.30.17: magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -5467,6 +5580,11 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
react: '>=16.0.0'
prismjs@1.29.0: prismjs@1.29.0:
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -5925,6 +6043,9 @@ packages:
simple-swizzle@0.2.2: simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6162,6 +6283,12 @@ packages:
tailwind-merge@3.3.1: tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwind-scrollbar@4.0.2:
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
engines: {node: '>=12.13.0'}
peerDependencies:
tailwindcss: 4.x
tailwindcss@4.1.10: tailwindcss@4.1.10:
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==} resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
@@ -6227,6 +6354,10 @@ packages:
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==} resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0: tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -6351,8 +6482,8 @@ packages:
uc.micro@2.1.0: uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ultracite@4.2.13: ultracite@5.0.32:
resolution: {integrity: sha512-j49R1z3xXIPhdvU19x0z0Z4hNewJYn4F1h42ULeaCOylBuxwGVE401piPxe3aVapwue7+Ec3J6wnL/+mW4zwww==} resolution: {integrity: sha512-JjVNswL1mkIaOkPVh1nuEGnbEaCa94+ftqJ9hpRX2Y+jt72pcv32JeWg3Dqhkz/e3l449f7KAzMHO4x6IbxFZQ==}
hasBin: true hasBin: true
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
@@ -6459,6 +6590,11 @@ packages:
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true hasBin: true
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.11: vite@5.4.11:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -6518,6 +6654,34 @@ packages:
jsdom: jsdom:
optional: true optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-languageserver-types@3.17.5: vscode-languageserver-types@3.17.5:
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
@@ -7100,41 +7264,52 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
'@biomejs/biome@1.9.4': '@biomejs/biome@2.1.1':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 1.9.4 '@biomejs/cli-darwin-arm64': 2.1.1
'@biomejs/cli-darwin-x64': 1.9.4 '@biomejs/cli-darwin-x64': 2.1.1
'@biomejs/cli-linux-arm64': 1.9.4 '@biomejs/cli-linux-arm64': 2.1.1
'@biomejs/cli-linux-arm64-musl': 1.9.4 '@biomejs/cli-linux-arm64-musl': 2.1.1
'@biomejs/cli-linux-x64': 1.9.4 '@biomejs/cli-linux-x64': 2.1.1
'@biomejs/cli-linux-x64-musl': 1.9.4 '@biomejs/cli-linux-x64-musl': 2.1.1
'@biomejs/cli-win32-arm64': 1.9.4 '@biomejs/cli-win32-arm64': 2.1.1
'@biomejs/cli-win32-x64': 1.9.4 '@biomejs/cli-win32-x64': 2.1.1
'@biomejs/cli-darwin-arm64@1.9.4': '@biomejs/cli-darwin-arm64@2.1.1':
optional: true optional: true
'@biomejs/cli-darwin-x64@1.9.4': '@biomejs/cli-darwin-x64@2.1.1':
optional: true optional: true
'@biomejs/cli-linux-arm64-musl@1.9.4': '@biomejs/cli-linux-arm64-musl@2.1.1':
optional: true optional: true
'@biomejs/cli-linux-arm64@1.9.4': '@biomejs/cli-linux-arm64@2.1.1':
optional: true optional: true
'@biomejs/cli-linux-x64-musl@1.9.4': '@biomejs/cli-linux-x64-musl@2.1.1':
optional: true optional: true
'@biomejs/cli-linux-x64@1.9.4': '@biomejs/cli-linux-x64@2.1.1':
optional: true optional: true
'@biomejs/cli-win32-arm64@1.9.4': '@biomejs/cli-win32-arm64@2.1.1':
optional: true optional: true
'@biomejs/cli-win32-x64@1.9.4': '@biomejs/cli-win32-x64@2.1.1':
optional: true optional: true
'@clack/core@0.5.0':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@0.11.0':
dependencies:
'@clack/core': 0.5.0
picocolors: 1.1.1
sisteransi: 1.0.5
'@codemirror/language@6.11.1': '@codemirror/language@6.11.1':
dependencies: dependencies:
'@codemirror/state': 6.5.2 '@codemirror/state': 6.5.2
@@ -7216,6 +7391,13 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': '@csstools/css-tokenizer@3.0.4':
optional: true optional: true
'@datasert/cronjs-matcher@1.4.0':
dependencies:
'@datasert/cronjs-parser': 1.4.0
luxon: 3.6.1
'@datasert/cronjs-parser@1.4.0': {}
'@date-fns/tz@1.2.0': {} '@date-fns/tz@1.2.0': {}
'@emnapi/runtime@1.4.3': '@emnapi/runtime@1.4.3':
@@ -9812,6 +9994,8 @@ snapshots:
dependencies: dependencies:
undici-types: 7.8.0 undici-types: 7.8.0
'@types/prismjs@1.26.5': {}
'@types/react-dom@19.1.6(@types/react@19.0.1)': '@types/react-dom@19.1.6(@types/react@19.0.1)':
dependencies: dependencies:
'@types/react': 19.0.1 '@types/react': 19.0.1
@@ -9836,6 +10020,37 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.0.10 '@types/node': 24.0.10
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20250712.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20250712.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20250712.1
'@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))': '@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies: dependencies:
'@babel/core': 7.27.4 '@babel/core': 7.27.4
@@ -9856,6 +10071,14 @@ snapshots:
chai: 5.2.0 chai: 5.2.0
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.2
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.3(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))': '@vitest/mocker@3.2.3(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies: dependencies:
'@vitest/spy': 3.2.3 '@vitest/spy': 3.2.3
@@ -9864,32 +10087,66 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1) vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
'@vitest/mocker@3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
'@vitest/pretty-format@3.2.3': '@vitest/pretty-format@3.2.3':
dependencies: dependencies:
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.3': '@vitest/runner@3.2.3':
dependencies: dependencies:
'@vitest/utils': 3.2.3 '@vitest/utils': 3.2.3
pathe: 2.0.3 pathe: 2.0.3
strip-literal: 3.0.0 strip-literal: 3.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.0.0
'@vitest/snapshot@3.2.3': '@vitest/snapshot@3.2.3':
dependencies: dependencies:
'@vitest/pretty-format': 3.2.3 '@vitest/pretty-format': 3.2.3
magic-string: 0.30.17 magic-string: 0.30.17
pathe: 2.0.3 pathe: 2.0.3
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/spy@3.2.3': '@vitest/spy@3.2.3':
dependencies: dependencies:
tinyspy: 4.0.3 tinyspy: 4.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.3
'@vitest/utils@3.2.3': '@vitest/utils@3.2.3':
dependencies: dependencies:
'@vitest/pretty-format': 3.2.3 '@vitest/pretty-format': 3.2.3
loupe: 3.1.3 loupe: 3.1.3
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.1.4
tinyrainbow: 2.0.0
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
dependencies: dependencies:
'@webassemblyjs/helper-numbers': 1.13.2 '@webassemblyjs/helper-numbers': 1.13.2
@@ -10219,7 +10476,7 @@ snapshots:
browserslist@4.25.0: browserslist@4.25.0:
dependencies: dependencies:
caniuse-lite: 1.0.30001721 caniuse-lite: 1.0.30001726
electron-to-chromium: 1.5.165 electron-to-chromium: 1.5.165
node-releases: 2.0.19 node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0) update-browserslist-db: 1.1.3(browserslist@4.25.0)
@@ -10282,8 +10539,7 @@ snapshots:
caniuse-lite@1.0.30001721: {} caniuse-lite@1.0.30001721: {}
caniuse-lite@1.0.30001726: caniuse-lite@1.0.30001726: {}
optional: true
capital-case@1.0.4: capital-case@1.0.4:
dependencies: dependencies:
@@ -11749,6 +12005,8 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonc-parser@3.3.1: {}
jsonfile@2.4.0: jsonfile@2.4.0:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -11866,6 +12124,8 @@ snapshots:
loupe@3.1.3: {} loupe@3.1.3: {}
loupe@3.1.4: {}
lower-case-first@2.0.2: lower-case-first@2.0.2:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11892,6 +12152,8 @@ snapshots:
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
luxon@3.6.1: {}
magic-string@0.30.17: magic-string@0.30.17:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
@@ -12317,6 +12579,12 @@ snapshots:
prettier@3.5.3: {} prettier@3.5.3: {}
prism-react-renderer@2.4.1(react@19.1.0):
dependencies:
'@types/prismjs': 1.26.5
clsx: 2.1.1
react: 19.1.0
prismjs@1.29.0: {} prismjs@1.29.0: {}
prismjs@1.30.0: {} prismjs@1.30.0: {}
@@ -12894,6 +13162,8 @@ snapshots:
is-arrayish: 0.3.2 is-arrayish: 0.3.2
optional: true optional: true
sisteransi@1.0.5: {}
slash@3.0.0: {} slash@3.0.0: {}
slice-ansi@3.0.0: slice-ansi@3.0.0:
@@ -13154,6 +13424,13 @@ snapshots:
tailwind-merge@3.3.1: {} tailwind-merge@3.3.1: {}
tailwind-scrollbar@4.0.2(react@19.1.0)(tailwindcss@4.1.10):
dependencies:
prism-react-renderer: 2.4.1(react@19.1.0)
tailwindcss: 4.1.10
transitivePeerDependencies:
- react
tailwindcss@4.1.10: {} tailwindcss@4.1.10: {}
tapable@2.2.2: {} tapable@2.2.2: {}
@@ -13208,6 +13485,8 @@ snapshots:
tinypool@1.1.0: {} tinypool@1.1.0: {}
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {} tinyrainbow@2.0.0: {}
tinyspy@4.0.3: {} tinyspy@4.0.3: {}
@@ -13334,9 +13613,30 @@ snapshots:
uc.micro@2.1.0: {} uc.micro@2.1.0: {}
ultracite@4.2.13: ultracite@5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies: dependencies:
'@clack/prompts': 0.11.0
commander: 14.0.0 commander: 14.0.0
deepmerge: 4.3.1
jsonc-parser: 3.3.1
vitest: 3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
transitivePeerDependencies:
- '@edge-runtime/vm'
- '@types/debug'
- '@types/node'
- '@vitest/browser'
- '@vitest/ui'
- happy-dom
- jsdom
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
dependencies: dependencies:
@@ -13471,6 +13771,24 @@ snapshots:
- supports-color - supports-color
- terser - terser
vite-node@3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1): vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
@@ -13522,6 +13840,45 @@ snapshots:
- supports-color - supports-color
- terser - terser
vitest@3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
vite-node: 3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.0.10
jsdom: 25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vscode-languageserver-types@3.17.5: {} vscode-languageserver-types@3.17.5: {}
w3c-keyname@2.2.8: {} w3c-keyname@2.2.8: {}

View File

@@ -19,8 +19,9 @@
"target": "ES2020", "target": "ES2020",
"strictNullChecks": true, "strictNullChecks": true,
"strict": true, "strict": true,
"noUnusedLocals": true, // controlled by biome
"noUnusedParameters": true, "noUnusedLocals": false,
"noUnusedParameters": false,
"useDefineForClassFields": true "useDefineForClassFields": true
} }
} }

View File

@@ -10,7 +10,6 @@
{ {
"path": "./apps/webui" "path": "./apps/webui"
}, },
{ {
"path": "./packages/email" "path": "./packages/email"
}, },