diff --git a/Cargo.lock b/Cargo.lock index b1b7269..a8a0a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -133,7 +133,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -479,6 +479,7 @@ dependencies = [ "lazy_static", "moka", "mvt-reader", + "pbf_font_tools", "rand 0.8.5", "regex", "rusqlite", @@ -1035,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1079,6 +1080,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1138,6 +1145,28 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-rs" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d228d6de56c90dd7585341f341849441b3490180c62d27133e525eb726809b4" +dependencies = [ + "bitflags 2.10.0", + "freetype-sys", + "libc", +] + +[[package]] +name = "freetype-sys" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab537ce43cab850c64b4cdc390ce7e4f47f877485ddc323208e268280c308ae" +dependencies = [ + "cc", + "libz-sys", + "pkg-config", +] + [[package]] name = "funty" version = "2.0.0" @@ -1152,6 +1181,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1174,6 +1204,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1209,6 +1250,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1837,6 +1879,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1991,6 +2045,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "mvt-reader" version = "2.3.0" @@ -1999,7 +2059,7 @@ checksum = "5057362d82a08bc3c84e420c42939463aff5c3e21a91a41d8b7af94c8f061189" dependencies = [ "geo-types", "num-traits", - "prost", + "prost 0.13.5", ] [[package]] @@ -2008,7 +2068,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2185,6 +2245,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbf_font_tools" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f621f25023f31c597de8b20a44d511d97eacea1943ccbcb709e6ed27156c144d" +dependencies = [ + "futures", + "prost 0.14.3", + "prost-build", + "protoc-bin-vendored", + "sdf_glyph_renderer", + "thiserror", + "tokio", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2201,6 +2276,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -2350,7 +2436,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost 0.14.3", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -2366,6 +2481,92 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2438,7 +2639,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2726,7 +2927,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2782,6 +2983,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdf_glyph_renderer" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672e58ce949c46be42fae549e86cc32c869a292fb40913d5f985decb45c5bde3" +dependencies = [ + "freetype-rs", + "thiserror", +] + [[package]] name = "seahash" version = "4.1.0" @@ -3128,10 +3339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ccdcc19..6ee251f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,6 +43,7 @@ aes-gcm = "0.10" base64 = "0.22" rand = "0.8" sha2 = "0.10" +pbf_font_tools = { version = "3.0", features = ["freetype"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Console"] } diff --git a/backend/src/auth_routes.rs b/backend/src/auth_routes.rs index a47a1d3..2d460bb 100644 --- a/backend/src/auth_routes.rs +++ b/backend/src/auth_routes.rs @@ -39,6 +39,7 @@ pub struct LoginResponse { pub struct CurrentWorkspace { id: String, name: String, + slug: String, } #[derive(Debug, Serialize)] @@ -98,20 +99,21 @@ async fn login( let current_workspace = if let Some(ref workspace_id) = user.current_workspace_id { let conn = state.db.lock().await; - let workspace_info: Option<(String,)> = conn + let workspace_info: Option<(String, String)> = conn .query_row( - "SELECT name FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id) FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![workspace_id], - |row| Ok((row.get(0)?,)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .ok() .flatten(); drop(conn); - workspace_info.map(|(name,)| CurrentWorkspace { + workspace_info.map(|(name, slug)| CurrentWorkspace { id: workspace_id.clone(), name, + slug, }) } else { None @@ -140,20 +142,21 @@ async fn check_auth( Some(user) => { let current_workspace = if let Some(ref workspace_id) = user.current_workspace_id { let conn = state.db.lock().await; - let workspace_info: Option<(String,)> = conn + let workspace_info: Option<(String, String)> = conn .query_row( - "SELECT name FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id) FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![workspace_id], - |row| Ok((row.get(0)?,)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .ok() .flatten(); drop(conn); - workspace_info.map(|(name,)| CurrentWorkspace { + workspace_info.map(|(name, slug)| CurrentWorkspace { id: workspace_id.clone(), name, + slug, }) } else { None @@ -285,10 +288,12 @@ async fn init_system( let workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = crate::workspace::make_personal_workspace_name(&req.username); + let workspace_slug = + crate::workspace::workspace_slug_base_from_name_or_id(&workspace_name, &workspace_id); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, TRUE, ?)", - duckdb::params![&workspace_id, &workspace_name, &user_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, TRUE, ?)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id, &created_at], ) .map_err(|e| { conn.execute("ROLLBACK", []).ok(); diff --git a/backend/src/bin/mapflow-desktop.rs b/backend/src/bin/mapflow-desktop.rs index 97550b0..114d4dd 100644 --- a/backend/src/bin/mapflow-desktop.rs +++ b/backend/src/bin/mapflow-desktop.rs @@ -147,6 +147,11 @@ async fn main() -> Result<()> { Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing files on startup"), } + match backend::reconcile_processing_fonts(&state.db).await { + Ok(count) => tracing::info!(reconciled = count, "Reconciled processing fonts on startup"), + Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing fonts on startup"), + } + let web_dist = std::env::var("WEB_DIST").unwrap_or_else(|_| "frontend/dist".to_string()); let web_dist_path = PathBuf::from(&web_dist); let public_viewer_available = backend::detect_public_viewer_available(&web_dist_path); diff --git a/backend/src/db.rs b/backend/src/db.rs index f8b8e2f..c2435ec 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -67,16 +67,42 @@ pub async fn reconcile_processing_files( ) } +pub async fn reconcile_processing_fonts( + db: &Arc>, +) -> Result { + let conn = db.lock().await; + conn.execute( + "UPDATE fonts SET status = 'failed', error = ? WHERE status = 'processing'", + duckdb::params![PROCESSING_RECONCILIATION_ERROR], + ) +} + fn ensure_workspace_schema_and_backfill(conn: &duckdb::Connection) { let _ = conn.execute( "ALTER TABLE users ADD COLUMN current_workspace_id VARCHAR", [], ); let _ = conn.execute("ALTER TABLE files ADD COLUMN workspace_id VARCHAR", []); + let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN slug VARCHAR", []); let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_files_workspace ON files(workspace_id)", [], ); + let _ = conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces(slug)", + [], + ); + let _ = conn.execute( + "ALTER TABLE fonts ADD COLUMN is_public BOOLEAN DEFAULT FALSE", + [], + ); + let _ = conn.execute("ALTER TABLE fonts ADD COLUMN slug VARCHAR", []); + let _ = conn.execute("ALTER TABLE fonts ADD COLUMN published_at TIMESTAMP", []); + let _ = conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_fonts_workspace_slug ON fonts(workspace_id, slug)", + [], + ); + let _ = conn.execute("DROP INDEX IF EXISTS idx_fonts_workspace_fontstack", []); recover_detached_workspace_members(conn).expect("Failed to recover detached workspace members"); backfill_workspace_data(conn).expect("Failed to backfill workspace data"); @@ -132,10 +158,11 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro &crate::workspace::make_personal_workspace_name(&username), &workspace_id, )?; + let workspace_slug = unique_workspace_slug(conn, &workspace_name, &workspace_id)?; conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, TRUE, ?)", - duckdb::params![&workspace_id, &workspace_name, &user_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, TRUE, ?)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id, &created_at], )?; conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, ?)", @@ -199,9 +226,11 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro crate::workspace::make_legacy_shared_workspace_name(), &shared_workspace_id, )?; + let workspace_slug = + unique_workspace_slug(conn, &workspace_name, &shared_workspace_id)?; conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, ?)", - duckdb::params![&shared_workspace_id, &workspace_name, &owner_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, ?)", + duckdb::params![&shared_workspace_id, &workspace_name, &workspace_slug, &owner_id, &created_at], )?; } @@ -297,6 +326,22 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro } } + let workspaces_without_slug = { + let mut stmt = conn.prepare("SELECT id, name FROM workspaces WHERE slug IS NULL")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + rows.collect::, _>>()? + }; + + for (workspace_id, workspace_name) in workspaces_without_slug { + let workspace_slug = unique_workspace_slug(conn, &workspace_name, &workspace_id)?; + conn.execute( + "UPDATE workspaces SET slug = ? WHERE id = ?", + duckdb::params![&workspace_slug, &workspace_id], + )?; + } + Ok(()) } @@ -322,6 +367,39 @@ fn unique_workspace_name( )) } +fn unique_workspace_slug( + conn: &duckdb::Connection, + workspace_name: &str, + workspace_id: &str, +) -> Result { + let base = crate::workspace::workspace_slug_base_from_name_or_id(workspace_name, workspace_id); + + let mut candidate = base.clone(); + let mut suffix = 1_u32; + loop { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM workspaces WHERE slug = ?", + duckdb::params![&candidate], + |row| row.get(0), + )?; + if count == 0 { + return Ok(candidate); + } + + let suffix_text = format!("-{suffix}"); + let max_base_len = crate::workspace::WORKSPACE_SLUG_MAX_LEN + .saturating_sub(suffix_text.len()) + .max(crate::workspace::WORKSPACE_SLUG_MIN_LEN); + let truncated_base = if base.len() > max_base_len { + base[..max_base_len].trim_end_matches('-').to_string() + } else { + base.clone() + }; + candidate = format!("{truncated_base}{suffix_text}"); + suffix = suffix.saturating_add(1); + } +} + pub fn init_database(db_path: &Path) -> duckdb::Connection { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).expect("Failed to create database directory"); @@ -499,6 +577,7 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { CREATE TABLE IF NOT EXISTS workspaces ( id VARCHAR PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, + slug VARCHAR UNIQUE, owner_id VARCHAR NOT NULL REFERENCES users(id), is_personal BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMP, @@ -511,6 +590,9 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { CREATE INDEX IF NOT EXISTS idx_workspaces_deleted_at ON workspaces(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_slug + ON workspaces(slug); + CREATE TABLE IF NOT EXISTS workspace_members ( workspace_id VARCHAR NOT NULL REFERENCES workspaces(id), user_id VARCHAR NOT NULL REFERENCES users(id), @@ -531,6 +613,37 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { ) .expect("Failed to create workspace tables"); + conn.execute_batch( + r" + CREATE TABLE IF NOT EXISTS fonts ( + id VARCHAR PRIMARY KEY, + workspace_id VARCHAR NOT NULL REFERENCES workspaces(id), + name VARCHAR NOT NULL, + fontstack VARCHAR NOT NULL, + family VARCHAR, + style VARCHAR, + original_path VARCHAR NOT NULL, + glyphs_path VARCHAR NOT NULL, + glyph_count INTEGER, + start_cp INTEGER, + end_cp INTEGER, + status VARCHAR NOT NULL DEFAULT 'processing', + error VARCHAR, + is_public BOOLEAN DEFAULT FALSE, + slug VARCHAR, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_fonts_workspace + ON fonts(workspace_id); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_fonts_workspace_slug + ON fonts(workspace_id, slug); + ", + ) + .expect("Failed to create fonts table"); + ensure_workspace_schema_and_backfill(&conn); conn diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs new file mode 100644 index 0000000..4ad842d --- /dev/null +++ b/backend/src/font_handlers.rs @@ -0,0 +1,796 @@ +use axum::{ + extract::{Multipart, Path as AxumPath, State}, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_login::AuthSession; +use duckdb::OptionalExt; +use pbf_font_tools::prost::Message; +use pbf_font_tools::Glyphs; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::Path; +use tokio::{ + fs, + io::{AsyncWriteExt, BufWriter}, +}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::{ + font_processor::{process_font, FontMetadata}, + http_errors::{bad_request, internal_error}, + models::ErrorResponse, + workspace::get_active_workspace_id, + AppState, AuthBackend, +}; + +fn create_id() -> String { + Uuid::new_v4().to_string() +} + +fn relative_path_for(absolute: &Path, upload_dir: &Path) -> String { + if let Ok(relative) = absolute.strip_prefix(upload_dir) { + let dir_name = upload_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("uploads"); + let mut s = format!( + "./{}/{}", + dir_name, + relative.to_string_lossy().replace('\\', "/") + ); + if !s.starts_with('.') { + s = format!("./{s}"); + } + return s; + } + let mut s = absolute.to_string_lossy().replace('\\', "/"); + if !s.starts_with('.') { + s = format!("./{s}"); + } + s +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FontItem { + pub id: String, + pub name: String, + pub fontstack: String, + pub family: Option, + pub style: Option, + pub glyph_count: Option, + pub start_cp: Option, + pub end_cp: Option, + pub status: String, + pub error: Option, + pub is_public: bool, + pub slug: Option, + pub workspace_slug: String, + pub created_at: String, + pub published_at: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FontUploadResponse { + pub id: String, + pub status: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishFontRequest { + pub slug: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishFontResponse { + pub url: String, + pub slug: String, + pub is_public: bool, + pub workspace_slug: String, +} + +pub async fn upload_font( + auth_session: AuthSession, + State(state): State, + mut multipart: Multipart, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + let mut field = loop { + let next = multipart.next_field().await.map_err(|e| { + let message = format!("Invalid multipart form: {e}"); + bad_request(&message) + })?; + match next { + Some(field) if field.name() == Some("file") => break field, + Some(_) => continue, + None => return Err(bad_request("No file uploaded")), + } + }; + + let original_name = field + .file_name() + .map(|name| name.to_string()) + .ok_or_else(|| bad_request("Missing file name"))?; + + let safe_name = Path::new(&original_name) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| bad_request("Invalid file name"))? + .to_string(); + + let ext = Path::new(&safe_name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()) + .ok_or_else(|| bad_request("Unsupported file type. Use .ttf or .otf"))?; + + if !["ttf", "otf"].contains(&ext.as_str()) { + return Err(bad_request("Unsupported file type. Use .ttf or .otf")); + } + + let font_id = create_id(); + info!(font_id = %font_id, filename = %safe_name, "Font upload started"); + + let fonts_dir = state.upload_dir.join("fonts").join(&font_id); + fs::create_dir_all(&fonts_dir) + .await + .map_err(internal_error)?; + + let original_path = fonts_dir.join(format!("original.{}", ext)); + let mut file = BufWriter::new( + fs::File::create(&original_path) + .await + .map_err(internal_error)?, + ); + + let mut size: u64 = 0; + let max_size = *state.max_size.read().await; + let max_size_label = state.max_size_label.read().await.clone(); + while let Some(chunk) = field.chunk().await.map_err(internal_error)? { + size = size.saturating_add(chunk.len() as u64); + if size > max_size { + drop(file); + let _ = fs::remove_file(&original_path).await; + let _ = fs::remove_dir(&fonts_dir).await; + let message = format!("File too large (max {})", max_size_label); + return Err(bad_request(&message)); + } + file.write_all(&chunk).await.map_err(internal_error)?; + } + file.flush().await.map_err(internal_error)?; + file.get_ref().sync_all().await.map_err(internal_error)?; + drop(file); + + let glyphs_dir = fonts_dir.join("glyphs"); + let original_rel = relative_path_for(&original_path, &state.upload_dir); + let glyphs_rel = relative_path_for(&glyphs_dir, &state.upload_dir); + + let display_name = Path::new(&safe_name) + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or(&safe_name) + .to_string(); + + let conn = state.db.lock().await; + let insert_result: Result<(), duckdb::Error> = conn + .execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CURRENT_TIMESTAMP)", + duckdb::params![ + &font_id, + &workspace_id, + &display_name, + &display_name, + &original_rel, + &glyphs_rel, + "processing", + ], + ) + .map(|_| ()); + drop(conn); + + if let Err(e) = insert_result { + let _ = fs::remove_dir_all(&fonts_dir).await; + return Err(internal_error(e)); + } + + let state_clone = state.clone(); + let font_id_clone = font_id.clone(); + let original_path_clone = original_path.clone(); + let glyphs_dir_clone = glyphs_dir.clone(); + + tokio::spawn(async move { + let original = original_path_clone.clone(); + let glyphs = glyphs_dir_clone.clone(); + let font_id_for_blocking = font_id_clone.clone(); + + let result = tokio::task::spawn_blocking(move || process_font(&original, &glyphs)).await; + + match result { + Ok(Ok((metadata, ranges))) => { + if let Err(e) = + update_font_ready(&state_clone, &font_id_clone, &metadata, ranges.len()).await + { + error!(font_id = %font_id_clone, error = %e, "Failed to update font status"); + } + } + Ok(Err(e)) => { + error!(font_id = %font_id_clone, error = %e, "Failed to process font"); + update_font_error(&state_clone, &font_id_clone, &e.to_string()).await; + } + Err(e) => { + error!(font_id = %font_id_for_blocking, error = %e, "Failed to spawn blocking task"); + update_font_error(&state_clone, &font_id_for_blocking, &e.to_string()).await; + } + } + }); + + Ok(( + StatusCode::CREATED, + Json(FontUploadResponse { + id: font_id, + status: "processing".to_string(), + }), + )) +} + +async fn update_font_error(state: &AppState, font_id: &str, error: &str) { + let conn = state.db.lock().await; + if let Err(e) = conn.execute( + "UPDATE fonts SET status = 'failed', error = ? WHERE id = ?", + duckdb::params![error, font_id], + ) { + warn!(font_id = %font_id, db_error = %e, "Failed to update font error status"); + } +} + +async fn update_font_ready( + state: &AppState, + font_id: &str, + metadata: &FontMetadata, + _range_count: usize, +) -> Result<(), String> { + let conn = state.db.lock().await; + conn.execute( + "UPDATE fonts SET status = 'ready', fontstack = ?, family = ?, style = ?, glyph_count = ?, start_cp = ?, end_cp = ?, error = NULL WHERE id = ?", + duckdb::params![ + &metadata.fontstack, + &metadata.family, + &metadata.style, + metadata.glyph_count as i32, + metadata.start_cp as i32, + metadata.end_cp as i32, + font_id, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn read_font_row(row: &duckdb::Row) -> Result { + let created_at: chrono::NaiveDateTime = row.get(13)?; + let published_at: Option = row.get(14)?; + Ok(FontItem { + id: row.get(0)?, + name: row.get(1)?, + fontstack: row.get(2)?, + family: row.get(3)?, + style: row.get(4)?, + glyph_count: row.get(5)?, + start_cp: row.get(6)?, + end_cp: row.get(7)?, + status: row.get(8)?, + error: row.get(9)?, + is_public: row.get(10)?, + slug: row.get(11)?, + workspace_slug: row.get(12)?, + created_at: created_at.and_utc().to_rfc3339(), + published_at: published_at.map(|t| t.and_utc().to_rfc3339()), + }) +} + +pub async fn list_fonts( + auth_session: AuthSession, + State(state): State, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + let conn = state.db.lock().await; + let mut stmt = conn + .prepare( + "SELECT f.id, f.name, f.fontstack, f.family, f.style, f.glyph_count, f.start_cp, f.end_cp, f.status, f.error, f.is_public, f.slug, COALESCE(w.slug, w.id), f.created_at, f.published_at + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.workspace_id = ? + ORDER BY f.created_at DESC", + ) + .map_err(internal_error)?; + + let fonts_iter = stmt + .query_map(duckdb::params![&workspace_id], read_font_row) + .map_err(internal_error)?; + + let mut fonts = Vec::new(); + for font in fonts_iter { + fonts.push(font.map_err(internal_error)?); + } + + drop(conn); + Ok(Json(fonts)) +} + +pub async fn get_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + let conn = state.db.lock().await; + let font: Option = conn + .query_row( + "SELECT f.id, f.name, f.fontstack, f.family, f.style, f.glyph_count, f.start_cp, f.end_cp, f.status, f.error, f.is_public, f.slug, COALESCE(w.slug, w.id), f.created_at, f.published_at + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.id = ? AND f.workspace_id = ?", + duckdb::params![&id, &workspace_id], + read_font_row, + ) + .optional() + .map_err(internal_error)?; + + drop(conn); + + match font { + Some(f) => Ok(Json(f)), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )), + } +} + +pub async fn delete_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + let conn = state.db.lock().await; + + let glyphs_path: Option = conn + .query_row( + "SELECT glyphs_path FROM fonts WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + + let Some(glyphs_path) = glyphs_path else { + drop(conn); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + }; + + conn.execute( + "DELETE FROM fonts WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + ) + .map_err(internal_error)?; + + drop(conn); + + let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); + let glyphs_dir = state.upload_dir.join(glyphs_path); + let font_dir = glyphs_dir + .parent() + .map_or(glyphs_dir.clone(), std::path::Path::to_path_buf); + if let Err(e) = tokio::fs::remove_dir_all(&font_dir).await { + warn!(font_dir = %font_dir.display(), error = %e, "Failed to remove font directory"); + } + + info!(font_id = %id, "Font deleted"); + Ok(StatusCode::NO_CONTENT) +} + +fn validate_slug(slug: &str) -> Result { + let slug = slug.trim().to_lowercase(); + if slug.is_empty() { + return Err("Slug cannot be empty".to_string()); + } + if slug.len() > 100 { + return Err("Slug too long (max 100 characters)".to_string()); + } + if !slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err("Slug can only contain letters, numbers, hyphens, and underscores".to_string()); + } + Ok(slug) +} + +fn parse_glyph_range(range: &str) -> Option<(u32, u32)> { + let normalized = range.strip_suffix(".pbf").unwrap_or(range); + if normalized.contains('/') || normalized.contains('\\') { + return None; + } + + let mut parts = normalized.split('-'); + let start = parts.next()?.parse::().ok()?; + let end = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + if end < start || end - start > 255 { + return None; + } + + Some((start, end)) +} + +fn parse_requested_fontstacks(fontstack: &str) -> Vec { + let mut seen = HashSet::new(); + let mut parsed = Vec::new(); + for part in fontstack.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + if seen.insert(trimmed.to_string()) { + parsed.push(trimmed.to_string()); + } + } + parsed +} + +pub async fn publish_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, + Json(req): Json, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + let slug = match req.slug { + Some(s) => validate_slug(&s).map_err(|e| bad_request(&e))?, + None => validate_slug(&id).map_err(|e| bad_request(&e))?, + }; + + info!(font_id = %id, slug = %slug, workspace_id = %workspace_id, "Publish font request"); + + let conn = state.db.lock().await; + + let workspace_meta: Option<(String, String)> = conn + .query_row( + "SELECT COALESCE(w.slug, w.id), f.fontstack + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.id = ? AND f.workspace_id = ?", + duckdb::params![&id, &workspace_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(internal_error)?; + + let Some((workspace_slug, fontstack)) = workspace_meta else { + drop(conn); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + }; + + let published_conflict: bool = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM fonts + WHERE workspace_id = ? AND id != ? AND is_public = TRUE AND fontstack = ? + )", + duckdb::params![&workspace_id, &id, &fontstack], + |row| row.get(0), + ) + .map_err(internal_error)?; + + if published_conflict { + drop(conn); + return Err(bad_request( + "Another published font with the same fontstack already exists in this workspace", + )); + } + + let result = conn.execute( + "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = CURRENT_TIMESTAMP WHERE id = ? AND workspace_id = ? AND status = 'ready'", + duckdb::params![&slug, &id, &workspace_id], + ); + + match result { + Ok(rows_affected) => { + drop(conn); + if rows_affected == 0 { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "Font is not ready for publishing".to_string(), + }), + )); + } + info!(font_id = %id, slug = %slug, "Font published"); + let url = format!("/fonts/{}/{{fontstack}}/{{range}}.pbf", workspace_slug); + Ok(Json(PublishFontResponse { + url, + slug, + is_public: true, + workspace_slug, + })) + } + Err(e) => { + let err_msg = e.to_string(); + if err_msg.contains("idx_fonts_workspace_slug") { + drop(conn); + Err(bad_request("Slug already in use")) + } else { + Err(internal_error(e)) + } + } + } +} + +pub async fn unpublish_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; + + info!(font_id = %id, "Unpublish font request"); + + let conn = state.db.lock().await; + + let rows_affected = conn + .execute( + "UPDATE fonts SET is_public = FALSE, slug = NULL, published_at = NULL WHERE id = ? AND workspace_id = ? AND is_public = TRUE", + duckdb::params![&id, &workspace_id], + ) + .map_err(internal_error)?; + + drop(conn); + + if rows_affected == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not published".to_string(), + }), + )); + } + + info!(font_id = %id, "Font unpublished"); + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_public_glyph( + State(state): State, + AxumPath((workspace_slug, fontstack, range)): AxumPath<(String, String, String)>, +) -> Result)> { + let (start, end) = parse_glyph_range(&range) + .ok_or_else(|| bad_request("Invalid glyph range format, expected -.pbf"))?; + let requested_fontstacks = parse_requested_fontstacks(&fontstack); + if requested_fontstacks.is_empty() { + return Err(bad_request("Invalid fontstack")); + } + + let conn = state.db.lock().await; + + let mut glyphs_paths = Vec::new(); + for requested_fontstack in &requested_fontstacks { + let glyphs_path: Option = conn + .query_row( + "SELECT f.glyphs_path + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE COALESCE(w.slug, w.id) = ? + AND f.fontstack = ? + AND f.is_public = TRUE + AND f.status = 'ready' + AND w.deleted_at IS NULL + LIMIT 1", + duckdb::params![&workspace_slug, requested_fontstack], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + if let Some(path) = glyphs_path { + glyphs_paths.push(path); + } + } + + drop(conn); + + if glyphs_paths.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + } + + let should_merge = requested_fontstacks.len() > 1 || glyphs_paths.len() > 1; + if !should_merge { + let glyphs_path = glyphs_paths[0].trim_start_matches("./uploads/"); + let pbf_path = state + .upload_dir + .join(glyphs_path) + .join(format!("{}-{}.pbf", start, end)); + + let canonical_path = match fs::canonicalize(&pbf_path).await { + Ok(path) => path, + Err(_) => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )) + } + }; + + if !canonical_path.starts_with(&state.upload_dir_canonical) { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Access denied".to_string(), + }), + )); + } + + return match fs::read(&canonical_path).await { + Ok(data) => Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/x-protobuf")], + data, + )), + Err(_) => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )), + }; + } + + let mut merged_glyphs = Glyphs::default(); + let mut has_any_stack = false; + + for glyphs_path in glyphs_paths { + let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); + let pbf_path = state + .upload_dir + .join(glyphs_path) + .join(format!("{}-{}.pbf", start, end)); + + let canonical_path = match fs::canonicalize(&pbf_path).await { + Ok(path) => path, + Err(_) => continue, + }; + + if !canonical_path.starts_with(&state.upload_dir_canonical) { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Access denied".to_string(), + }), + )); + } + + let bytes = match fs::read(&canonical_path).await { + Ok(data) => data, + Err(_) => continue, + }; + + let glyphs = Glyphs::decode(bytes.as_slice()).map_err(internal_error)?; + if glyphs.stacks.is_empty() { + continue; + } + + has_any_stack = true; + merged_glyphs.stacks.extend(glyphs.stacks); + } + + if !has_any_stack { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )); + } + + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/x-protobuf")], + merged_glyphs.encode_to_vec(), + )) +} + +#[cfg(test)] +mod tests { + use super::{parse_glyph_range, parse_requested_fontstacks, FontItem}; + + #[test] + fn parse_glyph_range_accepts_valid_patterns() { + assert_eq!(parse_glyph_range("0-255"), Some((0, 255))); + assert_eq!(parse_glyph_range("256-511.pbf"), Some((256, 511))); + } + + #[test] + fn parse_glyph_range_rejects_invalid_patterns() { + assert_eq!(parse_glyph_range("abc-def"), None); + assert_eq!(parse_glyph_range("10-300"), None); + assert_eq!(parse_glyph_range("300-10"), None); + assert_eq!(parse_glyph_range("../0-255"), None); + assert_eq!(parse_glyph_range("0-255/extra"), None); + } + + #[test] + fn parse_requested_fontstacks_splits_and_deduplicates() { + assert_eq!( + parse_requested_fontstacks("Noto Sans Regular, Noto Sans Regular , Arial Unicode"), + vec!["Noto Sans Regular".to_string(), "Arial Unicode".to_string()] + ); + assert_eq!(parse_requested_fontstacks(" , "), Vec::::new()); + } + + #[test] + fn font_item_serializes_as_camel_case() { + let item = FontItem { + id: "id-1".to_string(), + name: "Noto Sans".to_string(), + fontstack: "Noto Sans Regular".to_string(), + family: Some("Noto Sans".to_string()), + style: Some("Regular".to_string()), + glyph_count: Some(1024), + start_cp: Some(0), + end_cp: Some(1023), + status: "ready".to_string(), + error: None, + is_public: true, + slug: Some("noto-sans".to_string()), + workspace_slug: "team-alpha".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + published_at: Some("2024-01-01T00:01:00Z".to_string()), + }; + + let value = serde_json::to_value(item).expect("serialize FontItem"); + let obj = value.as_object().expect("json object"); + + assert!(obj.contains_key("glyphCount")); + assert!(obj.contains_key("startCp")); + assert!(obj.contains_key("endCp")); + assert!(obj.contains_key("isPublic")); + assert!(obj.contains_key("workspaceSlug")); + assert!(obj.contains_key("createdAt")); + assert!(obj.contains_key("publishedAt")); + + assert!(!obj.contains_key("glyph_count")); + assert!(!obj.contains_key("start_cp")); + assert!(!obj.contains_key("is_public")); + assert!(!obj.contains_key("created_at")); + } +} diff --git a/backend/src/font_processor.rs b/backend/src/font_processor.rs new file mode 100644 index 0000000..f56b114 --- /dev/null +++ b/backend/src/font_processor.rs @@ -0,0 +1,164 @@ +use std::path::Path; + +use pbf_font_tools::freetype::{Face, Library}; +use pbf_font_tools::prost::Message; +use pbf_font_tools::{render_sdf_glyph, Fontstack, Glyphs}; +use thiserror::Error; +use tracing::debug; + +const MAX_UNICODE_CP: usize = 0xFFFF; +const CP_RANGE_SIZE: usize = 256; +const FONT_SIZE: usize = 24; +#[allow(clippy::cast_possible_wrap)] +const CHAR_HEIGHT: isize = (FONT_SIZE as isize) << 6; +const BUFFER_SIZE: usize = 3; +const RADIUS: usize = 8; +const CUTOFF: f64 = 0.25_f64; + +#[derive(Debug, Error)] +pub enum FontError { + #[error("FreeType error: {0}")] + FreeType(#[from] pbf_font_tools::freetype::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Font has no family name")] + MissingFamilyName, + + #[error("Font has no glyphs")] + NoGlyphs, + + #[error("Failed to create directory: {0}")] + DirectoryCreation(String), +} + +#[derive(Debug, Clone)] +pub struct FontMetadata { + pub family: String, + pub style: Option, + pub fontstack: String, + pub glyph_count: usize, + pub start_cp: usize, + pub end_cp: usize, +} + +pub fn process_font( + font_path: &Path, + glyphs_dir: &Path, +) -> Result<(FontMetadata, Vec<(usize, usize)>), FontError> { + let lib = Library::init()?; + let mut face = lib.new_face(font_path, 0)?; + + let family = face.family_name().ok_or(FontError::MissingFamilyName)?; + let style = face.style_name(); + + let mut fontstack = family.clone(); + if let Some(ref s) = style { + fontstack.push(' '); + fontstack.push_str(s); + } + fontstack = fontstack + .replace(['/', ','], " ") + .split_whitespace() + .collect::>() + .join(" "); + + let (glyph_count, start_cp, end_cp) = count_glyphs(&mut face)?; + + if glyph_count == 0 { + return Err(FontError::NoGlyphs); + } + + let metadata = FontMetadata { + family, + style, + fontstack, + glyph_count, + start_cp, + end_cp, + }; + + std::fs::create_dir_all(glyphs_dir).map_err(|e| FontError::DirectoryCreation(e.to_string()))?; + + face.set_char_size(0, CHAR_HEIGHT, 0, 0)?; + + let mut ranges = Vec::new(); + + for range_start in (0..=MAX_UNICODE_CP).step_by(CP_RANGE_SIZE) { + let range_end = (range_start + CP_RANGE_SIZE - 1).min(MAX_UNICODE_CP); + + let pbf_data = generate_pbf_range(&mut face, range_start, range_end, &metadata.fontstack)?; + + if !pbf_data.is_empty() { + let filename = format!("{}-{}.pbf", range_start, range_end); + let pbf_path = glyphs_dir.join(&filename); + std::fs::write(&pbf_path, &pbf_data)?; + ranges.push((range_start, range_end)); + debug!( + range = format!("{}-{}", range_start, range_end), + path = %pbf_path.display(), + "Generated glyph range" + ); + } + } + + Ok((metadata, ranges)) +} + +fn count_glyphs(face: &mut Face) -> Result<(usize, usize, usize), FontError> { + let mut count = 0; + let mut first: Option = None; + let mut last = 0; + + for cp in 0..=MAX_UNICODE_CP { + if face.get_char_index(cp).is_some() { + count += 1; + if first.is_none() { + first = Some(cp); + } + last = cp; + } + } + + let start = first.unwrap_or(0); + Ok((count, start, last)) +} + +fn generate_pbf_range( + face: &mut Face, + start_cp: usize, + end_cp: usize, + fontstack_name: &str, +) -> Result, FontError> { + let mut stack = Fontstack { + name: fontstack_name.to_string(), + range: format!("{}-{}", start_cp, end_cp), + ..Default::default() + }; + + for cp in start_cp..=end_cp { + if face.get_char_index(cp).is_some() { + match render_sdf_glyph(face, cp as u32, BUFFER_SIZE, RADIUS, CUTOFF) { + Ok(glyph) => { + stack.glyphs.push(glyph); + } + Err(e) => { + debug!( + codepoint = cp, + error = %e, + "Failed to render glyph, skipping" + ); + } + } + } + } + + if stack.glyphs.is_empty() { + return Ok(Vec::new()); + } + + let mut glyphs = Glyphs::default(); + glyphs.stacks.push(stack); + Ok(glyphs.encode_to_vec()) +} diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 4d0cd67..1cfa0e3 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -138,10 +138,14 @@ async fn get_workspace_id( let workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = "Test Workspace".to_string(); + let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( + &workspace_name, + &workspace_id, + ); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&workspace_id, &workspace_name, &user_id], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id], ).ok(); conn.execute( @@ -1054,7 +1058,7 @@ pub async fn publish_file( StatusCode::CONFLICT, Json(ErrorResponse { error: error_msg }), )); - } else if err_msg.contains("UNIQUE") + } else if err_msg.contains("published_files") || (err_msg.contains("slug") && err_msg.contains("unique")) { Err("Slug already in use".to_string()) diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 9ad42db..60d06d4 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -8,6 +8,8 @@ mod auth_routes; mod config; mod crs; mod db; +mod font_handlers; +mod font_processor; mod handlers; mod http_errors; mod import; @@ -35,8 +37,8 @@ pub use config::{ read_preview_zoom_config, }; pub use db::{ - ensure_app_secret, init_database, is_initialized, reconcile_processing_files, set_initialized, - DEFAULT_DB_PATH, PROCESSING_RECONCILIATION_ERROR, + ensure_app_secret, init_database, is_initialized, reconcile_processing_files, + reconcile_processing_fonts, set_initialized, DEFAULT_DB_PATH, PROCESSING_RECONCILIATION_ERROR, }; pub use handlers::validate_slug; pub use models::{ @@ -216,6 +218,7 @@ mod tests { CREATE TABLE workspaces ( id VARCHAR PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, + slug VARCHAR UNIQUE, owner_id VARCHAR NOT NULL REFERENCES users(id), is_personal BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMP, diff --git a/backend/src/main.rs b/backend/src/main.rs index 49e1e03..62cc3a1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -140,6 +140,11 @@ async fn main() -> Result<()> { Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing files on startup"), } + match backend::reconcile_processing_fonts(&state.db).await { + Ok(count) => tracing::info!(reconciled = count, "Reconciled processing fonts on startup"), + Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing fonts on startup"), + } + let web_dist = std::env::var("WEB_DIST").unwrap_or_else(|_| "frontend/dist".to_string()); let web_dist_path = PathBuf::from(&web_dist); let public_viewer_available = backend::detect_public_viewer_available(&web_dist_path); diff --git a/backend/src/routes.rs b/backend/src/routes.rs index 7aab1e6..d9d8514 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -12,6 +12,10 @@ use tower_http::{ use tower_sessions::SessionManagerLayer; use crate::{ + font_handlers::{ + delete_font, get_font, get_public_glyph, list_fonts, publish_font, unpublish_font, + upload_font, + }, handlers::{ check_is_initialized, get_feature_properties, get_file_schema, get_preview_meta, get_public_url, get_settings, health_check, list_files, publish_file, unpublish_file, @@ -79,7 +83,11 @@ fn build_api_router_with_auth(state: AppState, with_auth: bool) -> Router { "/tiles/{slug}", get(get_public_pmtiles).head(head_public_pmtiles), ) - .route("/tiles/{slug}/meta", get(get_public_tile_meta)); + .route("/tiles/{slug}/meta", get(get_public_tile_meta)) + .route( + "/fonts/{workspace_slug}/{fontstack}/{*range}", + get(get_public_glyph), + ); let mut api_router = Router::new() .route("/api/files", get(list_files)) @@ -137,7 +145,11 @@ fn build_api_router_with_auth(state: AppState, with_auth: bool) -> Router { .route( "/api/workspaces/{id}/members/{user_id}", delete(remove_member), - ); + ) + .route("/api/fonts", get(list_fonts).post(upload_font)) + .route("/api/fonts/{id}", get(get_font).delete(delete_font)) + .route("/api/fonts/{id}/publish", post(publish_font)) + .route("/api/fonts/{id}/unpublish", post(unpublish_font)); if with_auth { api_router = api_router.route_layer(axum_login::login_required!(crate::AuthBackend)); diff --git a/backend/src/upload.rs b/backend/src/upload.rs index a0a72d4..1067507 100644 --- a/backend/src/upload.rs +++ b/backend/src/upload.rs @@ -6,7 +6,6 @@ use axum::{ }; use axum_login::AuthSession; use chrono::Utc; -use duckdb::OptionalExt; use std::path::Path; use tokio::{ fs, @@ -20,137 +19,16 @@ use crate::{ import::import_spatial_data, mbtiles, models::{ErrorResponse, FileItem}, + workspace::get_active_workspace_id, AppState, AuthBackend, }; -use tracing::debug; pub async fn upload_file( auth_session: AuthSession, State(state): State, mut multipart: Multipart, ) -> Result)> { - debug!("upload_file: starting upload, workspace_id check"); - let workspace_id = match auth_session.user { - Some(ref user) => { - debug!( - "upload_file: user found, current_workspace_id: {:?}", - user.current_workspace_id - ); - let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { - debug!("upload_file: no current workspace set for user"); - ( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: "No active workspace available, please switch workspace".to_string(), - }), - ) - })?; - - let conn = state.db.lock().await; - let active_workspace: Option = conn - .query_row( - r" - SELECT w.id - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL - LIMIT 1 - ", - duckdb::params![&workspace_id, &user.id], - |row| row.get(0), - ) - .optional() - .map_err(internal_error)?; - drop(conn); - - if active_workspace.is_none() { - return Err(( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: - "Current workspace is archived or inaccessible, please switch workspace" - .to_string(), - }), - )); - } - - workspace_id - } - None => { - debug!("upload_file: no user in session, checking test mode"); - let test_mode = std::env::var("MAPFLOW_TEST_MODE").as_deref() == Ok("1"); - debug!("upload_file: test_mode = {}", test_mode); - if test_mode { - debug!("upload_file: test mode enabled, looking for workspace"); - let conn = state.db.lock().await; - - let workspace_id: Option = conn - .query_row( - "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", - [], - |row| row.get(0), - ) - .ok() - .flatten(); - - if let Some(wid) = workspace_id { - drop(conn); - debug!( - "upload_file: found existing workspace in test mode: {}", - wid - ); - wid - } else { - debug!("upload_file: no workspace found, creating one"); - - let existing_user_id: Option = conn - .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) - .ok() - .flatten(); - - let user_id = match existing_user_id { - Some(uid) => uid, - None => { - let new_user_id = uuid::Uuid::new_v4().to_string(); - conn.execute( - "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", - duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], - ).ok(); - new_user_id - } - }; - - let new_workspace_id = uuid::Uuid::new_v4().to_string(); - let workspace_name = "Test Workspace".to_string(); - - conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&new_workspace_id, &workspace_name, &user_id], - ).ok(); - - conn.execute( - "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - duckdb::params![&new_workspace_id, &user_id], - ).ok(); - - drop(conn); - debug!( - "upload_file: created new workspace in test mode: {}", - new_workspace_id - ); - new_workspace_id - } - } else { - debug!("upload_file: not authenticated and not in test mode"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Not authenticated".to_string(), - }), - )); - } - } - }; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let mut field = loop { let next = multipart.next_field().await.map_err(|e| { diff --git a/backend/src/workspace.rs b/backend/src/workspace.rs index 106aecb..ff34224 100644 --- a/backend/src/workspace.rs +++ b/backend/src/workspace.rs @@ -1,10 +1,19 @@ +use std::sync::Arc; + use chrono::{DateTime, Utc}; +use duckdb::OptionalExt; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::http_errors::internal_error; +use crate::models::ErrorResponse; +use crate::AuthBackend; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { pub id: String, pub name: String, + pub slug: String, pub owner_id: String, #[serde(rename = "isPersonal")] pub is_personal: bool, @@ -29,6 +38,7 @@ pub struct WorkspaceMember { pub struct WorkspaceWithMemberCount { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "ownerId")] pub owner_id: String, #[serde(rename = "isPersonal")] @@ -54,6 +64,7 @@ pub struct WorkspaceMemberWithInfo { pub struct WorkspaceResponse { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "ownerId")] pub owner_id: String, #[serde(rename = "isPersonal")] @@ -64,12 +75,15 @@ pub struct WorkspaceResponse { pub struct CurrentWorkspaceResponse { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "isPersonal")] pub is_personal: bool, } pub const WORKSPACE_NAME_MIN_LEN: usize = 3; pub const WORKSPACE_NAME_MAX_LEN: usize = 50; +pub const WORKSPACE_SLUG_MIN_LEN: usize = 3; +pub const WORKSPACE_SLUG_MAX_LEN: usize = 63; pub const LEGACY_SHARED_WORKSPACE_NAME: &str = "Migrated Workspace"; pub fn validate_workspace_name(name: &str) -> Result { @@ -110,6 +124,189 @@ pub fn make_legacy_shared_workspace_name() -> &'static str { LEGACY_SHARED_WORKSPACE_NAME } +pub fn validate_workspace_slug(slug: &str) -> Result { + let slug = slug.trim().to_ascii_lowercase(); + let len = slug.chars().count(); + + if slug.is_empty() { + return Err("工作空间 slug 不能为空".to_string()); + } + if len < WORKSPACE_SLUG_MIN_LEN { + return Err(format!( + "工作空间 slug 至少需要 {} 个字符", + WORKSPACE_SLUG_MIN_LEN + )); + } + if len > WORKSPACE_SLUG_MAX_LEN { + return Err(format!( + "工作空间 slug 不能超过 {} 个字符", + WORKSPACE_SLUG_MAX_LEN + )); + } + if slug.starts_with('-') || slug.ends_with('-') { + return Err("工作空间 slug 不能以连字符开头或结尾".to_string()); + } + if !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err("工作空间 slug 仅支持小写字母、数字和连字符".to_string()); + } + Ok(slug) +} + +pub fn slugify_workspace_name(name: &str) -> String { + let mut out = String::new(); + let mut last_is_dash = false; + + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() { + out.push(c); + last_is_dash = false; + } else if (ch.is_whitespace() || c == '-' || c == '_') && !last_is_dash { + out.push('-'); + last_is_dash = true; + } + } + + let trimmed = out.trim_matches('-').to_string(); + if trimmed.len() > WORKSPACE_SLUG_MAX_LEN { + trimmed[..WORKSPACE_SLUG_MAX_LEN] + .trim_end_matches('-') + .to_string() + } else { + trimmed + } +} + +pub fn fallback_workspace_slug_from_id(workspace_id: &str) -> String { + let suffix = &workspace_id[..workspace_id.len().min(8)]; + format!("ws-{suffix}") +} + +pub fn workspace_slug_base_from_name_or_id(name: &str, workspace_id: &str) -> String { + let from_name = slugify_workspace_name(name); + if validate_workspace_slug(&from_name).is_ok() { + return from_name; + } + + let from_id = fallback_workspace_slug_from_id(workspace_id); + if validate_workspace_slug(&from_id).is_ok() { + return from_id; + } + + "workspace-default".to_string() +} + +pub async fn ensure_test_mode_workspace(db: &Arc>) -> Option { + let conn = db.lock().await; + + let workspace_id: Option = conn + .query_row( + "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", + [], + |row| row.get(0), + ) + .ok() + .flatten(); + + if let Some(wid) = workspace_id { + drop(conn); + return Some(wid); + } + + let existing_user_id: Option = conn + .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) + .ok() + .flatten(); + + let user_id = match existing_user_id { + Some(uid) => uid, + None => { + let new_user_id = uuid::Uuid::new_v4().to_string(); + let _ = conn.execute( + "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", + duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], + ); + new_user_id + } + }; + + let new_workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_name = "Test Workspace".to_string(); + let workspace_slug = workspace_slug_base_from_name_or_id(&workspace_name, &new_workspace_id); + + let _ = conn.execute( + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&new_workspace_id, &workspace_name, &workspace_slug, &user_id], + ); + + let _ = conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + duckdb::params![&new_workspace_id, &user_id], + ); + + drop(conn); + Some(new_workspace_id) +} + +pub async fn get_active_workspace_id( + auth_session: &axum_login::AuthSession, + db: &Arc>, +) -> Result)> { + match &auth_session.user { + Some(user) => { + let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { + ( + axum::http::StatusCode::CONFLICT, + axum::Json(ErrorResponse { + error: "No active workspace available, please switch workspace".to_string(), + }), + ) + })?; + + let conn = db.lock().await; + let active_workspace: Option = conn + .query_row( + r" + SELECT w.id + FROM workspaces w + JOIN workspace_members wm ON w.id = wm.workspace_id + WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL + LIMIT 1 + ", + duckdb::params![&workspace_id, &user.id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + drop(conn); + + if active_workspace.is_none() { + return Err(( + axum::http::StatusCode::CONFLICT, + axum::Json(ErrorResponse { + error: + "Current workspace is archived or inaccessible, please switch workspace" + .to_string(), + }), + )); + } + + Ok(workspace_id) + } + None => ensure_test_mode_workspace(db).await.ok_or_else(|| { + ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(ErrorResponse { + error: "Not authenticated".to_string(), + }), + ) + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +368,47 @@ mod tests { assert_eq!(too_long_name.chars().count(), 51); assert!(validate_workspace_name(&too_long_name).is_err()); } + + #[test] + fn validate_workspace_slug_rejects_invalid_chars() { + assert_eq!(validate_workspace_slug("Hello").unwrap(), "hello"); + assert!(validate_workspace_slug("abc_def").is_err()); + assert!(validate_workspace_slug("-abc").is_err()); + assert!(validate_workspace_slug("abc-").is_err()); + } + + #[test] + fn validate_workspace_slug_accepts_lowercase_dash() { + assert_eq!( + validate_workspace_slug("team-alpha-01").unwrap(), + "team-alpha-01" + ); + } + + #[test] + fn slugify_workspace_name_normalizes_to_kebab_case() { + assert_eq!(slugify_workspace_name("Team Alpha 01"), "team-alpha-01"); + assert_eq!(slugify_workspace_name(" Team___Beta "), "team-beta"); + assert_eq!(slugify_workspace_name("中文空间"), ""); + } + + #[test] + fn fallback_workspace_slug_from_id_prefixes_ws() { + assert_eq!( + fallback_workspace_slug_from_id("abcdef123456"), + "ws-abcdef12" + ); + } + + #[test] + fn workspace_slug_base_prefers_name_then_id() { + assert_eq!( + workspace_slug_base_from_name_or_id("Team One", "abcdef123456"), + "team-one" + ); + assert_eq!( + workspace_slug_base_from_name_or_id("中文空间", "abcdef123456"), + "ws-abcdef12" + ); + } } diff --git a/backend/src/workspace_handlers.rs b/backend/src/workspace_handlers.rs index 1b0d1de..1aaeaa2 100644 --- a/backend/src/workspace_handlers.rs +++ b/backend/src/workspace_handlers.rs @@ -13,13 +13,14 @@ use tracing::info; use crate::{ models::ErrorResponse, workspace::{ - generate_deleted_workspace_name, validate_workspace_name, CurrentWorkspaceResponse, - WorkspaceMemberWithInfo, WorkspaceResponse, WorkspaceWithMemberCount, + generate_deleted_workspace_name, validate_workspace_name, validate_workspace_slug, + workspace_slug_base_from_name_or_id, CurrentWorkspaceResponse, WorkspaceMemberWithInfo, + WorkspaceResponse, WorkspaceWithMemberCount, }, AppState, AuthBackend, User, }; -type WorkspaceRow = (String, String, String, bool, Option, String); +type WorkspaceRow = (String, String, String, String, bool, Option, String); type ApiResult = Result; @@ -66,6 +67,65 @@ fn workspace_name_conflict_or_internal(err: duckdb::Error) -> Response { internal_err(err) } +fn workspace_slug_conflict_or_internal(err: duckdb::Error) -> Response { + if is_unique_constraint_error(&err) { + return bad_req("工作空间 slug 已被使用"); + } + internal_err(err) +} + +fn workspace_slug_conflict() -> Response { + bad_req("工作空间 slug 已被使用") +} + +#[allow(clippy::result_large_err)] +fn build_unique_workspace_slug( + conn: &duckdb::Connection, + requested_slug: Option<&str>, + name: &str, + workspace_id: &str, + exclude_workspace_id: Option<&str>, +) -> ApiResult { + let base = if let Some(raw) = requested_slug { + validate_workspace_slug(raw).map_err(|e| bad_req(&e))? + } else { + workspace_slug_base_from_name_or_id(name, workspace_id) + }; + + let mut candidate = base.clone(); + let mut suffix: u32 = 1; + + loop { + let in_use: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE slug = ? AND (? IS NULL OR id != ?)", + duckdb::params![&candidate, &exclude_workspace_id, &exclude_workspace_id], + |row| row.get(0), + ) + .map_err(internal_err)?; + + if in_use == 0 { + return Ok(candidate); + } + + if requested_slug.is_some() { + return Err(workspace_slug_conflict()); + } + + let suffix_text = format!("-{suffix}"); + let max_base_len = crate::workspace::WORKSPACE_SLUG_MAX_LEN + .saturating_sub(suffix_text.len()) + .max(crate::workspace::WORKSPACE_SLUG_MIN_LEN); + let truncated_base = if base.len() > max_base_len { + base[..max_base_len].trim_end_matches('-').to_string() + } else { + base.clone() + }; + candidate = format!("{truncated_base}{suffix_text}"); + suffix = suffix.saturating_add(1); + } +} + #[allow(clippy::result_large_err)] fn with_detached_workspace_members( conn: &duckdb::Connection, @@ -135,7 +195,7 @@ async fn require_user(auth_session: &AuthSession) -> ApiResult Result { - let (id, name, owner_id, is_personal, deleted_at_str, created_at_str) = row; + let (id, name, slug, owner_id, is_personal, deleted_at_str, created_at_str) = row; let deleted_at = deleted_at_str .map(|s| { chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") @@ -151,6 +211,7 @@ fn parse_workspace_row(row: WorkspaceRow) -> Result(3)?, row.get::<_, Option>(4)?, row.get::<_, String>(5)?, - row.get::<_, i64>(6)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, )) }) .map_err(internal_err)? @@ -205,7 +268,16 @@ pub async fn list_workspaces( let workspaces: Result, String> = rows .into_iter() .map( - |(id, name, owner_id, is_personal, _deleted_at_str, created_at_str, member_count)| { + |( + id, + name, + owner_id, + is_personal, + _deleted_at_str, + created_at_str, + slug, + member_count, + )| { let created_at = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") .map_err(|e| format!("Failed to parse created_at: {}", e))? @@ -214,6 +286,7 @@ pub async fn list_workspaces( Ok(WorkspaceWithMemberCount { id, name, + slug, owner_id, is_personal, member_count, @@ -231,6 +304,7 @@ pub async fn list_workspaces( #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { pub name: String, + pub slug: Option, } pub async fn create_workspace( @@ -256,14 +330,15 @@ pub async fn create_workspace( } let workspace_id = uuid::Uuid::new_v4().to_string(); + let slug = build_unique_workspace_slug(&conn, req.slug.as_deref(), &name, &workspace_id, None)?; let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute_batch("BEGIN TRANSACTION") .map_err(internal_err)?; let insert_result = conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, ?)", - duckdb::params![&workspace_id, &name, &user.id, &now], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, ?)", + duckdb::params![&workspace_id, &name, &slug, &user.id, &now], ); if let Err(e) = insert_result { @@ -287,13 +362,14 @@ pub async fn create_workspace( conn.execute_batch("COMMIT").map_err(internal_err)?; - info!(workspace_id = %workspace_id, name = %name, owner_id = %user.id, "Workspace created"); + info!(workspace_id = %workspace_id, name = %name, slug = %slug, owner_id = %user.id, "Workspace created"); Ok(( StatusCode::CREATED, Json(WorkspaceResponse { id: workspace_id, name, + slug, owner_id: user.id, is_personal: false, }), @@ -329,7 +405,7 @@ pub async fn get_workspace( let row: Option = conn .query_row( - "SELECT id, name, owner_id, is_personal, deleted_at, created_at FROM workspaces WHERE id = ?", + "SELECT id, name, COALESCE(slug, id), owner_id, is_personal, deleted_at, created_at FROM workspaces WHERE id = ?", duckdb::params![&workspace_id], |row| { Ok(( @@ -339,6 +415,7 @@ pub async fn get_workspace( row.get(3)?, row.get(4)?, row.get(5)?, + row.get(6)?, )) }, ) @@ -693,16 +770,17 @@ pub async fn switch_workspace( let user = require_user(&auth_session).await?; let conn = state.db.lock().await; - let workspace_info: Option<(String, bool)> = conn + let workspace_info: Option<(String, String, bool)> = conn .query_row( - "SELECT name, is_personal FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id), is_personal FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![&req.workspace_id], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(internal_err)?; - let (name, is_personal) = workspace_info.ok_or_else(|| not_found("Workspace not found"))?; + let (name, slug, is_personal) = + workspace_info.ok_or_else(|| not_found("Workspace not found"))?; let is_member: i64 = conn .query_row( @@ -738,13 +816,15 @@ pub async fn switch_workspace( Ok(Json(CurrentWorkspaceResponse { id: req.workspace_id, name, + slug, is_personal, })) } #[derive(Debug, Deserialize)] pub struct UpdateWorkspaceRequest { - pub name: String, + pub name: Option, + pub slug: Option, } #[allow(clippy::result_large_err)] @@ -755,19 +835,22 @@ pub async fn update_workspace( Json(req): Json, ) -> ApiResult { let user = require_user(&auth_session).await?; - let name = validate_workspace_name(&req.name).map_err(|e| bad_req(&e))?; + if req.name.is_none() && req.slug.is_none() { + return Err(bad_req("至少需要提供 name 或 slug")); + } + let conn = state.db.lock().await; - let workspace_info: Option<(String, bool, Option)> = conn + let workspace_info: Option<(String, bool, Option, String, String)> = conn .query_row( - "SELECT owner_id, is_personal, deleted_at FROM workspaces WHERE id = ?", + "SELECT owner_id, is_personal, deleted_at, name, COALESCE(slug, id) FROM workspaces WHERE id = ?", duckdb::params![&workspace_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .optional() .map_err(internal_err)?; - let (owner_id, is_personal, deleted_at) = + let (owner_id, is_personal, deleted_at, current_name, current_slug) = workspace_info.ok_or_else(|| not_found("Workspace not found"))?; if user.id != owner_id { @@ -778,34 +861,62 @@ pub async fn update_workspace( return Err(not_found("Workspace not found")); } - if is_personal { - return Err(bad_req("Cannot rename personal workspace")); - } + let target_name = match req.name { + Some(name_raw) => { + if is_personal { + return Err(bad_req("Cannot rename personal workspace")); + } + validate_workspace_name(&name_raw).map_err(|e| bad_req(&e))? + } + None => current_name.clone(), + }; - let existing: i64 = conn - .query_row( - "SELECT COUNT(*) FROM workspaces WHERE name = ? AND id != ? AND deleted_at IS NULL", - duckdb::params![&name, &workspace_id], - |row| row.get(0), - ) - .map_err(internal_err)?; + if target_name != current_name { + let existing: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE name = ? AND id != ? AND deleted_at IS NULL", + duckdb::params![&target_name, &workspace_id], + |row| row.get(0), + ) + .map_err(internal_err)?; - if existing > 0 { - return Err(bad_req("工作空间名称已被使用")); + if existing > 0 { + return Err(bad_req("工作空间名称已被使用")); + } } + let target_slug = match req.slug { + Some(slug_raw) => build_unique_workspace_slug( + &conn, + Some(&slug_raw), + &target_name, + &workspace_id, + Some(&workspace_id), + )?, + None => current_slug.clone(), + }; + with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ? WHERE id = ?", - duckdb::params![&name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ? WHERE id = ?", + duckdb::params![&target_name, &target_slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("idx_workspaces_slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, name = %name, "Workspace updated"); + info!(workspace_id = %workspace_id, name = %target_name, slug = %target_slug, "Workspace updated"); - Ok(Json(json!({ "id": workspace_id, "name": name }))) + Ok(Json( + json!({ "id": workspace_id, "name": target_name, "slug": target_slug }), + )) } #[allow(clippy::result_large_err)] @@ -867,7 +978,16 @@ pub async fn delete_workspace( })?; conn.execute( - "UPDATE workspaces SET deleted_at = ?, name = ? WHERE id = ?", + "UPDATE fonts SET is_public = FALSE, slug = NULL, published_at = NULL WHERE workspace_id = ?", + duckdb::params![&workspace_id], + ) + .map_err(|err| { + conn.execute_batch("ROLLBACK").ok(); + internal_err(err) + })?; + + conn.execute( + "UPDATE workspaces SET deleted_at = ?, name = ?, slug = NULL WHERE id = ?", duckdb::params![&now, &archived_name, &workspace_id], ) .map_err(|err| { @@ -896,6 +1016,7 @@ pub async fn delete_workspace( #[derive(Debug, Deserialize)] pub struct RestoreWorkspaceRequest { pub name: Option, + pub slug: Option, } #[allow(clippy::result_large_err)] @@ -930,6 +1051,13 @@ pub async fn restore_workspace( if let Some(new_name) = &req.name { let name = validate_workspace_name(new_name).map_err(|e| bad_req(&e))?; + let slug = build_unique_workspace_slug( + &conn, + req.slug.as_deref(), + &name, + &workspace_id, + Some(&workspace_id), + )?; let existing: i64 = conn .query_row( @@ -945,18 +1073,34 @@ pub async fn restore_workspace( with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ?, deleted_at = NULL WHERE id = ?", - duckdb::params![&name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ?, deleted_at = NULL WHERE id = ?", + duckdb::params![&name, &slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("idx_workspaces_slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, name = %name, "Workspace restored with new name"); + info!(workspace_id = %workspace_id, name = %name, slug = %slug, "Workspace restored with new name"); - Ok(Json(json!({ "id": workspace_id, "name": name }))) + Ok(Json( + json!({ "id": workspace_id, "name": name, "slug": slug }), + )) } else { let restored_name = archived_workspace_original_name(¤t_name, &workspace_id); + let restored_slug = build_unique_workspace_slug( + &conn, + req.slug.as_deref(), + &restored_name, + &workspace_id, + Some(&workspace_id), + )?; let existing: i64 = conn .query_row( @@ -972,16 +1116,25 @@ pub async fn restore_workspace( with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ?, deleted_at = NULL WHERE id = ?", - duckdb::params![&restored_name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ?, deleted_at = NULL WHERE id = ?", + duckdb::params![&restored_name, &restored_slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("idx_workspaces_slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, "Workspace restored"); + info!(workspace_id = %workspace_id, slug = %restored_slug, "Workspace restored"); - Ok(Json(json!({ "id": workspace_id, "name": restored_name }))) + Ok(Json( + json!({ "id": workspace_id, "name": restored_name, "slug": restored_slug }), + )) } } @@ -996,6 +1149,7 @@ pub async fn list_archived_workspaces( .prepare( r" SELECT w.id, w.name, w.owner_id, w.is_personal, w.deleted_at, w.created_at + , COALESCE(w.slug, w.id) as slug FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE wm.user_id = ? AND w.deleted_at IS NOT NULL @@ -1013,6 +1167,7 @@ pub async fn list_archived_workspaces( row.get::<_, bool>(3)?, row.get::<_, Option>(4)?, row.get::<_, String>(5)?, + row.get::<_, String>(6)?, )) }) .map_err(internal_err)? @@ -1023,7 +1178,7 @@ pub async fn list_archived_workspaces( let workspaces: Result, String> = rows .into_iter() .map( - |(id, name, owner_id, is_personal, deleted_at_str, created_at_str)| { + |(id, name, owner_id, is_personal, deleted_at_str, created_at_str, slug)| { let deleted_at = deleted_at_str .map(|s| { chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") @@ -1041,6 +1196,7 @@ pub async fn list_archived_workspaces( Ok(json!({ "id": id, "name": display_name, + "slug": slug, "ownerId": owner_id, "isPersonal": is_personal, "deletedAt": deleted_at, @@ -1062,18 +1218,18 @@ pub async fn get_current_workspace( let user = require_user(&auth_session).await?; let conn = state.db.lock().await; - let workspace_info: Option<(String, String, bool)> = + let workspace_info: Option<(String, String, String, bool)> = if let Some(current_workspace_id) = &user.current_workspace_id { conn.query_row( r" - SELECT w.id, w.name, w.is_personal + SELECT w.id, w.name, COALESCE(w.slug, w.id), w.is_personal FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL LIMIT 1 ", duckdb::params![current_workspace_id, &user.id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional() .map_err(internal_err)? @@ -1086,7 +1242,7 @@ pub async fn get_current_workspace( None => conn .query_row( r" - SELECT w.id, w.name, w.is_personal + SELECT w.id, w.name, COALESCE(w.slug, w.id), w.is_personal FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE wm.user_id = ? AND w.deleted_at IS NULL @@ -1094,16 +1250,17 @@ pub async fn get_current_workspace( LIMIT 1 ", duckdb::params![&user.id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional() .map_err(internal_err)?, }; match workspace_info { - Some((id, name, is_personal)) => Ok(Json(CurrentWorkspaceResponse { + Some((id, name, slug, is_personal)) => Ok(Json(CurrentWorkspaceResponse { id, name, + slug, is_personal, })), None => Err(not_found("No workspace found")), diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 784a5a6..0cd6a2a 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -6812,6 +6812,103 @@ async fn test_update_workspace_maps_unique_conflict_to_400() { assert_eq!(payload["error"], "工作空间名称已被使用"); } +#[tokio::test] +async fn test_update_workspace_slug_persists_and_visible_in_read_models() { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir.path().join("workspace-update-slug-persist.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + let app = build_api_router(state); + let cookie = + create_user_and_session(&app, db.clone(), "user-update-slug-1", "slugger", "admin").await; + + let create_request = Request::builder() + .method("POST") + .uri("/api/workspaces") + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"Slug Workspace"}"#)) + .unwrap(); + let create_response = app.clone().oneshot(create_request).await.unwrap(); + assert_eq!(create_response.status(), axum::http::StatusCode::CREATED); + let create_body = create_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let created_workspace: serde_json::Value = serde_json::from_slice(&create_body).unwrap(); + let workspace_id = created_workspace["id"].as_str().unwrap().to_string(); + + let update_request = Request::builder() + .method("PUT") + .uri(format!("/api/workspaces/{}", workspace_id)) + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"slug-workspace"}"#)) + .unwrap(); + let update_response = app.clone().oneshot(update_request).await.unwrap(); + assert_eq!(update_response.status(), axum::http::StatusCode::OK); + let update_body = update_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let updated_payload: serde_json::Value = serde_json::from_slice(&update_body).unwrap(); + assert_eq!(updated_payload["slug"], "slug-workspace"); + + let get_request = Request::builder() + .method("GET") + .uri(format!("/api/workspaces/{}", workspace_id)) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + let get_response = app.clone().oneshot(get_request).await.unwrap(); + assert_eq!(get_response.status(), axum::http::StatusCode::OK); + let get_body = get_response.into_body().collect().await.unwrap().to_bytes(); + let got_workspace: serde_json::Value = serde_json::from_slice(&get_body).unwrap(); + assert_eq!(got_workspace["slug"], "slug-workspace"); + + let list_request = Request::builder() + .method("GET") + .uri("/api/workspaces") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + let list_response = app.oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), axum::http::StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let list_payload: serde_json::Value = serde_json::from_slice(&list_body).unwrap(); + let listed_workspace = list_payload + .as_array() + .and_then(|items| items.iter().find(|item| item["id"] == workspace_id)) + .expect("workspace appears in list"); + assert_eq!(listed_workspace["slug"], "slug-workspace"); +} + #[tokio::test] async fn test_update_workspace_rejects_archived_workspace() { ensure_test_mode(); @@ -7919,6 +8016,162 @@ async fn test_delete_workspace_unpublishes_public_tiles() { assert!(!is_public); } +#[tokio::test] +async fn test_public_font_glyphs_blocked_when_workspace_archived() { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir + .path() + .join("public-font-glyph-archived-workspace.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + let app = build_test_router(state); + + let active_workspace_id = "ws-font-active"; + let archived_workspace_id = "ws-font-archived"; + let active_workspace_slug = "font-active-workspace"; + let archived_workspace_slug = "font-archived-workspace"; + + { + let conn = db.lock().await; + + conn.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", + duckdb::params![ + "user-font-public", + "fontpublic", + "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36IgQE0VrqQ6EJdNpO5mLY", + "admin" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, CURRENT_TIMESTAMP)", + duckdb::params![ + active_workspace_id, + "Font Active", + active_workspace_slug, + "user-font-public" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, deleted_at, created_at) VALUES (?, ?, ?, ?, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + duckdb::params![ + archived_workspace_id, + "Font Archived", + archived_workspace_slug, + "user-font-public" + ], + ) + .unwrap(); + } + + let active_font_id = "font-public-active"; + let archived_font_id = "font-public-archived"; + let active_slug = "public-active-font"; + let archived_slug = "public-archived-font"; + + for (font_id, workspace_id) in [ + (active_font_id, active_workspace_id), + (archived_font_id, archived_workspace_id), + ] { + let glyph_file_path = temp_dir + .path() + .join("uploads") + .join("fonts") + .join(font_id) + .join("glyphs") + .join("0-255.pbf"); + std::fs::create_dir_all(glyph_file_path.parent().expect("glyphs dir")).unwrap(); + std::fs::write(&glyph_file_path, b"glyph-data").unwrap(); + + let original_rel = format!("./uploads/fonts/{}/original", font_id); + let glyphs_rel = format!("./uploads/fonts/{}/glyphs", font_id); + let slug = if font_id == active_font_id { + active_slug + } else { + archived_slug + }; + + let conn = db.lock().await; + conn.execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, is_public, slug, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'ready', TRUE, ?, CURRENT_TIMESTAMP)", + duckdb::params![ + font_id, + workspace_id, + "Public Font", + "Public Font", + &original_rel, + &glyphs_rel, + slug, + ], + ) + .unwrap(); + } + + let active_glyph_request = Request::builder() + .method("GET") + .uri(format!( + "/fonts/{}/Public%20Font/0-255.pbf", + active_workspace_slug + )) + .body(Body::empty()) + .unwrap(); + let active_glyph_response = app.clone().oneshot(active_glyph_request).await.unwrap(); + assert_eq!(active_glyph_response.status(), axum::http::StatusCode::OK); + + let archived_glyph_request = Request::builder() + .method("GET") + .uri(format!( + "/fonts/{}/Public%20Font/0-255.pbf", + archived_workspace_slug + )) + .body(Body::empty()) + .unwrap(); + let archived_glyph_response = app.clone().oneshot(archived_glyph_request).await.unwrap(); + assert_eq!( + archived_glyph_response.status(), + axum::http::StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn test_fonts_api_supports_test_mode_without_auth() { + let (app, _temp) = setup_app().await; + + let request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _items: Vec = serde_json::from_slice(&body).unwrap(); +} + #[tokio::test] async fn test_delete_workspace_rolls_back_public_state_when_archive_update_fails() { ensure_test_mode(); diff --git a/backend/tests/fixtures/fonts/OFL.txt b/backend/tests/fixtures/fonts/OFL.txt new file mode 100644 index 0000000..22796df --- /dev/null +++ b/backend/tests/fixtures/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/backend/tests/fixtures/fonts/PressStart2P-Regular.ttf b/backend/tests/fixtures/fonts/PressStart2P-Regular.ttf new file mode 100644 index 0000000..39adf42 Binary files /dev/null and b/backend/tests/fixtures/fonts/PressStart2P-Regular.ttf differ diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs new file mode 100644 index 0000000..589c1b0 --- /dev/null +++ b/backend/tests/font_api_tests.rs @@ -0,0 +1,474 @@ +#![allow(clippy::unwrap_used)] + +use axum::body::Body; +use axum::http::Request; +use backend::{build_test_router, init_database, AppState, AuthBackend, DuckDBStore}; +use http_body_util::BodyExt; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::sync::RwLock; +use tower::ServiceExt; + +static TEST_MODE_SET: std::sync::Once = std::sync::Once::new(); + +fn ensure_test_mode() { + TEST_MODE_SET.call_once(|| { + std::env::set_var("MAPFLOW_TEST_MODE", "1"); + }); +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .to_path_buf() +} + +fn read_fixture_bytes(rel_path_from_repo_root: &str) -> Vec { + let p = repo_root().join(rel_path_from_repo_root); + std::fs::read(&p).unwrap_or_else(|e| panic!("Failed to read fixture {p:?}: {e}")) +} + +fn multipart_body(boundary: &str, filename: &str, bytes: &[u8]) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(bytes); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body +} + +async fn setup_app() -> ( + axum::Router, + TempDir, + Arc>, +) { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir.path().join("font-api-tests.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + + let router = build_test_router(state); + (router, temp_dir, db) +} + +async fn upload_fixture_font(app: &axum::Router) -> String { + let font_bytes = read_fixture_bytes("backend/tests/fixtures/fonts/PressStart2P-Regular.ttf"); + let boundary = "------------------------boundaryFONT"; + let body = multipart_body(boundary, "PressStart2P-Regular.ttf", &font_bytes); + + let upload_request = Request::builder() + .method("POST") + .uri("/api/fonts") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(); + + let upload_response = app.clone().oneshot(upload_request).await.unwrap(); + let upload_status = upload_response.status(); + if upload_status != axum::http::StatusCode::CREATED { + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + panic!( + "expected upload 201 but got {} with body {}", + upload_status, + String::from_utf8_lossy(&upload_body) + ); + } + + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let uploaded: Value = serde_json::from_slice(&upload_body).unwrap(); + uploaded + .get("id") + .and_then(Value::as_str) + .expect("font id") + .to_string() +} + +async fn wait_until_font_ready(app: &axum::Router, font_id: &str) -> Value { + let mut last_status: Option = None; + let mut last_error: Option = None; + + for _ in 0..120 { + let request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let fonts: Vec = serde_json::from_slice(&body_bytes).unwrap(); + + if let Some(item) = fonts + .into_iter() + .find(|f| f.get("id").and_then(Value::as_str) == Some(font_id)) + { + let status = item + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + last_status = Some(status.clone()); + last_error = item + .get("error") + .and_then(Value::as_str) + .map(ToString::to_string); + if status == "ready" { + return item; + } + if status == "failed" { + panic!("Font processing failed: {:?}", last_error); + } + } + + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + + panic!( + "Timeout waiting for font ready (last_status={:?}, last_error={:?})", + last_status, last_error + ); +} + +#[tokio::test] +async fn test_font_upload_publish_and_public_glyph_lifecycle() { + let (app, _temp_dir, _db) = setup_app().await; + + let font_id = upload_fixture_font(&app).await; + + let ready_font = wait_until_font_ready(&app, &font_id).await; + let glyph_count = ready_font + .get("glyphCount") + .and_then(Value::as_i64) + .unwrap_or(0); + assert!(glyph_count > 0); + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"pressstart2p-test"}"#)) + .unwrap(); + let publish_response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(publish_response.status(), axum::http::StatusCode::OK); + let publish_body = publish_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let publish_payload: Value = serde_json::from_slice(&publish_body).unwrap(); + assert_eq!(publish_payload["workspaceSlug"], "test-workspace"); + assert_eq!( + publish_payload["url"], + "/fonts/test-workspace/{fontstack}/{range}.pbf" + ); + + let public_glyph_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); + assert_eq!(public_glyph_response.status(), axum::http::StatusCode::OK); + assert_eq!( + public_glyph_response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/x-protobuf") + ); + + let fallback_fontstack_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular,Missing%20Fallback/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let fallback_fontstack_response = app + .clone() + .oneshot(fallback_fontstack_request) + .await + .unwrap(); + assert_eq!( + fallback_fontstack_response.status(), + axum::http::StatusCode::OK + ); + assert_eq!( + fallback_fontstack_response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/x-protobuf") + ); + + let invalid_range_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-999.pbf") + .body(Body::empty()) + .unwrap(); + let invalid_range_response = app.clone().oneshot(invalid_range_request).await.unwrap(); + assert_eq!( + invalid_range_response.status(), + axum::http::StatusCode::BAD_REQUEST + ); + + let unpublish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/unpublish", font_id)) + .body(Body::empty()) + .unwrap(); + let unpublish_response = app.clone().oneshot(unpublish_request).await.unwrap(); + assert_eq!( + unpublish_response.status(), + axum::http::StatusCode::NO_CONTENT + ); + + let public_after_unpublish_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_after_unpublish_response = app + .clone() + .oneshot(public_after_unpublish_request) + .await + .unwrap(); + assert_eq!( + public_after_unpublish_response.status(), + axum::http::StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn test_publish_font_rejects_invalid_slug() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"bad slug!*"}"#)) + .unwrap(); + let response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_publish_font_rejects_slug_conflict() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id_1 = upload_fixture_font(&app).await; + let font_id_2 = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id_1).await; + let _ = wait_until_font_ready(&app, &font_id_2).await; + + let publish_1 = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id_1)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"duplicate-font-slug"}"#)) + .unwrap(); + let response_1 = app.clone().oneshot(publish_1).await.unwrap(); + assert_eq!(response_1.status(), axum::http::StatusCode::OK); + + let publish_2 = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id_2)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"duplicate-font-slug"}"#)) + .unwrap(); + let response_2 = app.clone().oneshot(publish_2).await.unwrap(); + assert_eq!(response_2.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_publish_font_rejects_non_ready_status() { + let (app, _temp_dir, db) = setup_app().await; + let font_id = "font-not-ready-1"; + + let seed_request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let seed_response = app.clone().oneshot(seed_request).await.unwrap(); + assert_eq!(seed_response.status(), axum::http::StatusCode::OK); + + let workspace_id = { + let conn = db.lock().await; + conn.query_row( + "SELECT id FROM workspaces WHERE is_personal = TRUE AND deleted_at IS NULL LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .unwrap() + }; + + { + let conn = db.lock().await; + conn.execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'processing', CURRENT_TIMESTAMP)", + duckdb::params![ + font_id, + &workspace_id, + "Not Ready Font", + "Not Ready Font", + "./uploads/fonts/font-not-ready-1/original", + "./uploads/fonts/font-not-ready-1/glyphs", + ], + ) + .unwrap(); + } + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"not-ready-font"}"#)) + .unwrap(); + let response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_delete_font_removes_public_access() { + let (app, temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"deletable-font"}"#)) + .unwrap(); + let publish_response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(publish_response.status(), axum::http::StatusCode::OK); + + let delete_request = Request::builder() + .method("DELETE") + .uri(format!("/api/fonts/{}", font_id)) + .body(Body::empty()) + .unwrap(); + let delete_response = app.clone().oneshot(delete_request).await.unwrap(); + assert_eq!(delete_response.status(), axum::http::StatusCode::NO_CONTENT); + + let public_glyph_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); + assert_eq!( + public_glyph_response.status(), + axum::http::StatusCode::NOT_FOUND + ); + + let font_dir = temp_dir.path().join("uploads").join("fonts").join(font_id); + assert!(!font_dir.exists()); +} + +#[tokio::test] +async fn test_list_and_get_font_use_camel_case_contract() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let list_request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let list_response = app.clone().oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), axum::http::StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let items: Vec = serde_json::from_slice(&list_body).unwrap(); + let item = items + .into_iter() + .find(|it| it.get("id").and_then(Value::as_str) == Some(font_id.as_str())) + .expect("uploaded font in list"); + + assert!(item.get("glyphCount").is_some()); + assert!(item.get("startCp").is_some()); + assert!(item.get("endCp").is_some()); + assert!(item.get("isPublic").is_some()); + assert!(item.get("workspaceSlug").is_some()); + assert!(item.get("createdAt").is_some()); + assert!(item.get("glyph_count").is_none()); + assert!(item.get("is_public").is_none()); + + let get_request = Request::builder() + .method("GET") + .uri(format!("/api/fonts/{}", font_id)) + .body(Body::empty()) + .unwrap(); + let get_response = app.clone().oneshot(get_request).await.unwrap(); + assert_eq!(get_response.status(), axum::http::StatusCode::OK); + let get_body = get_response.into_body().collect().await.unwrap().to_bytes(); + let got: Value = serde_json::from_slice(&get_body).unwrap(); + assert!(got.get("glyphCount").is_some()); + assert!(got.get("isPublic").is_some()); + assert!(got.get("workspaceSlug").is_some()); + assert!(got.get("glyph_count").is_none()); + assert!(got.get("is_public").is_none()); +} + +#[tokio::test] +async fn test_upload_font_rejects_unsupported_extension() { + let (app, _temp_dir, _db) = setup_app().await; + let body = multipart_body("----boundaryTXT", "not-a-font.txt", b"hello"); + + let request = Request::builder() + .method("POST") + .uri("/api/fonts") + .header( + "content-type", + "multipart/form-data; boundary=----boundaryTXT", + ) + .body(Body::from(body)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} diff --git a/docs/dev/behaviors.md b/docs/dev/behaviors.md index 8321772..ba6e804 100644 --- a/docs/dev/behaviors.md +++ b/docs/dev/behaviors.md @@ -12,6 +12,7 @@ - 基于角色的权限控制(admin/user) - **工作空间隔离**:用户只能访问所属工作空间的数据 - **公开瓦片服务**:发布后的文件可通过公共 URL 访问,无需认证 +- **公开字体服务**:发布后的字体字形可通过公共 URL 访问,无需认证(按 workspace slug + fontstack + range 路由) **核心概念:** - **工作空间**:数据隔离单元,每个文件属于一个工作空间 @@ -38,6 +39,12 @@ ## 行为契约表 +### 与本次改动相关的 User Stories(可观测) + +1. 作为工作空间 owner,我可以在设置页管理 workspace slug,并立即在 API 读模型中看到新 slug。 +2. 作为字体发布者,我发布字体后拿到可直接写入 Mapbox Style 的 URL 模板:`/fonts/{workspaceSlug}/{fontstack}/{range}.pbf`。 +3. 作为公开访问者,我只能访问“未归档 workspace + 已发布字体”的 glyph 资源;workspace 归档后公开地址立刻失效。 + | ID | 模块 | 可观测行为 | 验证标准 | 验证命令 | 层级 | 优先级 | |----|------|-----------|---------|---------|------|--------| | CLI-001 | 服务启动 | 支持 `--listen [host]:port` 和 `LISTEN` 环境变量。`:port` 表示监听所有接口。CLI 优先于环境变量。默认 `:3000` | 绑定到指定地址 | `cargo test test_listen_*` | Unit | P1 | @@ -66,6 +73,9 @@ | API-017 | 系统设置 | GET/PATCH /api/settings 需要认证。GET 返回 `{maxSizeMb}`。PATCH 仅 admin 可用,更新上传大小限制(最小 1MB),持久化到 system_settings 表,重启后保留 | 200 + `{maxSizeMb}` / 400(值无效) / 401 / 403(非admin) | `cargo test test_get_settings_* test_update_settings_*` | Integration | P1 | | API-018 | PostGIS 连接测试 | POST /api/postgis/connections/test 需要认证。请求体 `{connection:{host,port,database,username,password,sslMode}}`。MVP 仅允许 `sslMode=disable`,成功时返回 PostgreSQL 与 PostGIS 版本 | 200 + `{success,serverVersion,postgisVersion}` / 400(连接失败或参数非法) / 401 | `bash scripts/test/postgis-integration.sh` | Integration | P1 | | API-019 | PostGIS 源注册 | POST /api/postgis/sources/register 需要认证。请求体包含 `connectionName/schema/object/geometryColumn/fidColumn` 等,校验 relation 与列合法后创建 `files` 记录(`type=postgis`,`tileSource=postgis`,`status=ready`)并写入 `postgis_connections/postgis_sources/dataset_columns`。当 relation 当前无非空几何时,只要 geometry 列元数据可推断出正 SRID(typmod/geometry_columns),也允许注册 | 201 + `{fileId,status}` / 400(对象或列校验失败) / 401 / 500(内部存储或加解密失败) | `bash scripts/test/postgis-integration.sh` | Integration | P0 | +| API-020 | 字体上传与查询 | `POST /api/fonts` 上传 `.ttf/.otf`,异步处理后 `GET /api/fonts` / `GET /api/fonts/:id` 返回字体元数据(含 `workspaceSlug`) | 200 + 字体元数据 / 400(格式错误) / 401 | `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_list_and_get_font_use_camel_case_contract` | Integration | P1 | +| API-021 | 字体发布与取消发布 | `POST /api/fonts/:id/publish` 返回 `{url,slug,isPublic,workspaceSlug}`(其中 `url` 模板不含 `fontSlug`);`POST /api/fonts/:id/unpublish` 取消发布后公开地址失效 | 200 / 204 / 400(冲突) / 404 / 409(非 ready) | `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_font_upload_publish_and_public_glyph_lifecycle` | Integration | P1 | +| API-022 | 公开字体字形 | `GET /fonts/:workspaceSlug/:fontstack/:range` **无需认证**;仅当 workspace 未归档且存在已发布 fontstack 时返回 pbf 数据 | 200 + `application/x-protobuf` / 400(range 无效) / 404 | `cargo test test_public_font_glyphs_blocked_when_workspace_archived` + `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_font_upload_publish_and_public_glyph_lifecycle` | Integration | P1 | | AUTH-001 | 首次设置 | POST /api/auth/init 创建初始管理员 | 200 / 400 / 409 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | | AUTH-002 | 登录 | POST /api/auth/login 验证凭证,设置会话 | 200 / 401 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | | AUTH-003 | 登出 | POST /api/auth/logout 清除会话 | 204 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | @@ -115,15 +125,16 @@ | OSM-004 | 瓦片生成(simple polygons) | OSM sf_simple_polygons(10,000 简单多边形,Polygon几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_simple_polygons_samples` | Integration | P1 | | OSM-005 | 瓦片生成(multipoints) | OSM sf_multipoints(402 多点要素,MultiPoint几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_multipoints_samples` | Integration | P1 | | OSM-006 | 瓦片生成(multilinestrings) | OSM sf_multilinestrings(511 多线要素,MultiLineString几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_multilinestrings_samples` | Integration | P1 | -| WS-001 | 工作空间创建 | POST /api/workspaces 创建工作空间,名称全局唯一(3-50字符),创建者自动成为 owner 和成员 | 201 + `{id,name,ownerId,isPersonal}` / 400(名称无效) / 409(名称冲突) / 401 | `cargo test test_create_workspace*` | Integration | P0 | -| WS-002 | 工作空间列表 | GET /api/workspaces 返回用户所属的所有工作空间(不含已删除) | 200 + `[{id,name,ownerId,isPersonal,memberCount,createdAt}]` / 401 | `cargo test test_list_members*` | Integration | P0 | +| WS-001 | 工作空间创建 | POST /api/workspaces 创建工作空间,名称全局唯一(3-50字符),可选 `slug`;创建者自动成为 owner 和成员 | 201 + `{id,name,slug,ownerId,isPersonal}` / 400(名称/slug 无效) / 409(名称冲突) / 401 | `cargo test test_create_workspace*` | Integration | P0 | +| WS-002 | 工作空间列表 | GET /api/workspaces 返回用户所属的所有工作空间(不含已删除) | 200 + `[{id,name,slug,ownerId,isPersonal,memberCount,createdAt}]` / 401 | `cargo test test_list_members*` | Integration | P0 | | WS-003 | 工作空间删除 | DELETE /api/workspaces/:id 软删除工作空间(设置 deleted_at,重命名释放原名),仅 owner 可操作,个人工作空间不可删除 | 204 / 401 / 403(非owner/个人空间) / 404 | `cargo test test_delete_workspace*` | Integration | P0 | -| WS-004 | 工作空间恢复 | POST /api/workspaces/:id/restore 恢复已删除工作空间,若原名被占用需提供新名称 | 200 + `{id,name}` / 400(未删除/名称冲突) / 401 / 403 / 404 | `cargo test test_restore_workspace*` | Integration | P1 | -| WS-005 | 工作空间切换 | PUT /api/session/workspace 切换当前工作空间,更新 session 中的 currentWorkspaceId | 200 + `{workspaceId}` / 401 / 403(不属于该工作空间) / 404 | `cargo test test_switch_workspace*` | Integration | P0 | +| WS-004 | 工作空间恢复 | POST /api/workspaces/:id/restore 恢复已删除工作空间,若原名被占用需提供新名称;恢复时重建可用 slug | 200 + `{id,name,slug}` / 400(未删除/名称或 slug 冲突) / 401 / 403 / 404 | `cargo test test_restore_workspace*` | Integration | P1 | +| WS-005 | 工作空间切换 | PUT /api/workspaces/current 切换当前工作空间,更新 session 中的 currentWorkspaceId | 200 + `{id,name,slug,isPersonal}` / 401 / 403(不属于该工作空间) / 404 | `cargo test test_switch_workspace*` | Integration | P0 | | WS-006 | 成员邀请 | POST /api/workspaces/:id/members 通过用户名邀请成员,任意成员可邀请 | 201 + `{userId,username}` / 400(缺少用户名) / 404(用户不存在) / 409(已是成员) / 401 / 403 | `cargo test test_invite_member*` | Integration | P0 | | WS-007 | 成员移除 | DELETE /api/workspaces/:id/members/:userId 移除成员。Owner 可移除任何人(除自己),成员可移除自己(离开) | 204 / 401 / 403(移除owner) / 404 | `cargo test test_remove_member*` | Integration | P0 | | WS-008 | 数据隔离 | 文件 API(上传/列表/预览/瓦片/发布)自动过滤 currentWorkspaceId,跨工作空间访问返回 404 | 404 / 401 | `cargo test test_*workspace*` | Integration | P0 | | WS-009 | 个人工作空间 | POST /api/auth/init 注册成功后自动创建个人工作空间(名称:`{username}的个人空间`),用户成为 owner 和成员 | 201 + 用户信息,含工作空间 | `cargo test test_init_system_sets_current_workspace_to_personal_workspace` | Integration | P0 | +| WS-010 | 工作空间 slug 管理 | PUT /api/workspaces/:id 支持仅更新 `slug`;owner 可修改,返回最新 `slug`,并在 GET/list/current/auth-check 可观测到 | 200 + `{id,name,slug}` / 400(slug 非法或冲突) / 403 / 404 | `cargo test test_update_workspace_slug_persists_and_visible_in_read_models` | Integration | P1 | ## 参考 diff --git a/docs/internal.md b/docs/internal.md index 94362d9..10d780c 100644 --- a/docs/internal.md +++ b/docs/internal.md @@ -18,8 +18,13 @@ Windows 桌面集成: - 用户可属于多个工作空间(多对多:workspace_members) - Session 存储 currentWorkspaceId,所有文件操作基于此过滤 - 个人工作空间:注册时自动创建,不可删除,名称格式 `{username}的个人空间` +- 工作空间对外标识使用 `workspaces.slug`(可由 owner 调整);管理 API 仍使用 workspace id - 公开瓦片 URL 通过 slug 访问,不依赖工作空间过滤 +**字体资源公开地址:** +- 字体管理 API 使用 `/api/fonts/:id`(id 语义稳定) +- 公开 glyph URL 使用 `/fonts/:workspaceSlug/:fontstack/:range`(对 style JSON 更稳定,不暴露单字体 slug) + **MBTiles 支持:** - MBTiles 文件不导入 DuckDB,直接读取原始 SQLite 文件 - 通过 `tile_format` 字段区分动态(NULL)、MVT、PNG diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a18887e..5314685 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,11 +21,82 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@playwright/test": "^1.50.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "vite": "^8.0.0", "vitest": "^4.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -198,6 +269,159 @@ "node": ">=14.21.3" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -232,6 +456,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -583,6 +825,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -594,6 +899,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -772,6 +1085,42 @@ "unzipit": "1.4.3" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -782,6 +1131,16 @@ "node": ">=12" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -812,6 +1171,52 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -822,10 +1227,31 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/earcut": { "version": "3.0.2", "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -906,6 +1332,19 @@ "node": ">=10.19" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -955,6 +1394,62 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lerc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", @@ -1222,6 +1717,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1232,6 +1748,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1313,6 +1836,19 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1399,10 +1935,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", @@ -1474,6 +2036,14 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", @@ -1518,6 +2088,16 @@ "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "license": "MIT", @@ -1566,6 +2146,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1605,6 +2198,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -1647,6 +2247,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1655,6 +2301,16 @@ "license": "0BSD", "optional": true }, + "node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unzipit": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", @@ -1864,12 +2520,60 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "dev": true, @@ -1885,12 +2589,29 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", "license": "CC0-1.0" }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zarrita": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9045a11..c3d0c58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,10 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@playwright/test": "^1.50.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "vite": "^8.0.0", "vitest": "^4.1.0" } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 231becd..1789ce9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import { } from './api.js'; import { formatSize, parseType, validateSlug } from './utils.js'; import LanguageSwitcher from './LanguageSwitcher.jsx'; +import ResourcesPanel from './ResourcesPanel.jsx'; const INITIAL_POSTGIS_FORM = { connectionName: '', @@ -1386,6 +1387,7 @@ export default function App() { ); const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false); const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [mainTab, setMainTab] = useState('data'); useEffect(() => { loadFilesFailedMessageRef.current = t('app.loadFilesFailed'); @@ -1821,90 +1823,116 @@ export default function App() { - {errorMessage ?
{errorMessage}
: null} -
-
-

