Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/expo/components/book/overview/BookMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export default function BookMenu({ data }: Props) {
client.refetchQueries({ queryKey: ['onDeck'], exact: false }),
client.refetchQueries({ queryKey: ['recentlyAddedBooks'], exact: false }),
client.refetchQueries({ queryKey: ['recentlyAddedSeries'], exact: false }),
// TODO: would be better to have a little bit smarter cache invalidation here,
// im casting a wide net because i don't want to have to figure out where i am
// in the router (e.g., did i come from books? a series? etc)
client.invalidateQueries({ queryKey: ['seriesById', book.series.id], exact: false }), // stats
client.invalidateQueries({ queryKey: ['seriesBooks', book.series.id], exact: false }),
client.invalidateQueries({ queryKey: ['booksStats', serverID], exact: false }), // stats
client.invalidateQueries({ queryKey: ['books', serverID], exact: false }), // server books
])
}

Expand Down
49 changes: 24 additions & 25 deletions apps/expo/components/listLayout/grid/GridImageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,37 +91,36 @@ export default function GridImageItem({
)}

{hasCompleted && (
<BlurView
blurTarget={blurTargetRef}
blurMethod="dimezisBlurView"
<View
className={cn(
'right-2 bottom-2 squircle absolute z-30 rounded-full',
isReading && 'bottom-5',
)}
intensity={4}
>
<View className="bg-white/30 flex flex-row items-center justify-center">
{showNumber && (
<Text
className="font-bold ml-2 shadow tablet:text-base"
style={{
color: '#f5f3ef',
}}
>
{numberOfReads}
</Text>
)}
<BlurView blurTarget={blurTargetRef} blurMethod="dimezisBlurView" intensity={4}>
<View className="bg-white/30 flex flex-row items-center justify-center">
{showNumber && (
<Text
className="font-bold ml-2 shadow tablet:text-base"
style={{
color: '#f5f3ef',
}}
>
{numberOfReads}
</Text>
)}

<Icon
as={Check}
// This icon looks optically off center so I've adjusted it down a bit
className="shadow m-1 top-[0.7]"
size={20}
color="#f5f3ef"
strokeWidth={2.5}
/>
</View>
</BlurView>
<Icon
as={Check}
// This icon looks optically off center so I've adjusted it down a bit
className="shadow m-1 top-[0.7] z-50"
size={20}
color="#f5f3ef"
strokeWidth={2.5}
/>
</View>
</BlurView>
</View>
)}
</View>

