From 90e3c399ea9380cf7a30259c2149e95bc0a7a0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 25 Sep 2026 15:07:20 +0200 Subject: [PATCH] v0.2.84 --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../mysql/0035_workspace_note_archive.sql | 3 + .../postgres/0035_workspace_note_archive.sql | 3 + .../sqlite/0035_workspace_note_archive.sql | 3 + src/api/files.rs | 7 +- src/api/mod.rs | 89 +++++- src/app/mod.rs | 4 + src/db/mod.rs | 44 ++- src/queries/mod.rs | 6 + src/queries/mysql.rs | 23 +- src/queries/postgres.rs | 23 +- src/queries/sqlite.rs | 23 +- src/tests/db.rs | 1 + src/tests/queries.rs | 3 + static/css/styles.css | 275 +++++++++++++++++- static/editor.html | 4 + static/js/markdown.js | 17 +- static/js/note-api.js | 14 +- static/js/note-editor.js | 174 ++++++++++- static/js/workspace.js | 46 ++- static/workspace.html | 9 + 22 files changed, 729 insertions(+), 46 deletions(-) create mode 100644 migrations/mysql/0035_workspace_note_archive.sql create mode 100644 migrations/postgres/0035_workspace_note_archive.sql create mode 100644 migrations/sqlite/0035_workspace_note_archive.sql diff --git a/Cargo.lock b/Cargo.lock index 0a1179e..93fc71c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.83" +version = "0.2.84" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 33bad75..8bb61c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.83" +version = "0.2.84" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0035_workspace_note_archive.sql b/migrations/mysql/0035_workspace_note_archive.sql new file mode 100644 index 0000000..fbdffd4 --- /dev/null +++ b/migrations/mysql/0035_workspace_note_archive.sql @@ -0,0 +1,3 @@ +ALTER TABLE workspaces ADD COLUMN archive_deleted_notes TINYINT(1) NOT NULL DEFAULT 0; +ALTER TABLE notes ADD COLUMN deleted_at VARCHAR(64) NULL; +CREATE INDEX idx_notes_workspace_deleted ON notes(workspace_id, deleted_at, updated_at DESC); diff --git a/migrations/postgres/0035_workspace_note_archive.sql b/migrations/postgres/0035_workspace_note_archive.sql new file mode 100644 index 0000000..274701f --- /dev/null +++ b/migrations/postgres/0035_workspace_note_archive.sql @@ -0,0 +1,3 @@ +ALTER TABLE workspaces ADD COLUMN archive_deleted_notes BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE notes ADD COLUMN deleted_at TEXT; +CREATE INDEX idx_notes_workspace_deleted ON notes(workspace_id, deleted_at, updated_at DESC); diff --git a/migrations/sqlite/0035_workspace_note_archive.sql b/migrations/sqlite/0035_workspace_note_archive.sql new file mode 100644 index 0000000..88b15ee --- /dev/null +++ b/migrations/sqlite/0035_workspace_note_archive.sql @@ -0,0 +1,3 @@ +ALTER TABLE workspaces ADD COLUMN archive_deleted_notes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE notes ADD COLUMN deleted_at TEXT; +CREATE INDEX IF NOT EXISTS idx_notes_workspace_deleted ON notes(workspace_id, deleted_at, updated_at DESC); diff --git a/src/api/files.rs b/src/api/files.rs index 6234784..984d1de 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -405,6 +405,11 @@ pub async fn delete_note( "This note is protected. Only its owner can delete it.", )); } + if workspace.archive_deleted_notes != 0 { + db::archive_note(&state.db, note.id).await?; + return Ok(Json(serde_json::json!({"ok": true, "archived": true}))); + } + for file in db::list_note_files(&state.db, note.id).await? { crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) .await @@ -417,7 +422,7 @@ pub async fn delete_note( ) .await?; db::delete_note(&state.db, note.id).await?; - Ok(Json(serde_json::json!({"ok": true}))) + Ok(Json(serde_json::json!({"ok": true, "archived": false}))) } pub async fn note_files( diff --git a/src/api/mod.rs b/src/api/mod.rs index 747e3ba..e5c9b0c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -177,6 +177,22 @@ async fn can_set_workspace_password( ) } +async fn can_manage_workspace_settings( + state: &SharedState, + headers: &HeaderMap, + workspace: &db::Workspace, +) -> bool { + let account_owner = crate::auth::is_resource_owner( + state, + "workspace", + &workspace.slug, + user_session_token(headers), + ) + .await + .unwrap_or(false); + account_owner || workspace_creator_is_requester(headers, workspace) +} + async fn has_write_permission( state: &SharedState, headers: &HeaderMap, @@ -354,6 +370,11 @@ pub struct SetWorkspacePasswordRequest { client_id: Option, } +#[derive(Debug, Deserialize)] +pub struct SetWorkspaceSettingsRequest { + archive_deleted_notes: bool, +} + #[derive(Debug, Deserialize)] pub struct RestoreRequest { #[serde(default)] @@ -370,6 +391,8 @@ pub struct WorkspaceInfo { protected: bool, access_level: String, can_set_password: bool, + can_manage_settings: bool, + archive_deleted_notes: bool, created_at: String, updated_at: String, } @@ -442,6 +465,7 @@ pub struct NoteInfo { public_page_unprotected: bool, public_page_enabled: bool, private: bool, + archive_deleted_notes: bool, created_at: String, updated_at: String, can_delete: bool, @@ -714,10 +738,53 @@ pub async fn workspace_info( ) .await?; let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; + let can_manage_settings = can_manage_workspace_settings(&state, &headers, &workspace).await; Ok(Json(workspace_info_from( &workspace, access_level, can_set_password, + can_manage_settings, + ))) +} + +pub async fn set_workspace_settings( + State(state): State, + headers: HeaderMap, + Path(workspace_slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if !can_manage_workspace_settings(&state, &headers, &workspace).await { + return Err(ApiError::forbidden( + "Only the workspace owner can change workspace settings.", + )); + } + db::set_workspace_archive_deleted_notes( + &state.db, + workspace.id, + payload.archive_deleted_notes, + ) + .await?; + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + let access_level = effective_header_access_level( + &state, + &headers, + "workspace", + &workspace.slug, + workspace.is_private, + workspace.password_hash.is_some(), + ) + .await?; + let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; + Ok(Json(workspace_info_from( + &workspace, + access_level, + can_set_password, + true, ))) } @@ -875,8 +942,14 @@ pub async fn open_workspace( let notes = notes.into_iter().skip(start).take(per_page).collect(); let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; + let can_manage_settings = can_manage_workspace_settings(&state, &headers, &workspace).await; Ok(Json(WorkspaceOpenResponse { - workspace: workspace_info_from(&workspace, access_level, can_set_password), + workspace: workspace_info_from( + &workspace, + access_level, + can_set_password, + can_manage_settings, + ), notes, pagination: ListPaginationMeta { page, @@ -1159,6 +1232,7 @@ pub async fn note_info( public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?, public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?, private: workspace.is_private != 0, + archive_deleted_notes: workspace.archive_deleted_notes != 0, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), can_delete: can_delete_files, @@ -1628,6 +1702,7 @@ fn workspace_info_from( workspace: &db::Workspace, access_level: AccessLevel, can_set_password: bool, + can_manage_settings: bool, ) -> WorkspaceInfo { WorkspaceInfo { slug: workspace.slug.clone(), @@ -1635,6 +1710,8 @@ fn workspace_info_from( protected: workspace.password_hash.is_some(), access_level: access_level_name(access_level).into(), can_set_password, + can_manage_settings, + archive_deleted_notes: workspace.archive_deleted_notes != 0, created_at: db::normalize_timestamp(&workspace.created_at), updated_at: db::normalize_timestamp(&workspace.updated_at), } @@ -1701,18 +1778,12 @@ async fn unique_note_slug( workspace_id: i64, base: &str, ) -> Result { - if db::find_note(&state.db, workspace_id, base) - .await? - .is_none() - { + if !db::note_slug_exists_any(&state.db, workspace_id, base).await? { return Ok(base.to_owned()); } for _ in 0..8 { let candidate = format!("{base}-{}", db::random_suffix(6)); - if db::find_note(&state.db, workspace_id, &candidate) - .await? - .is_none() - { + if !db::note_slug_exists_any(&state.db, workspace_id, &candidate).await? { return Ok(candidate); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 832636a..3fc3f16 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -183,6 +183,10 @@ pub fn router( "/api/workspaces/{workspace_slug}/password", post(api::set_workspace_password), ) + .route( + "/api/workspaces/{workspace_slug}/settings", + post(api::set_workspace_settings), + ) .route( "/api/workspaces/{workspace_slug}/open", post(api::open_workspace), diff --git a/src/db/mod.rs b/src/db/mod.rs index 25dbc38..3e458fb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -52,6 +52,7 @@ pub struct Workspace { pub created_at: String, pub updated_at: String, pub is_private: i64, + pub archive_deleted_notes: i64, pub created_by_guest_id: Option, } @@ -179,6 +180,22 @@ pub async fn set_workspace_password( Ok(()) } +pub async fn set_workspace_archive_deleted_notes( + pool: &Database, + workspace_id: i64, + enabled: bool, +) -> Result<(), sqlx::Error> { + sqlx::query(queries::get( + pool.kind(), + queries::WORKSPACE_SET_ARCHIVE_DELETED_NOTES, + )) + .bind(enabled) + .bind(workspace_id) + .execute(pool.pool()) + .await?; + Ok(()) +} + pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { match ( &workspace.password_hash, @@ -241,7 +258,7 @@ pub async fn list_note_stats( (SELECT COUNT(*) FROM note_files f WHERE f.note_id = n.id) AS file_count, \ (SELECT {file_size_sum} FROM note_files f WHERE f.note_id = n.id) AS file_size_bytes, \ (SELECT COUNT(*) FROM note_revisions r WHERE r.note_id = n.id) AS revision_count \ - FROM notes n WHERE n.workspace_id = {placeholder}" + FROM notes n WHERE n.workspace_id = {placeholder} AND n.deleted_at IS NULL" ); let rows = sqlx::query(&query) @@ -284,6 +301,30 @@ pub async fn find_note( .await } +pub async fn note_slug_exists_any( + pool: &Database, + workspace_id: i64, + slug: &str, +) -> Result { + let count: i64 = sqlx::query_scalar(queries::get( + pool.kind(), + queries::NOTE_SLUG_EXISTS_ANY, + )) + .bind(workspace_id) + .bind(slug) + .fetch_one(pool.pool()) + .await?; + Ok(count > 0) +} + +pub async fn archive_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::NOTE_ARCHIVE)) + .bind(note_id) + .execute(pool.pool()) + .await?; + Ok(()) +} + pub async fn create_note( pool: &Database, workspace_id: i64, @@ -665,6 +706,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")?, + archive_deleted_notes: row.try_get("archive_deleted_notes")?, created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?, }) } diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 9824ade..7c045ae 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -99,6 +99,9 @@ pub enum Query { USER_SET_PAD_PASSWORD, USER_SET_WORKSPACE_PRIVACY, USER_SET_PAD_PRIVACY, + WORKSPACE_SET_ARCHIVE_DELETED_NOTES, + NOTE_ARCHIVE, + NOTE_SLUG_EXISTS_ANY, RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, RESOURCE_ACCESS_TOKENS_INSERT, @@ -276,6 +279,9 @@ pub const USER_SET_WORKSPACE_PASSWORD: Query = Query::USER_SET_WORKSPACE_PASSWOR pub const USER_SET_PAD_PASSWORD: Query = Query::USER_SET_PAD_PASSWORD; pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY; pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY; +pub const WORKSPACE_SET_ARCHIVE_DELETED_NOTES: Query = Query::WORKSPACE_SET_ARCHIVE_DELETED_NOTES; +pub const NOTE_ARCHIVE: Query = Query::NOTE_ARCHIVE; +pub const NOTE_SLUG_EXISTS_ANY: Query = Query::NOTE_SLUG_EXISTS_ANY; pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE; pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query = diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 0996631..e0cc8d1 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -200,10 +200,10 @@ pub fn get(query: Query) -> &'static str { r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# } Query::USER_FAVORITE_NOTE_LIST => { - r#"SELECT n.id, n.slug, CAST(n.title AS CHAR CHARACTER SET utf8mb4) AS title, n.updated_at, w.slug AS workspace_slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"# + r#"SELECT n.id, n.slug, CAST(n.title AS CHAR CHARACTER SET utf8mb4) AS title, n.updated_at, w.slug AS workspace_slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? AND n.deleted_at IS NULL ORDER BY f.created_at DESC, n.id DESC"# } Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { - r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"# + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ? AND n.deleted_at IS NULL"# } Query::USER_FAVORITE_PADS_DELETE_BY_USER => { r#"DELETE FROM user_pad_favorites WHERE user_id = ?"# @@ -231,6 +231,15 @@ pub fn get(query: Query) -> &'static str { Query::USER_SET_PAD_PRIVACY => { r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# } + Query::WORKSPACE_SET_ARCHIVE_DELETED_NOTES => { + r#"UPDATE workspaces SET archive_deleted_notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::NOTE_ARCHIVE => { + r#"UPDATE notes SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL"# + } + Query::NOTE_SLUG_EXISTS_ANY => { + r#"SELECT COUNT(*) FROM notes WHERE workspace_id = ? AND slug = ?"# + } Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# } @@ -311,14 +320,14 @@ pub fn get(query: Query) -> &'static str { r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM notes WHERE id = ?"# } Query::Q001 => { - r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"# + r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS SIGNED) AS archive_deleted_notes, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"# } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"# + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL"# } Query::Q005 => { r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"# @@ -355,14 +364,14 @@ pub fn get(query: Query) -> &'static str { Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#, Query::Q021 => { - r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.slug, n.slug) AS CHAR CHARACTER SET utf8mb4) AS resource_slug, n.workspace_id AS workspace_id, CAST(w.slug AS CHAR CHARACTER SET utf8mb4) AS workspace_slug, CAST(COALESCE(p.owner_map, n.owner_map, '[]') AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ?"# + r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.slug, n.slug) AS CHAR CHARACTER SET utf8mb4) AS resource_slug, n.workspace_id AS workspace_id, CAST(w.slug AS CHAR CHARACTER SET utf8mb4) AS workspace_slug, CAST(COALESCE(p.owner_map, n.owner_map, '[]') AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ? AND (pp.note_id IS NULL OR n.deleted_at IS NULL)"# } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#, Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = ?"#, Query::Q025 => r#"UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q026 => r#"SELECT id FROM pads WHERE file_token = ?"#, - Query::Q027 => r#"SELECT id FROM notes WHERE file_token = ?"#, + Query::Q027 => r#"SELECT id FROM notes WHERE file_token = ? AND deleted_at IS NULL"#, Query::Q028 => { r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM note_revisions WHERE id = ? AND note_id = ?"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 2459d9d..08d07c7 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -202,10 +202,10 @@ pub fn get(query: Query) -> &'static str { r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"# } Query::USER_FAVORITE_NOTE_LIST => { - r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, n.id DESC"# + r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = $1 AND n.deleted_at IS NULL ORDER BY f.created_at DESC, n.id DESC"# } Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { - r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = $1 AND n.workspace_id = $2"# + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = $1 AND n.workspace_id = $2 AND n.deleted_at IS NULL"# } Query::USER_FAVORITE_PADS_DELETE_BY_USER => { r#"DELETE FROM user_pad_favorites WHERE user_id = $1"# @@ -233,6 +233,15 @@ pub fn get(query: Query) -> &'static str { Query::USER_SET_PAD_PRIVACY => { r#"UPDATE pads SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"# } + Query::WORKSPACE_SET_ARCHIVE_DELETED_NOTES => { + r#"UPDATE workspaces SET archive_deleted_notes = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"# + } + Query::NOTE_ARCHIVE => { + r#"UPDATE notes SET deleted_at = (CURRENT_TIMESTAMP::text), updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $1 AND deleted_at IS NULL"# + } + Query::NOTE_SLUG_EXISTS_ANY => { + r#"SELECT COUNT(*) FROM notes WHERE workspace_id = $1 AND slug = $2"# + } Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"# } @@ -311,14 +320,14 @@ pub fn get(query: Query) -> &'static str { r#"SELECT public_page_disabled FROM notes WHERE id = $1"# } Query::Q001 => { - r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM workspaces WHERE slug = $1"# + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS BIGINT) AS archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = $1"# } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND slug = $2"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND slug = $2 AND deleted_at IS NULL"# } Query::Q005 => { r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES ($1, $2, $3, $4, $5, $6)"# @@ -357,14 +366,14 @@ pub fn get(query: Query) -> &'static str { Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = $1"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#, Query::Q021 => { - r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = $1"# + r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = $1 AND (pp.note_id IS NULL OR n.deleted_at IS NULL)"# } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = $1"#, Query::Q023 => r#"UPDATE pads SET file_token = $1 WHERE id = $2 AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = $1"#, Query::Q025 => r#"UPDATE notes SET file_token = $1 WHERE id = $2 AND file_token IS NULL"#, Query::Q026 => r#"SELECT id FROM pads WHERE file_token = $1"#, - Query::Q027 => r#"SELECT id FROM notes WHERE file_token = $1"#, + Query::Q027 => r#"SELECT id FROM notes WHERE file_token = $1 AND deleted_at IS NULL"#, Query::Q028 => r#"SELECT content FROM note_revisions WHERE id = $1 AND note_id = $2"#, Query::Q029 => r#"SELECT content FROM revisions WHERE id = $1 AND pad_id = $2"#, Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = $1 AND pad_id = $2"#, diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index f6201d9..1b64e0e 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -200,10 +200,10 @@ pub fn get(query: Query) -> &'static str { r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# } Query::USER_FAVORITE_NOTE_LIST => { - r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"# + r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? AND n.deleted_at IS NULL ORDER BY f.created_at DESC, n.id DESC"# } Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { - r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"# + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ? AND n.deleted_at IS NULL"# } Query::USER_FAVORITE_PADS_DELETE_BY_USER => { r#"DELETE FROM user_pad_favorites WHERE user_id = ?"# @@ -231,6 +231,15 @@ pub fn get(query: Query) -> &'static str { Query::USER_SET_PAD_PRIVACY => { r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# } + Query::WORKSPACE_SET_ARCHIVE_DELETED_NOTES => { + r#"UPDATE workspaces SET archive_deleted_notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::NOTE_ARCHIVE => { + r#"UPDATE notes SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL"# + } + Query::NOTE_SLUG_EXISTS_ANY => { + r#"SELECT COUNT(*) FROM notes WHERE workspace_id = ? AND slug = ?"# + } Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# } @@ -311,14 +320,14 @@ pub fn get(query: Query) -> &'static str { r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM notes WHERE id = ?"# } Query::Q001 => { - r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM workspaces WHERE slug = ?"# + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = ?"# } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL"# } Query::Q005 => { r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"# @@ -355,14 +364,14 @@ pub fn get(query: Query) -> &'static str { Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#, Query::Q021 => { - r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ?"# + r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ? AND (pp.note_id IS NULL OR n.deleted_at IS NULL)"# } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#, Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = ?"#, Query::Q025 => r#"UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q026 => r#"SELECT id FROM pads WHERE file_token = ?"#, - Query::Q027 => r#"SELECT id FROM notes WHERE file_token = ?"#, + Query::Q027 => r#"SELECT id FROM notes WHERE file_token = ? AND deleted_at IS NULL"#, Query::Q028 => r#"SELECT content FROM note_revisions WHERE id = ? AND note_id = ?"#, Query::Q029 => r#"SELECT content FROM revisions WHERE id = ? AND pad_id = ?"#, Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"#, diff --git a/src/tests/db.rs b/src/tests/db.rs index 3466aa2..a673609 100644 --- a/src/tests/db.rs +++ b/src/tests/db.rs @@ -18,6 +18,7 @@ fn workspace(password_hash: Option) -> Workspace { created_at: String::new(), updated_at: String::new(), is_private: 1, + archive_deleted_notes: 0, created_by_guest_id: None, } } diff --git a/src/tests/queries.rs b/src/tests/queries.rs index ba06e36..e9e3240 100644 --- a/src/tests/queries.rs +++ b/src/tests/queries.rs @@ -30,6 +30,9 @@ fn every_backend_has_explicit_queries() { USER_FAVORITE_NOTE_IDS_BY_WORKSPACE, USER_FAVORITE_PADS_DELETE_BY_USER, USER_FAVORITE_NOTES_DELETE_BY_USER, + WORKSPACE_SET_ARCHIVE_DELETED_NOTES, + NOTE_ARCHIVE, + NOTE_SLUG_EXISTS_ANY, RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, SHARE_LINK_SESSION_SOURCE, SHARE_SESSION_INSERT, diff --git a/static/css/styles.css b/static/css/styles.css index 114d555..3ede1ae 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1115,12 +1115,16 @@ textarea::selection { } .markdown-body blockquote { - + margin: .35em 0; padding: .2em 1em; border-left: 3px solid var(--accent); color: var(--text-muted-strong); } +.markdown-body .markdown-quote-line { + min-height: 1.5em; +} + .markdown-body a { color: var(--accent-text); } @@ -1153,7 +1157,7 @@ textarea::selection { bottom: 0; z-index: 24; display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); width: 340px; max-width: 100%; overflow: hidden; @@ -1184,6 +1188,33 @@ textarea::selection { padding: 18px; } +.history-view-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin: 0 18px 8px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-inset); +} + +.history-view-switch button { + min-height: 30px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: .74rem; + font-weight: 700; +} + +.history-view-switch button.active { + background: var(--surface-2); + color: var(--text-heading); + box-shadow: 0 0 0 1px var(--border); +} + .icon-button { display: grid; place-items: center; @@ -1203,6 +1234,190 @@ textarea::selection { padding: 10px 18px 24px; } +.history-list--archive { + padding-top: 4px; +} + +.history-archive { + display: grid; + gap: 14px; +} + +.history-archive__years { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 2px; +} + +.history-archive__years button { + flex: 0 0 auto; + min-width: 56px; + min-height: 30px; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + color: var(--muted); + font-size: .72rem; + font-weight: 700; +} + +.history-archive__years button.active { + border-color: color-mix(in srgb, var(--accent) 56%, var(--border)); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); + color: var(--text-heading); +} + +.history-archive__histogram { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + align-items: end; + gap: 3px; + min-height: 98px; + padding: 10px 8px 6px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-inset); +} + +.history-archive__bar-cell { + display: grid; + grid-template-rows: 74px auto; + align-items: end; + gap: 4px; + min-width: 0; +} + +.history-archive__bar { + display: block; + width: 100%; + height: var(--history-bar-height, 3px); + border-radius: 3px 3px 1px 1px; + background: color-mix(in srgb, var(--accent) 72%, var(--surface-2)); +} + +.history-archive__bar-cell small { + overflow: hidden; + color: var(--muted-2); + font-size: .52rem; + text-align: center; + text-transform: uppercase; +} + +.history-calendar { + display: grid; + gap: 12px; +} + +.history-calendar__month { + padding: 10px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-card); +} + +.history-calendar__month-head, +.history-archive__selected-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} + +.history-calendar__month-head strong, +.history-archive__selected-head strong { + font-size: .78rem; +} + +.history-calendar__month-head span, +.history-archive__selected-head span { + color: var(--muted-2); + font-size: .64rem; +} + +.history-calendar__weekdays, +.history-calendar__days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; +} + +.history-calendar__weekdays { + margin-bottom: 4px; +} + +.history-calendar__weekdays span { + color: var(--muted-2); + font-size: .56rem; + text-align: center; +} + +.history-calendar__blank { + min-height: 30px; +} + +.history-calendar__day { + position: relative; + display: grid; + place-items: center; + min-height: 31px; + padding: 0; + border: 1px solid transparent; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 22%, var(--surface)); + color: var(--text-heading); + font-size: .66rem; +} + +button.history-calendar__day { + cursor: pointer; +} + +.history-calendar__day small { + position: absolute; + right: -1px; + bottom: -1px; + display: grid; + place-items: center; + min-width: 13px; + height: 13px; + padding: 0 3px; + border: 1px solid var(--surface-card); + border-radius: 999px; + background: var(--accent); + color: var(--on-accent); + font-size: .48rem; + line-height: 1; +} + +.history-calendar__day.active { + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.history-calendar__day--empty { + background: transparent; + color: var(--muted-2); +} + +.history-archive__selected { + padding-top: 2px; + border-top: 1px solid var(--border); +} + +.history-archive__hint { + margin: 0; + padding: 8px 0 18px; + color: var(--muted-2); + font-size: .72rem; + text-align: center; +} + +.revision--compact { + padding: 10px 0; +} + .revision { position: relative; display: grid; @@ -3367,6 +3582,62 @@ dialog::backdrop { display: none; } +.workspace-settings-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px 14px; + margin-top: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-card); +} + +.workspace-settings-card[hidden] { + display: none; +} + +.workspace-settings-card__copy { + display: grid; + gap: 2px; + grid-column: 1 / -1; +} + +.workspace-settings-card__copy strong { + font-size: .82rem; +} + +.workspace-settings-card__copy small, +.workspace-settings-card>.form-message { + color: var(--muted); + font-size: .72rem; +} + +.workspace-archive-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: .78rem; +} + +.workspace-archive-toggle input { + width: auto; + min-height: auto; + margin: 0; + accent-color: var(--accent); +} + +.workspace-settings-card>.form-message { + grid-column: 1 / -1; + margin: 0; +} + +.workspace-settings-card>.form-message:empty { + display: none; +} + .notes-view-switch { display: inline-flex; padding: 3px; diff --git a/static/editor.html b/static/editor.html index 458f159..0212a27 100644 --- a/static/editor.html +++ b/static/editor.html @@ -272,6 +272,10 @@

Author, time, and version preview

+
+ + +
diff --git a/static/js/markdown.js b/static/js/markdown.js index c673b7f..a8bee8b 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -531,11 +531,27 @@ export function renderMarkdown(source, lineOffset = 0) { const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); const listItem = listLine(line); + const quote = line.match(/^>\s?(.*)$/); if (listItem) { const rendered = renderList(lines, index, lineOffset, listItem.indent, listItem.type); html += rendered.html; index = rendered.end - 1; + } else if (quote) { + closeList(); + let quoteBody = ""; + let quoteIndex = index; + while (quoteIndex < lines.length) { + const quoteLine = lines[quoteIndex].match(/^>\s?(.*)$/); + if (!quoteLine) break; + const prefix = lines[quoteIndex].startsWith("> ") ? "> " : ">"; + const lineAttrs = attrs(quoteIndex, true, prefix, "", lineOffset) + .replace(' class="', ' class="markdown-quote-line '); + quoteBody += `${quoteLine[1] ? inline(quoteLine[1]) : "
"}`; + quoteIndex++; + } + html += `
${quoteBody}
`; + index = quoteIndex - 1; } else if (heading) { closeList(); const n = heading[1].length; @@ -555,7 +571,6 @@ export function renderMarkdown(source, lineOffset = 0) { html += ``; } else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += ``; else if (/^---+$/.test(line.trim())) html += ``; - else if (line.startsWith("> ")) html += ` ", "", lineOffset)}>${inline(line.slice(2))}`; else if (line.trim()) html += `${inline(line)}

`; else html += `
`; } diff --git a/static/js/note-api.js b/static/js/note-api.js index 338bce1..1cdb7fb 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -112,18 +112,22 @@ export function createWorkspaceNoteAdapter() { : "Delete note"; }, async deleteNote(info, accessToken) { - if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, { + const archive = Boolean(info.archive_deleted_notes); + const message = archive + ? `Delete note “${info.title}”? It will be archived and hidden from this workspace, but kept in the database.` + : `Delete note “${info.title}”? This cannot be undone.`; + if (!await askConfirm(message, { title: "Delete note", - confirmText: "Delete", + confirmText: archive ? "Archive" : "Delete", danger: true, })) return; - await api(base, { + const result = await api(base, { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }), }); - queueToast(`The note "${info.title}" was deleted.`, { + queueToast(result.archived ? `The note "${info.title}" was archived.` : `The note "${info.title}" was deleted.`, { type: "success", - title: "Note deleted", + title: result.archived ? "Note archived" : "Note deleted", }); location.assign(`/w/${encode(workspaceSlug)}`); }, diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 8aa1034..eb6507e 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -2424,7 +2424,179 @@ export function startNoteEditor(adapter) { } }); document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); void syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } }); - const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