{t('app.dataSource')}

- {t('app.supportedFormatsHint')} +
+ +
-
-
- {isLoading ? ( -
{t('app.loading')}
- ) : orderedFiles.length === 0 ? ( -
- {t('app.noFiles')} -
- ) : ( -
-
-
{t('fileList.name')}
-
{t('fileList.type')}
-
{t('fileList.size')}
-
{t('fileList.uploadTime')}
-
{t('fileList.status')}
-
+ {errorMessage ?
{errorMessage}
: null} + + {mainTab === 'data' ? ( +
+
+ {isLoading ? ( +
{t('app.loading')}
+ ) : orderedFiles.length === 0 ? ( +
+ {t('app.noFiles')}
- {orderedFiles.map((item) => ( - - ))} -
- )} -
+ {item.name} + + {item.type} + {item.tileSource === 'postgis' ? ' · PostGIS' : ''} + + {formatSize(item.size || 0)} + + {item.uploadedAt + ? dateTimeFormatter.format(new Date(item.uploadedAt)) + : '--'} + + + {getStatusLabel(t, item.status)} + + + {item.status === 'ready' && ( + { + e.stopPropagation(); + }} + > + {t('fileList.view')} + + )} + + + ))} +
+ )} +
-
- +
+ +
-
+ ) : ( + + )}
{showPostgisModal && ( diff --git a/frontend/src/FontsPanel.jsx b/frontend/src/FontsPanel.jsx new file mode 100644 index 0000000..d29d65e --- /dev/null +++ b/frontend/src/FontsPanel.jsx @@ -0,0 +1,382 @@ +import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { listFonts, uploadFont, deleteFont, publishFont, unpublishFont } from './api.js'; + +const FONT_STATUS_LABELS = { + processing: 'font.status.processing', + ready: 'font.status.ready', + failed: 'font.status.failed', +}; + +function getFontStatusLabel(t, status) { + const key = FONT_STATUS_LABELS[status]; + return key ? t(key) : status; +} + +export default function FontsPanel() { + const { t, i18n } = useTranslation(); + const [fonts, setFonts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [publishError, setPublishError] = useState(''); + const [isPublishing, setIsPublishing] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + const fileInputRef = useRef(null); + + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.resolvedLanguage || i18n.language || undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [i18n.language, i18n.resolvedLanguage], + ); + + const selectedFont = fonts.find((f) => f.id === selectedId); + + const refreshFonts = useCallback(async () => { + try { + const data = await listFonts(); + setFonts(Array.isArray(data) ? data : []); + setErrorMessage(''); + } catch (err) { + setErrorMessage(err.message || t('font.loadFailed')); + } finally { + setIsLoading(false); + } + }, [t]); + + useEffect(() => { + refreshFonts(); + }, [refreshFonts]); + + const hasProcessingFonts = useMemo( + () => fonts.some((font) => font.status === 'processing'), + [fonts], + ); + + useEffect(() => { + if (!hasProcessingFonts) return; + + const interval = setInterval(() => { + refreshFonts(); + }, 2000); + + return () => clearInterval(interval); + }, [hasProcessingFonts, refreshFonts]); + + async function handleFileChange(e) { + const file = e.target.files[0]; + if (!file) return; + + const ext = file.name.split('.').pop().toLowerCase(); + if (!['ttf', 'otf'].includes(ext)) { + setErrorMessage(t('font.unsupportedFormat')); + e.target.value = ''; + return; + } + + setIsUploading(true); + setErrorMessage(''); + try { + await uploadFont(file); + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.uploadFailed')); + } finally { + setIsUploading(false); + e.target.value = ''; + } + } + + async function handleDelete(fontId) { + if (!confirm(t('font.deleteConfirm'))) return; + try { + await deleteFont(fontId); + if (selectedId === fontId) { + setSelectedId(null); + } + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.deleteFailed')); + } + } + + async function handlePublish(fontId) { + setPublishError(''); + setIsPublishing(true); + try { + await publishFont(fontId); + await refreshFonts(); + } catch (err) { + setPublishError(err.message || t('font.publishFailed')); + } finally { + setIsPublishing(false); + } + } + + async function handleUnpublish(fontId) { + if (!confirm(t('font.unpublishConfirm'))) return; + try { + await unpublishFont(fontId); + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.unpublishFailed')); + } + } + + function buildPublicPath(workspaceSlug) { + return `/fonts/${workspaceSlug}/{fontstack}/{range}.pbf`; + } + + function copyPublicUrl(workspaceSlug) { + const url = `${window.location.origin}${buildPublicPath(workspaceSlug)}`; + navigator.clipboard + .writeText(url) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + alert(t('font.copyFailed')); + }); + } + + return ( +
+ {errorMessage &&
{errorMessage}
} + +
+
+ +
+ + {isLoading ? ( +
{t('common.loading')}
+ ) : fonts.length === 0 ? ( +
+ {t('font.noFonts')} +
+ ) : ( +
+
+
{t('font.name')}
+
{t('font.family')}
+
{t('font.glyphCount')}
+
{t('font.statusLabel')}
+
+
+ {fonts.map((font) => ( + + ))} +
+ )} +
+ +
+ {!selectedFont ? ( +
+

{t('font.selectFontToView')}

+
+ ) : ( +
+
+
+

{selectedFont.name}

+ {selectedFont.id} +
+ +
+
{t('font.fontstack')}
+
{selectedFont.fontstack}
+
+ + {selectedFont.family && ( +
+
{t('font.family')}
+
{selectedFont.family}
+
+ )} + + {selectedFont.style && ( +
+
{t('font.style')}
+
{selectedFont.style}
+
+ )} + + {selectedFont.glyphCount && ( +
+
{t('font.glyphCount')}
+
{selectedFont.glyphCount.toLocaleString()}
+
+ )} + + {selectedFont.startCp != null && selectedFont.endCp != null && ( +
+
{t('font.unicodeRange')}
+
+ U+{selectedFont.startCp.toString(16).toUpperCase().padStart(4, '0')} - U+ + {selectedFont.endCp.toString(16).toUpperCase().padStart(4, '0')} +
+
+ )} + +
+
{t('font.statusLabel')}
+
+ {getFontStatusLabel(t, selectedFont.status)} +
+
+ +
+
{t('font.uploadTime')}
+
+ {selectedFont.createdAt + ? dateTimeFormatter.format(new Date(selectedFont.createdAt)) + : '-'} +
+
+ + {selectedFont.status === 'failed' && selectedFont.error && ( +
+ {t('font.errorLabel')}: {selectedFont.error} +
+ )} + + {selectedFont.status === 'ready' && ( + <> + {!selectedFont.isPublic ? ( +
+
{t('font.publishStatus')}
+
+
+ {t('font.notPublished')} + +
+
+ {t('font.publicUrl')} +
+
+ {buildPublicPath(selectedFont.workspaceSlug || 'workspace')} +
+
+ + {publishError && ( +
+ {publishError} +
+ )} + + +
+
+
+ ) : ( + <> +
+
{t('font.publishStatus')}
+
+ + {t('font.published')} + +
+
+ +
+
{t('font.publicUrl')}
+
+
+
+ {buildPublicPath(selectedFont.workspaceSlug || 'workspace')} +
+ +
+
+
+ +
+ +
+ + )} + + )} + +
+ +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/IconsPanel.jsx b/frontend/src/IconsPanel.jsx new file mode 100644 index 0000000..edd3cee --- /dev/null +++ b/frontend/src/IconsPanel.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function IconsPanel() { + const { t } = useTranslation(); + + return
{t('app.resourceIconsComingSoon')}
; +} diff --git a/frontend/src/ResourcesPanel.jsx b/frontend/src/ResourcesPanel.jsx new file mode 100644 index 0000000..203bed6 --- /dev/null +++ b/frontend/src/ResourcesPanel.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import FontsPanel from './FontsPanel.jsx'; +import IconsPanel from './IconsPanel.jsx'; +import StylesPanel from './StylesPanel.jsx'; + +export default function ResourcesPanel() { + const { t } = useTranslation(); + const [resourceTab, setResourceTab] = useState('fonts'); + + return ( +
+
+ + + +
+ + {resourceTab === 'fonts' && ( +
+ +
+ )} + + {resourceTab === 'icons' && ( +
+ +
+ )} + + {resourceTab === 'styles' && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/Settings.jsx b/frontend/src/Settings.jsx index dd91496..7fee7f3 100644 --- a/frontend/src/Settings.jsx +++ b/frontend/src/Settings.jsx @@ -16,6 +16,7 @@ import { removeWorkspaceMember, leaveWorkspace, } from './api.js'; +import { validateWorkspaceSlug } from './utils.js'; export default function Settings() { const { t } = useTranslation(); @@ -34,8 +35,13 @@ export default function Settings() { const [workspacesLoading, setWorkspacesLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(''); + const [newWorkspaceSlug, setNewWorkspaceSlug] = useState(''); const [createError, setCreateError] = useState(''); const [isCreating, setIsCreating] = useState(false); + const [editingSlugWorkspaceId, setEditingSlugWorkspaceId] = useState(null); + const [editingSlugValue, setEditingSlugValue] = useState(''); + const [slugActionError, setSlugActionError] = useState(''); + const [isSavingSlug, setIsSavingSlug] = useState(false); const [showMembersModal, setShowMembersModal] = useState(false); const [selectedWorkspace, setSelectedWorkspace] = useState(null); @@ -52,6 +58,15 @@ export default function Settings() { setArchivedWorkspaces(archived); } + function getWorkspaceSlugValidationError(slug) { + return validateWorkspaceSlug(slug.trim(), { + tooLong: t('workspace.slugTooLong'), + tooShort: t('workspace.slugTooShort'), + invalidChars: t('workspace.slugInvalidChars'), + edgeDash: t('workspace.slugEdgeDash'), + }).error; + } + useEffect(() => { loadFailedMessageRef.current = t('settings.loadFailed'); }, [t]); @@ -151,17 +166,24 @@ export default function Settings() { setCreateError(''); const name = newWorkspaceName.trim(); + const slug = newWorkspaceSlug.trim(); if (name.length < 3 || name.length > 50) { setCreateError(t('workspace.nameLengthError')); return; } + const slugError = getWorkspaceSlugValidationError(slug); + if (slugError) { + setCreateError(slugError); + return; + } setIsCreating(true); try { - await createWorkspace(name); + await createWorkspace(name, { slug: slug || undefined }); await refreshWorkspaces(); setShowCreateModal(false); setNewWorkspaceName(''); + setNewWorkspaceSlug(''); } catch (err) { setCreateError(err.message || t('workspace.createFailed')); } finally { @@ -187,6 +209,43 @@ export default function Settings() { } } + function startEditWorkspaceSlug(workspace) { + setSlugActionError(''); + setEditingSlugWorkspaceId(workspace.id); + setEditingSlugValue(workspace.slug || ''); + } + + function cancelEditWorkspaceSlug() { + setEditingSlugWorkspaceId(null); + setEditingSlugValue(''); + setSlugActionError(''); + } + + async function saveWorkspaceSlug(workspaceId) { + const slug = editingSlugValue.trim(); + if (!slug) { + setSlugActionError(t('workspace.slugRequired')); + return; + } + const slugError = getWorkspaceSlugValidationError(slug); + if (slugError) { + setSlugActionError(slugError); + return; + } + + setIsSavingSlug(true); + setSlugActionError(''); + try { + await updateWorkspace(workspaceId, { slug }); + await refreshWorkspaces(); + cancelEditWorkspaceSlug(); + } catch (err) { + setSlugActionError(err.message || t('workspace.updateFailed')); + } finally { + setIsSavingSlug(false); + } + } + async function handleRestoreWorkspace(workspaceId) { try { await restoreWorkspace(workspaceId); @@ -315,7 +374,16 @@ export default function Settings() { }} >

{t('workspace.management')}

- @@ -363,6 +431,38 @@ export default function Settings() { > {t('workspace.memberCount', { count: ws.memberCount })} +
+ {t('workspace.slugLabel')}: {ws.slug || '-'} +
+ {editingSlugWorkspaceId === ws.id && ( +
+ setEditingSlugValue(e.target.value)} + className="form-input" + style={{ width: '100%' }} + placeholder="team-alpha" + /> + {slugActionError && ( +
+ {slugActionError} +
+ )} +
+ )}
+ {ws.ownerId === user.id && + (editingSlugWorkspaceId === ws.id ? ( + <> + + + + ) : ( + + ))} {!ws.isPersonal && ws.ownerId === user.id && (
+
+ + setNewWorkspaceSlug(e.target.value)} + className="form-input" + style={{ width: '100%' }} + placeholder="team-alpha" + /> + {t('workspace.slugHint')} +
+ {createError && ( +
+ {createError} +
+ )}
setShowCreateModal(false)} + onClick={() => { + setShowCreateModal(false); + setCreateError(''); + setNewWorkspaceName(''); + setNewWorkspaceSlug(''); + }} > {t('common.cancel')} diff --git a/frontend/src/StylesPanel.jsx b/frontend/src/StylesPanel.jsx new file mode 100644 index 0000000..5b0c9b1 --- /dev/null +++ b/frontend/src/StylesPanel.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function StylesPanel() { + const { t } = useTranslation(); + + return
{t('app.resourceStylesComingSoon')}
; +} diff --git a/frontend/src/api.js b/frontend/src/api.js index f62aa9f..fd31386 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -176,11 +176,14 @@ export async function listWorkspaces() { return res.json(); } -export async function createWorkspace(name) { +export async function createWorkspace(name, options = {}) { + const body = { name }; + if (options.slug) body.slug = options.slug; + const res = await fetchWithAuth('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -189,11 +192,18 @@ export async function createWorkspace(name) { return res.json(); } -export async function updateWorkspace(workspaceId, name) { +export async function updateWorkspace(workspaceId, updates) { + const body = + typeof updates === 'string' + ? { name: updates } + : { + ...(updates || {}), + }; + const res = await fetchWithAuth(`/api/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -305,3 +315,74 @@ export async function leaveWorkspace(workspaceId) { } return null; } + +export async function listFonts() { + const res = await fetchWithAuth('/api/fonts'); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function uploadFont(file) { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetchWithAuth('/api/fonts', { + method: 'POST', + body: formData, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function getFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function deleteFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}`, { + method: 'DELETE', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return null; +} + +export async function publishFont(fontId, options = {}) { + const body = {}; + if (options.slug) body.slug = options.slug; + + const res = await fetchWithAuth(`/api/fonts/${fontId}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function unpublishFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}/unpublish`, { + method: 'POST', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return null; +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3dc044a..d72e8f5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -49,6 +49,13 @@ "app": { "title": "MapFlow", "subtitle": "Data Source Management", + "mainTabData": "Data", + "mainTabResources": "Resources", + "resourceTabFonts": "Fonts", + "resourceTabIcons": "Icon Sets", + "resourceTabStyles": "Styles", + "resourceIconsComingSoon": "Icon set features are coming soon", + "resourceStylesComingSoon": "Style features are coming soon", "settings": "Settings", "connectPostgis": "Connect PostGIS", "upload": "Upload", @@ -60,6 +67,46 @@ "loadFilesFailed": "Failed to load file list", "uploadFailed": "Upload failed" }, + "font": { + "name": "Name", + "family": "Family", + "style": "Style", + "fontstack": "Font Stack", + "glyphCount": "Glyphs", + "unicodeRange": "Unicode Range", + "statusLabel": "Status", + "uploadTime": "Upload Time", + "errorLabel": "Error", + "uploadFont": "Upload Font", + "uploading": "Uploading...", + "noFonts": "No fonts uploaded yet", + "selectFontToView": "Select a font to view details", + "loadFailed": "Failed to load font list", + "unsupportedFormat": "Unsupported file format. Only .ttf and .otf are allowed", + "uploadFailed": "Font upload failed", + "deleteBtn": "Delete Font", + "deleteConfirm": "Are you sure you want to delete this font?", + "deleteFailed": "Delete font failed", + "publishStatus": "Publish Status", + "notPublished": "Not Published", + "published": "Published", + "publishSlug": "URL Slug (Optional)", + "publishSlugHint": "Leave empty to use font ID. Only letters, numbers, hyphens and underscores", + "publicUrl": "Public URL", + "publishBtn": "Publish", + "publishing": "Publishing...", + "publishFailed": "Publish font failed", + "copyUrl": "Copy URL", + "copyFailed": "Copy failed, please copy manually", + "unpublishBtn": "Unpublish", + "unpublishConfirm": "Are you sure you want to unpublish this font?", + "unpublishFailed": "Unpublish font failed", + "status": { + "processing": "Processing", + "ready": "Ready", + "failed": "Failed" + } + }, "fileList": { "name": "Name", "type": "Type", @@ -304,6 +351,15 @@ "noWorkspaces": "No workspaces", "personalBadge": "Personal", "memberCount": "{{count}} member(s)", + "slugLabel": "Slug", + "slugLabelOptional": "Workspace Slug (Optional)", + "slugHint": "Only lowercase letters, numbers, and hyphens. Example: team-alpha", + "slugRequired": "Workspace slug is required when editing", + "editSlug": "Edit Slug", + "slugTooLong": "Workspace slug cannot exceed 63 characters", + "slugTooShort": "Workspace slug must be at least 3 characters", + "slugInvalidChars": "Only lowercase letters, numbers, and hyphens are supported", + "slugEdgeDash": "Workspace slug cannot start or end with a hyphen", "manageMembers": "Manage members", "delete": "Delete", "leave": "Leave", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index f8af4e2..2d598c5 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -49,6 +49,13 @@ "app": { "title": "MapFlow", "subtitle": "数据源管理与列表", + "mainTabData": "数据", + "mainTabResources": "资源", + "resourceTabFonts": "字体", + "resourceTabIcons": "图标集", + "resourceTabStyles": "样式", + "resourceIconsComingSoon": "图标集功能即将上线", + "resourceStylesComingSoon": "样式功能即将上线", "settings": "设置", "connectPostgis": "连接 PostGIS", "upload": "上传", @@ -60,6 +67,46 @@ "loadFilesFailed": "无法加载文件列表", "uploadFailed": "上传失败" }, + "font": { + "name": "名称", + "family": "字体家族", + "style": "字体样式", + "fontstack": "字体栈", + "glyphCount": "字形数量", + "unicodeRange": "Unicode 范围", + "statusLabel": "状态", + "uploadTime": "上传时间", + "errorLabel": "错误", + "uploadFont": "上传字体", + "uploading": "上传中...", + "noFonts": "暂无字体", + "selectFontToView": "选择一个字体查看详情", + "loadFailed": "加载字体列表失败", + "unsupportedFormat": "不支持的文件格式,仅允许 .ttf 和 .otf", + "uploadFailed": "字体上传失败", + "deleteBtn": "删除字体", + "deleteConfirm": "确定要删除该字体吗?", + "deleteFailed": "删除字体失败", + "publishStatus": "发布状态", + "notPublished": "未发布", + "published": "已发布", + "publishSlug": "URL 标识(可选)", + "publishSlugHint": "留空则使用字体 ID。仅支持字母、数字、连字符和下划线", + "publicUrl": "公开地址", + "publishBtn": "发布", + "publishing": "发布中...", + "publishFailed": "发布字体失败", + "copyUrl": "复制地址", + "copyFailed": "复制失败,请手动复制", + "unpublishBtn": "取消发布", + "unpublishConfirm": "确定要取消发布该字体吗?", + "unpublishFailed": "取消发布字体失败", + "status": { + "processing": "处理中", + "ready": "已就绪", + "failed": "失败" + } + }, "fileList": { "name": "名称", "type": "类型", @@ -304,6 +351,15 @@ "noWorkspaces": "暂无工作空间", "personalBadge": "个人", "memberCount": "{{count}} 个成员", + "slugLabel": "Slug", + "slugLabelOptional": "工作空间 Slug(可选)", + "slugHint": "仅支持小写字母、数字和连字符,例如:team-alpha", + "slugRequired": "编辑时必须填写工作空间 slug", + "editSlug": "编辑 Slug", + "slugTooLong": "工作空间 slug 不能超过 63 个字符", + "slugTooShort": "工作空间 slug 至少需要 3 个字符", + "slugInvalidChars": "工作空间 slug 仅支持小写字母、数字和连字符", + "slugEdgeDash": "工作空间 slug 不能以连字符开头或结尾", "manageMembers": "管理成员", "delete": "删除", "leave": "离开", diff --git a/frontend/src/utils.js b/frontend/src/utils.js index c2b631f..8775e50 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -36,3 +36,32 @@ export function validateSlug(slug, messages = {}) { } return { valid: true, error: '' }; } + +export function validateWorkspaceSlug(slug, messages = {}) { + if (!slug) return { valid: true, error: '' }; + if (slug.length > 63) { + return { + valid: false, + error: messages.tooLong || 'Workspace slug cannot exceed 63 characters', + }; + } + if (slug.length < 3) { + return { + valid: false, + error: messages.tooShort || 'Workspace slug must be at least 3 characters', + }; + } + if (!/^[a-z0-9-]+$/.test(slug)) { + return { + valid: false, + error: messages.invalidChars || 'Only lowercase letters, numbers, and hyphens are supported', + }; + } + if (slug.startsWith('-') || slug.endsWith('-')) { + return { + valid: false, + error: messages.edgeDash || 'Workspace slug cannot start or end with a hyphen', + }; + } + return { valid: true, error: '' }; +} diff --git a/frontend/tests/unit/resources-panel.test.jsx b/frontend/tests/unit/resources-panel.test.jsx new file mode 100644 index 0000000..d4d13d6 --- /dev/null +++ b/frontend/tests/unit/resources-panel.test.jsx @@ -0,0 +1,86 @@ +/* @vitest-environment jsdom */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ResourcesPanel from '../../src/ResourcesPanel.jsx'; + +vi.mock('../../src/FontsPanel.jsx', () => ({ + default: function MockFontsPanel() { + return
fonts-panel
; + }, +})); + +vi.mock('../../src/IconsPanel.jsx', () => ({ + default: function MockIconsPanel() { + return
icons-panel
; + }, +})); + +vi.mock('../../src/StylesPanel.jsx', () => ({ + default: function MockStylesPanel() { + return
styles-panel
; + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => { + const labels = { + 'app.resourceTabFonts': 'Fonts', + 'app.resourceTabIcons': 'Icon Sets', + 'app.resourceTabStyles': 'Styles', + }; + return labels[key] || key; + }, + }), +})); + +afterEach(() => { + cleanup(); +}); + +describe('ResourcesPanel', () => { + it('shows fonts tab content by default', () => { + render(); + + expect(screen.getByTestId('fonts-panel')).toBeTruthy(); + expect(screen.queryByTestId('icons-panel')).toBeNull(); + expect(screen.queryByTestId('styles-panel')).toBeNull(); + }); + + it('switches to icon sets and styles tabs', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('resource-tab-icons')); + expect(screen.queryByTestId('fonts-panel')).toBeNull(); + expect(screen.getByTestId('icons-panel')).toBeTruthy(); + expect(screen.queryByTestId('styles-panel')).toBeNull(); + + await user.click(screen.getByTestId('resource-tab-styles')); + expect(screen.queryByTestId('fonts-panel')).toBeNull(); + expect(screen.queryByTestId('icons-panel')).toBeNull(); + expect(screen.getByTestId('styles-panel')).toBeTruthy(); + }); + + it('updates aria-selected state when switching tabs', async () => { + const user = userEvent.setup(); + render(); + + const fontsTab = screen.getByTestId('resource-tab-fonts'); + const iconsTab = screen.getByTestId('resource-tab-icons'); + const stylesTab = screen.getByTestId('resource-tab-styles'); + + expect(fontsTab.getAttribute('aria-selected')).toBe('true'); + expect(iconsTab.getAttribute('aria-selected')).toBe('false'); + expect(stylesTab.getAttribute('aria-selected')).toBe('false'); + + await user.click(iconsTab); + expect(fontsTab.getAttribute('aria-selected')).toBe('false'); + expect(iconsTab.getAttribute('aria-selected')).toBe('true'); + expect(stylesTab.getAttribute('aria-selected')).toBe('false'); + }); +}); diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js index bb3b02a..1077c95 100644 --- a/frontend/vitest.config.js +++ b/frontend/vitest.config.js @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['tests/unit/**/*.test.js'], + include: ['tests/unit/**/*.test.{js,jsx}'], reporters: 'default', }, });