Expand Down
2 changes: 1 addition & 1 deletion apps/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"expo-application": "~56.0.3",
"expo-asset": "~56.0.17",
"expo-auth-session": "~56.0.14",
"expo-blur": "~56.0.3",
"expo-blur": "~56.0.4",
"expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.19",
"expo-constants": "~56.0.18",
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@ impl IntoResponse for APIErrorResponse {

let mut builder = Response::builder()
.status(self.status)
.header("Content-Type", "application/json");
.header("Content-Type", "application/json")
// do not cache error responses
.header("Cache-Control", "no-store");

// if the status is 401, we want to encourage the client to delete their
// session cookie
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ pub async fn run_http_server(config: StumpConfig) -> ServerResult<()> {
.await
.map_err(|e| ServerError::ServerStartError(e.to_string()))?;

// Initialize the scheduler
core.init_scheduler()
let _scheduler = core
.init_scheduler()
.await
.map_err(|e| ServerError::ServerStartError(e.to_string()))?;

Expand Down
1 change: 1 addition & 0 deletions core/src/job/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::job::stump_job::StumpJob;
use crate::{CoreError, CoreResult, Ctx};

/// A scheduler that loads cron-based jobs and spawns them accordingly
#[must_use = "dropping the JobScheduler aborts all scheduled job loops"]
pub struct JobScheduler {
handles: Vec<tokio::task::JoinHandle<()>>,
}
Expand Down
4 changes: 2 additions & 2 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,10 @@ impl StumpCore {
}
}

pub async fn init_scheduler(&self) -> Result<Arc<JobScheduler>, CoreError> {
pub async fn init_scheduler(&self) -> Result<JobScheduler, CoreError> {
let ctx = self.ctx.arced();
let scheduler = JobScheduler::init(ctx).await?;
Ok(Arc::new(scheduler))
Ok(scheduler)
}

pub async fn init_library_watcher(&self) -> CoreResult<()> {
Expand Down
60 changes: 60 additions & 0 deletions crates/graphql/src/filter/keyword.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! LIKE-pattern escaping helpers for string filters

use sea_orm::sea_query::LikeExpr;

/// The escape character paired with every generated `LIKE` pattern
const LIKE_ESCAPE_CHAR: char = '\\';

/// Escapes the LIKE metacharacters `%` and `_`, plus the escape character
/// itself, so e.g., a search for `50%` is correctly understood to be literal
/// and not a pattern
pub fn escape_like_fragment(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
if ch == LIKE_ESCAPE_CHAR || ch == '%' || ch == '_' {
escaped.push(LIKE_ESCAPE_CHAR);
}
escaped.push(ch);
}
escaped
}

/// `%value%`, with `value` lowercased and escaped
pub(crate) fn like_contains(value: &str) -> LikeExpr {
LikeExpr::new(format!("%{}%", escape_like_fragment(&value.to_lowercase())))
.escape(LIKE_ESCAPE_CHAR)
}

/// `value%`, with `value` lowercased and escaped
pub(crate) fn like_starts_with(value: &str) -> LikeExpr {
LikeExpr::new(format!("{}%", escape_like_fragment(&value.to_lowercase())))
.escape(LIKE_ESCAPE_CHAR)
}

/// `%value`, with `value` lowercased and escaped
pub(crate) fn like_ends_with(value: &str) -> LikeExpr {
LikeExpr::new(format!("%{}", escape_like_fragment(&value.to_lowercase())))
.escape(LIKE_ESCAPE_CHAR)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn escape_like_fragment_handles_wildcards() {
assert_eq!(escape_like_fragment("50%"), r"50\%");
assert_eq!(escape_like_fragment("file_name"), r"file\_name");
assert_eq!(escape_like_fragment("100% true"), r"100\% true");
assert_eq!(
escape_like_fragment(r"already\escaped"),
r"already\\escaped"
);
}

#[test]
fn escape_like_fragment_noop_for_safe_strings() {
assert_eq!(escape_like_fragment("normal"), "normal");
assert_eq!(escape_like_fragment("with spaces"), "with spaces");
}
}
82 changes: 74 additions & 8 deletions crates/graphql/src/filter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ use sea_orm::{
};
use serde::{Deserialize, Serialize};

pub mod keyword;
pub mod library;
pub mod log;
pub mod media;
pub mod media_metadata;
pub mod series;
pub mod series_metadata;

use keyword::{like_contains, like_ends_with, like_starts_with};

// TODO: This probably needs a rewrite to make it more compatible with async-graphql. The big issue is generics
// with input objects. Look at and yoink from seaography for how they are doing things

Expand Down Expand Up @@ -77,36 +80,36 @@ where
let v: String = value.into();
Condition::all().add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.like(format!("%{}%", v.to_lowercase())),
.like(like_contains(&v)),
)
},
StringLikeFilter::Excludes(value) => {
let v: String = value.into();
Condition::all().add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.not_like(format!("%{}%", v.to_lowercase())),
.not_like(like_contains(&v)),
)
},
StringLikeFilter::StartsWith(value) => {
let v: String = value.into();
Condition::all().add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.like(format!("{}%", v.to_lowercase())),
.like(like_starts_with(&v)),
)
},
StringLikeFilter::EndsWith(value) => {
let v: String = value.into();
Condition::all().add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.like(format!("%{}", v.to_lowercase())),
.like(like_ends_with(&v)),
)
},
StringLikeFilter::LikeAnyOf(values) => {
values.into_iter().fold(Condition::any(), |acc, value| {
let v: String = value.into();
acc.add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.like(format!("%{}%", v.to_lowercase())),
.like(like_contains(&v)),
)
})
},
Expand All @@ -116,7 +119,7 @@ where
let v: String = value.into();
acc.add(
QExpr::expr(Func::lower(QExpr::col(column.as_column_ref())))
.like(format!("%{}%", v.to_lowercase())),
.like(like_contains(&v)),
)
})
.not(),
Expand Down Expand Up @@ -226,7 +229,7 @@ mod tests {

assert_eq!(
sql,
r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%test%' OR LOWER("media"."name") LIKE '%example%'"#
r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%test%' ESCAPE '\' OR LOWER("media"."name") LIKE '%example%' ESCAPE '\'"#
);
}

Expand All @@ -243,7 +246,70 @@ mod tests {

assert_eq!(
sql,
r#"SELECT FROM "media" WHERE NOT (LOWER("media"."name") LIKE '%test%' OR LOWER("media"."name") LIKE '%example%')"#
r#"SELECT FROM "media" WHERE NOT (LOWER("media"."name") LIKE '%test%' ESCAPE '\' OR LOWER("media"."name") LIKE '%example%' ESCAPE '\')"#
);
}

#[test]
fn test_contains_escapes_wildcards_in_the_pattern() {
let sql = media::Entity::find()
.filter(apply_string_filter(
media::Column::Name,
StringLikeFilter::Contains("50%".to_string()),
))
.select_only()
.into_query()
.to_string(SqliteQueryBuilder);

assert_eq!(
sql,
r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%50\%%' ESCAPE '\'"#
);
}

#[test]
fn test_starts_and_ends_with_escape_wildcards() {
let starts = media::Entity::find()
.filter(apply_string_filter(
media::Column::Name,
StringLikeFilter::StartsWith("file_".to_string()),
))
.select_only()
.into_query()
.to_string(SqliteQueryBuilder);
assert_eq!(
starts,
r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE 'file\_%' ESCAPE '\'"#
);

let ends = media::Entity::find()
.filter(apply_string_filter(
media::Column::Name,
StringLikeFilter::EndsWith("_v1".to_string()),
))
.select_only()
.into_query()
.to_string(SqliteQueryBuilder);
assert_eq!(
ends,
r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%\_v1' ESCAPE '\'"#
);
}

#[test]
fn test_excludes_is_a_substring_negation() {
let sql = media::Entity::find()
.filter(apply_string_filter(
media::Column::Name,
StringLikeFilter::Excludes("annual".to_string()),
))
.select_only()
.into_query()
.to_string(SqliteQueryBuilder);

assert_eq!(
sql,
r#"SELECT FROM "media" WHERE LOWER("media"."name") NOT LIKE '%annual%' ESCAPE '\'"#
);
}
}
Loading
Loading