Loading…

'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `

${escapeHtml(e.message)}

`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); + const historyPanel = document.querySelector("#history-panel"); + const historyList = document.querySelector("#history-list"); + let historyRevisions = []; + let historyView = "list"; + let historyArchiveYear = null; + let historyArchiveDay = null; + + function parseHistoryDate(value) { + const raw = String(value ?? "").trim(); + let normalized = raw; + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; + else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); + else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + } + + function historyRevisionCard(revision, compact = false) { + const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || "Empty note"); + const author = revision.author || "Unknown author"; + return `
${escapeHtml(author)}

${snippet}

`; + } + + function renderHistoryListView() { + historyList.classList.remove("history-list--archive"); + historyList.innerHTML = historyRevisions.length + ? historyRevisions.map(revision => historyRevisionCard(revision)).join("") + : '

No history yet.

'; + } + + function dayKey(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function renderHistoryArchiveView() { + historyList.classList.add("history-list--archive"); + const dated = historyRevisions + .map(revision => ({ revision, date: parseHistoryDate(revision.created_at) })) + .filter(item => item.date); + if (!dated.length) { + historyList.innerHTML = historyRevisions.length + ? '

Archive view needs valid revision timestamps.

' + : '

No history yet.

'; + return; + } + + const years = [...new Set(dated.map(item => item.date.getFullYear()))].sort((a, b) => b - a); + if (!historyArchiveYear || !years.includes(historyArchiveYear)) historyArchiveYear = years[0]; + const yearItems = dated.filter(item => item.date.getFullYear() === historyArchiveYear); + const monthCounts = Array.from({ length: 12 }, () => 0); + const byDay = new Map(); + yearItems.forEach(item => { + monthCounts[item.date.getMonth()] += 1; + const key = dayKey(item.date); + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(item.revision); + }); + const maxMonthCount = Math.max(1, ...monthCounts); + const months = Array.from({ length: 12 }, (_, month) => month); + const monthNames = new Intl.DateTimeFormat(undefined, { month: "short" }); + const monthNamesLong = new Intl.DateTimeFormat(undefined, { month: "long" }); + const yearButtons = years.map(year => ``).join(""); + const histogram = months.map(month => { + const count = monthCounts[month]; + const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3; + return `
${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}
`; + }).join(""); + + const calendars = months.filter(month => monthCounts[month] > 0).map(month => { + const first = new Date(historyArchiveYear, month, 1); + const daysInMonth = new Date(historyArchiveYear, month + 1, 0).getDate(); + const mondayOffset = (first.getDay() + 6) % 7; + const blanks = Array.from({ length: mondayOffset }, () => '').join(""); + let days = ""; + for (let day = 1; day <= daysInMonth; day++) { + const key = `${historyArchiveYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const revisions = byDay.get(key) || []; + const active = historyArchiveDay === key; + days += revisions.length + ? `` + : `${day}`; + } + return `
${escapeHtml(monthNamesLong.format(first))}${monthCounts[month]} revisions
MTWTFSS
${blanks}${days}
`; + }).join(""); + + const selected = historyArchiveDay && byDay.get(historyArchiveDay); + const selectedHtml = selected?.length + ? `
${escapeHtml(historyArchiveDay)}${selected.length} saved version${selected.length === 1 ? "" : "s"}
${selected.map(revision => historyRevisionCard(revision, true)).join("")}
` + : '

