Update 1.3.0
This commit is contained in:
+5
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -21,13 +21,15 @@ futures-util = "0.3"
|
||||
ecow = "0.2"
|
||||
|
||||
typst = { version = "0.14.2", path = "../typst/crates/typst" }
|
||||
typst-kit = { path = "../typst/crates/typst-kit", features = ["downloads", "packages", "embed-fonts"] }
|
||||
typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||
typst-pdf = { path = "../typst/crates/typst-pdf" }
|
||||
typst-render = { path = "../typst/crates/typst-render" }
|
||||
typst-svg = { path = "../typst/crates/typst-svg" }
|
||||
typst-layout = { path = "../typst/crates/typst-layout" }
|
||||
|
||||
yrs = "0.18.8"
|
||||
yrs-axum = "0.8"
|
||||
|
||||
typst-assets = "0.14.2"
|
||||
typst-assets = { version = "0.14.2", features = ["fonts"] }
|
||||
tokio-stream = "0.1.18"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
+26
-10
@@ -1,7 +1,7 @@
|
||||
use crate::world::MemoryWorld;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::{SourceDiagnostic, Warned};
|
||||
use typst::layout::PagedDocument;
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::render;
|
||||
|
||||
@@ -16,15 +16,15 @@ impl TypstCompiler {
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<(Vec<String>, String), Vec<SourceDiagnostic>> {
|
||||
) -> Result<(Vec<String>, String), Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let svgs = doc.pages.iter().map(typst_svg::svg).collect();
|
||||
let thumbnail = if let Some(page) = doc.pages.first() {
|
||||
let svgs = doc.pages().iter().map(typst_svg::svg).collect();
|
||||
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||
typst_svg::svg(page)
|
||||
} else {
|
||||
String::new()
|
||||
@@ -35,7 +35,11 @@ impl TypstCompiler {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => {
|
||||
let diag = errors.into_iter().collect();
|
||||
use typst::World;
|
||||
let diag = errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect();
|
||||
Err(diag)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +49,7 @@ impl TypstCompiler {
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
@@ -61,7 +65,13 @@ impl TypstCompiler {
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
} => {
|
||||
use typst::World;
|
||||
Err(errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +79,14 @@ impl TypstCompiler {
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
if let Some(page) = doc.pages.first() {
|
||||
if let Some(page) = doc.pages().first() {
|
||||
let pixmap = render(page, 2.0);
|
||||
if let Ok(encoded) = pixmap.encode_png() {
|
||||
return Ok(encoded);
|
||||
@@ -87,7 +97,13 @@ impl TypstCompiler {
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
} => {
|
||||
use typst::World;
|
||||
Err(errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -233,6 +233,7 @@ pub async fn upload_file(
|
||||
let (_, folder_id) = doc_exists.unwrap();
|
||||
|
||||
let mut uploaded_filename = String::new();
|
||||
let mut font_family = None;
|
||||
|
||||
if let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
@@ -241,6 +242,12 @@ pub async fn upload_file(
|
||||
|
||||
let file_id = Uuid::new_v4().to_string();
|
||||
|
||||
if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") {
|
||||
if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() {
|
||||
font_family = Some(font.info().family.clone());
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
@@ -256,5 +263,8 @@ pub async fn upload_file(
|
||||
uploaded_filename = file_name;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"filename": uploaded_filename})))
|
||||
Ok(Json(serde_json::json!({
|
||||
"filename": uploaded_filename,
|
||||
"font_family": font_family
|
||||
})))
|
||||
}
|
||||
|
||||
+42
-1
@@ -63,6 +63,7 @@ pub async fn upload_file_global(
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let mut uploaded_files = vec![];
|
||||
let mut font_families = vec![];
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
@@ -71,6 +72,13 @@ pub async fn upload_file_global(
|
||||
|
||||
let file_id = Uuid::new_v4().to_string();
|
||||
|
||||
let mut font_family = None;
|
||||
if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") {
|
||||
if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() {
|
||||
font_family = Some(font.info().family.clone());
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
@@ -83,9 +91,13 @@ pub async fn upload_file_global(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded_files.push(file_name);
|
||||
font_families.push(font_family);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"files": uploaded_files})))
|
||||
Ok(Json(serde_json::json!({
|
||||
"files": uploaded_files,
|
||||
"font_families": font_families
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_file_data(
|
||||
@@ -135,6 +147,35 @@ pub async fn delete_file(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_fonts(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let files = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT name FROM files WHERE owner_id = $1"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
for (name,) in files {
|
||||
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
|
||||
if let Some(stem) = std::path::Path::new(&name).file_stem() {
|
||||
if let Some(stem_str) = stem.to_str() {
|
||||
fonts.push(stem_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(fonts))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFileRequest {
|
||||
pub name: Option<String>,
|
||||
|
||||
+152
-1
@@ -64,6 +64,8 @@ pub struct CompileResponse {
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
pub from: Option<usize>,
|
||||
pub to: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn yjs_handler(
|
||||
@@ -232,9 +234,11 @@ pub async fn compile_handler(
|
||||
Err(diags) => {
|
||||
let errors = diags
|
||||
.into_iter()
|
||||
.map(|d| Diagnostic {
|
||||
.map(|(d, range)| Diagnostic {
|
||||
message: d.message.to_string(),
|
||||
severity: format!("{:?}", d.severity),
|
||||
from: range.as_ref().map(|r| r.start),
|
||||
to: range.as_ref().map(|r| r.end),
|
||||
})
|
||||
.collect();
|
||||
Json(CompileResponse {
|
||||
@@ -464,3 +468,150 @@ pub async fn pandoc_import_handler(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn lsp_handler(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::SignedCookieJar,
|
||||
) -> impl IntoResponse {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
let doc = match sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(&id).fetch_optional(&state.db).await {
|
||||
Ok(Some(d)) => d,
|
||||
_ => return (StatusCode::NOT_FOUND, "Document not found").into_response(),
|
||||
};
|
||||
|
||||
let mut has_access = false;
|
||||
if let Some(uid) = &user_id_opt {
|
||||
if &doc.owner_id == uid {
|
||||
has_access = true;
|
||||
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
|
||||
.bind(&id)
|
||||
.bind(uid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
has_access = true;
|
||||
}
|
||||
}
|
||||
if !has_access {
|
||||
if let Some(pr) = &doc.public_role {
|
||||
if pr == "viewer" || pr == "editor" {
|
||||
has_access = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_access {
|
||||
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||
}
|
||||
|
||||
let mut files_map = std::collections::HashMap::new();
|
||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1")
|
||||
.bind(doc.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in files {
|
||||
files_map.insert(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
for (name, data) in files_map {
|
||||
let path = temp_dir.path().join(&name);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&path, data);
|
||||
}
|
||||
|
||||
let mut child = Command::new("tinymist")
|
||||
.arg("lsp")
|
||||
.arg("--font-path")
|
||||
.arg(temp_dir.path())
|
||||
.current_dir(temp_dir.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to start tinymist lsp");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
let root_uri = format!("file://{}", temp_dir.path().display());
|
||||
let init_msg = serde_json::json!({
|
||||
"type": "init",
|
||||
"rootUri": root_uri
|
||||
});
|
||||
use futures_util::SinkExt;
|
||||
let _ = ws_tx.send(axum::extract::ws::Message::Text(init_msg.to_string().into())).await;
|
||||
|
||||
let ws_to_lsp = tokio::spawn(async move {
|
||||
while let Some(Ok(axum::extract::ws::Message::Text(msg))) = ws_rx.next().await {
|
||||
let content_length = format!("Content-Length: {}\r\n\r\n", msg.len());
|
||||
if stdin.write_all(content_length.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin.write_all(msg.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let lsp_to_ws = tokio::spawn(async move {
|
||||
loop {
|
||||
let mut content_length = 0;
|
||||
let mut header = String::new();
|
||||
loop {
|
||||
let mut char_buf = [0; 1];
|
||||
if stdout_reader.read_exact(&mut char_buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
header.push(char_buf[0] as char);
|
||||
if header.ends_with("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for line in header.split("\r\n") {
|
||||
if line.starts_with("Content-Length: ") {
|
||||
if let Ok(len) = line["Content-Length: ".len()..].trim().parse::<usize>() {
|
||||
content_length = len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content_length == 0 { continue; }
|
||||
|
||||
let mut body = vec![0; content_length];
|
||||
if stdout_reader.read_exact(&mut body).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(text) = String::from_utf8(body) {
|
||||
use futures_util::SinkExt;
|
||||
if ws_tx.send(axum::extract::ws::Message::Text(text.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = ws_to_lsp => {}
|
||||
_ = lsp_to_ws => {}
|
||||
_ = child.wait() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ async fn main() {
|
||||
.route("/export/{format}", post(export_handler))
|
||||
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
|
||||
.route("/import/pandoc", post(handlers::pandoc_import_handler))
|
||||
.route("/lsp/{id}", get(handlers::lsp_handler))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
@@ -77,6 +78,7 @@ async fn main() {
|
||||
.route("/auth/change-password", put(auth::change_password))
|
||||
.route("/folders", get(folders::list_folders).post(folders::create_folder))
|
||||
.route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder))
|
||||
.route("/fonts", get(files::list_fonts))
|
||||
.route("/files", get(files::list_files).post(files::upload_file_global))
|
||||
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
|
||||
.route("/files/{id}/data", get(files::get_file_data))
|
||||
|
||||
+35
-31
@@ -2,13 +2,13 @@ use chrono::Datelike;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime};
|
||||
use typst::syntax::{FileId, Source, VirtualPath};
|
||||
use typst_kit::download::{Downloader, ProgressSink};
|
||||
use typst_kit::package::PackageStorage;
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::World;
|
||||
use typst::{Library, LibraryExt};
|
||||
use typst_kit::downloader::SystemDownloader;
|
||||
use typst_kit::packages::SystemPackages;
|
||||
|
||||
pub struct MemoryWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
@@ -17,15 +17,18 @@ pub struct MemoryWorld {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
packages: PackageStorage,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
impl MemoryWorld {
|
||||
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
|
||||
let main = FileId::new(None, VirtualPath::new("main.typ"));
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new("main.typ").unwrap(),
|
||||
));
|
||||
let source = Source::new(main, text);
|
||||
let downloader = Downloader::new("TypstDrive (typst-kit)");
|
||||
let packages = PackageStorage::new(None, None, downloader);
|
||||
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
let mut book = FontBook::new();
|
||||
let mut fonts = Vec::new();
|
||||
@@ -41,7 +44,7 @@ impl MemoryWorld {
|
||||
|
||||
// Add custom fonts from files
|
||||
for (name, data) in &files {
|
||||
if name.ends_with(".ttf") || name.ends_with(".otf") {
|
||||
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
|
||||
for font in Font::iter(Bytes::new(data.clone())) {
|
||||
let info = font.info().clone();
|
||||
book.push(info.clone());
|
||||
@@ -87,39 +90,39 @@ impl World for MemoryWorld {
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.main {
|
||||
Ok(self.source.clone())
|
||||
} else if let Some(package) = id.package() {
|
||||
let dir = self
|
||||
} else if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.prepare_package(package, &mut ProgressSink)
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
|
||||
let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
|
||||
let text = String::from_utf8(data).map_err(|_| FileError::InvalidUtf8)?;
|
||||
let data = root.load(id.vpath())?;
|
||||
let text = std::str::from_utf8(&data)
|
||||
.map_err(|_| FileError::InvalidUtf8)?
|
||||
.to_owned();
|
||||
Ok(Source::new(id, text))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
id.vpath().as_rootless_path().into(),
|
||||
))
|
||||
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
Ok(Bytes::from_string(self.source.text().to_string()))
|
||||
} else if let Some(package) = id.package() {
|
||||
let dir = self
|
||||
} else if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.prepare_package(package, &mut ProgressSink)
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
|
||||
let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
|
||||
Ok(Bytes::new(data))
|
||||
} else if let Some(data) = self.files.get(&id.vpath().as_rootless_path().to_string_lossy().to_string().replace("\\", "/")) {
|
||||
root.load(id.vpath())
|
||||
} else if let Some(data) = self.files.get(
|
||||
&id.vpath()
|
||||
.get_without_slash()
|
||||
.to_string()
|
||||
.replace("\\", "/"),
|
||||
) {
|
||||
Ok(Bytes::new(data.clone()))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
id.vpath().as_rootless_path().into(),
|
||||
))
|
||||
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +130,12 @@ impl World for MemoryWorld {
|
||||
self.fonts.get(index).cloned()
|
||||
}
|
||||
|
||||
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
let now = chrono::Local::now();
|
||||
let date = if let Some(offset) = offset {
|
||||
let offset = chrono::FixedOffset::east_opt(offset as i32)?;
|
||||
now.with_timezone(&offset).date_naive()
|
||||
let offset_secs = offset.hours() as i32 * 3600;
|
||||
let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?;
|
||||
now.with_timezone(&offset_chrono).date_naive()
|
||||
} else {
|
||||
now.date_naive()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user