Select a highlighted day to inspect saved versions.

'; + + historyList.innerHTML = `
${yearButtons}
${histogram}
${calendars}
${selectedHtml}
`; + } + + function renderHistory() { + document.querySelectorAll("[data-history-view]").forEach(button => { + const active = button.dataset.historyView === historyView; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + if (historyView === "archive") renderHistoryArchiveView(); + else renderHistoryListView(); + } + + document.querySelectorAll("[data-history-view]").forEach(button => button.addEventListener("click", () => { + historyView = button.dataset.historyView === "archive" ? "archive" : "list"; + renderHistory(); + })); + + historyList.addEventListener("click", async event => { + const yearButton = event.target.closest("[data-history-year]"); + if (yearButton) { + historyArchiveYear = Number(yearButton.dataset.historyYear); + historyArchiveDay = null; + renderHistoryArchiveView(); + return; + } + const dayButton = event.target.closest("[data-history-day]"); + if (dayButton) { + historyArchiveDay = dayButton.dataset.historyDay; + renderHistoryArchiveView(); + return; + } + const previewButton = event.target.closest("[data-preview]"); + if (previewButton) { + const revision = historyRevisions.find(item => String(item.id) === previewButton.dataset.preview); + const previewNode = previewButton.closest(".revision")?.querySelector(".revision__preview"); + if (!revision || !previewNode) return; + previewNode.hidden = !previewNode.hidden; + previewNode.textContent = revision.content; + return; + } + const restoreButton = event.target.closest("[data-revision]"); + if (!restoreButton) return; + restoreButton.disabled = true; + try { + await adapter.restoreRevision(Number(restoreButton.dataset.revision), accessToken); + toast.success("The selected revision is now the current version.", { title: "Version restored" }); + historyRevisions = await adapter.loadHistory(accessToken); + renderHistory(); + } catch (error) { + toast.danger(error.message, { title: "Could not restore version" }); + restoreButton.disabled = false; + } + }); + + document.querySelector("#history-button").addEventListener("click", async () => { + if (info?.protected && !resourceUnlocked) { + if (!passwordDialog.open) passwordDialog.showModal(); + document.querySelector("#open-password")?.focus(); + return; + } + historyPanel.classList.add("open"); + historyPanel.setAttribute("aria-hidden", "false"); + document.body.classList.add("history-open"); + historyList.innerHTML = '

Loading…

'; + try { + historyRevisions = await adapter.loadHistory(accessToken); + historyArchiveYear = null; + historyArchiveDay = null; + renderHistory(); + } catch (e) { + historyList.innerHTML = `

${escapeHtml(e.message)}

`; + toast.danger(e.message, { title: "Could not load version history" }); + } + }); + document.querySelector("#close-history").addEventListener("click", () => { + historyPanel.classList.remove("open"); + historyPanel.setAttribute("aria-hidden", "true"); + document.body.classList.remove("history-open"); + }); const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } }); initialize(); diff --git a/static/js/workspace.js b/static/js/workspace.js index c0b6c7c..189b80d 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -45,6 +45,9 @@ const notesPagination = document.querySelector("#notes-pagination"); const workspacePasswordForm = document.querySelector("#workspace-password-form"); const workspacePasswordInput = document.querySelector("#workspace-set-password"); const workspacePasswordError = document.querySelector("#workspace-password-error"); +const workspaceSettingsForm = document.querySelector("#workspace-settings-form"); +const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes"); +const workspaceSettingsStatus = document.querySelector("#workspace-settings-status"); let notesPage = 1; let notesSearchTimer; const notesViewKey = `rustpad:workspace:${slug}:notes-view`; @@ -55,6 +58,11 @@ function updateWorkspacePasswordControl() { workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password); } +function updateWorkspaceSettingsControl() { + workspaceSettingsForm.hidden = !info?.can_manage_settings; + archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes); +} + function stopWorkspaceWatch() { clearInterval(workspaceWatchPingTimer); clearTimeout(workspaceWatchReconnectTimer); @@ -293,6 +301,7 @@ async function openWorkspace() { renderNotes(); renderNotesPagination(data.pagination); updateWorkspacePasswordControl(); + updateWorkspaceSettingsControl(); workspaceLockedForPassword = false; workspaceContent.hidden = false; if (dialog.open) dialog.close(); @@ -313,6 +322,7 @@ async function init() { document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; updateWorkspacePasswordControl(); + updateWorkspaceSettingsControl(); if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue."); else await openWorkspace(); } catch (e) { @@ -364,6 +374,29 @@ workspacePasswordForm.addEventListener("submit", async event => { submit.disabled = false; } }); +workspaceSettingsForm.addEventListener("submit", async event => { + event.preventDefault(); + workspaceSettingsStatus.textContent = ""; + const submit = workspaceSettingsForm.querySelector('button[type="submit"]'); + submit.disabled = true; + try { + const updated = await api(`/api/workspaces/${encodeURIComponent(slug)}/settings`, { + method: "POST", + body: JSON.stringify({ archive_deleted_notes: archiveDeletedNotesToggle.checked }), + }); + info = { ...info, ...updated }; + updateWorkspaceSettingsControl(); + workspaceSettingsStatus.textContent = info.archive_deleted_notes + ? "Deleted notes will be archived in the database." + : "Deleted notes will be permanently removed."; + toast.success("Workspace deletion policy saved.", { title: "Workspace settings" }); + } catch (error) { + workspaceSettingsStatus.textContent = error.message; + toast.danger(error.message, { title: "Could not save workspace settings" }); + } finally { + submit.disabled = false; + } +}); document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); document.querySelector("#note-form").addEventListener("submit", async e => { @@ -387,13 +420,20 @@ notesList.addEventListener("click", async event => { const button = event.target.closest("[data-delete-note]"); if (!button) return; const title = button.dataset.noteTitle; - if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return; + const archive = Boolean(info?.archive_deleted_notes); + const message = archive + ? `Delete note “${title}”? It will be archived and hidden from this workspace, but kept in the database.` + : `Delete note “${title}”? This cannot be undone.`; + if (!await askConfirm(message, { title: "Delete note", confirmText: archive ? "Archive" : "Delete", danger: true })) return; button.disabled = true; try { - await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { + const result = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }) }); - toast.success(`The note "${title}" was deleted.`, { title: "Note deleted" }); + toast.success( + result.archived ? `The note "${title}" was archived.` : `The note "${title}" was deleted.`, + { title: result.archived ? "Note archived" : "Note deleted" }, + ); await openWorkspace(); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; } }); diff --git a/static/workspace.html b/static/workspace.html index a36c8bc..56f2a1e 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -42,6 +42,15 @@ required> +