This commit is contained in:
Mateusz Gruszczyński committed 2026-09-25 15:07:20 +02:00
1 parent 6329ac33d1
commit 90e3c399ea
22 files changed
+729 -46

No files matched your search

Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.83"
version = "0.2.84"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
@@ -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);
@@ -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);
@@ -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);
+6 -1
View File
@@ -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(
+80 -9
View File
@@ -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<String>,
}
#[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<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Json(payload): Json<SetWorkspaceSettingsRequest>,
) -> Result<Json<WorkspaceInfo>, 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(&note.created_at),
updated_at: db::normalize_timestamp(&note.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<String, ApiError> {
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);
}
}
+4
View File
@@ -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),
+43 -1
View File
@@ -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<String>,
}
@@ -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<bool, sqlx::Error> {
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")?,
})
}
+6
View File
@@ -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 =
+16 -7
View File
@@ -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 = ?"#
}
+16 -7
View File
@@ -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"#,
+16 -7
View File
@@ -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 = ?"#,
+1
View File
@@ -18,6 +18,7 @@ fn workspace(password_hash: Option<String>) -> Workspace {
created_at: String::new(),
updated_at: String::new(),
is_private: 1,
archive_deleted_notes: 0,
created_by_guest_id: None,
}
}
+3
View File
@@ -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,
+273 -2
View File
@@ -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;
+4
View File
@@ -272,6 +272,10 @@
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div class="history-view-switch" role="group" aria-label="History view">
<button type="button" data-history-view="list" class="active" aria-pressed="true">List</button>
<button type="button" data-history-view="archive" aria-pressed="false">Archive</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
+16 -1
View File
@@ -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 += `<div${lineAttrs}>${quoteLine[1] ? inline(quoteLine[1]) : "<br>"}</div>`;
quoteIndex++;
}
html += `<blockquote class="markdown-quote-group">${quoteBody}</blockquote>`;
index = quoteIndex - 1;
} else if (heading) {
closeList();
const n = heading[1].length;
@@ -555,7 +571,6 @@ export function renderMarkdown(source, lineOffset = 0) {
html += `</dl>`;
} else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
}
+9 -5
View File
@@ -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)}`);
},
+173 -1
View File
@@ -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 = '<p class="empty">Loading…</p>'; 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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; 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 = `<p class="error">${escapeHtml(e.message)}</p>`; 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 `<article class="revision${compact ? " revision--compact" : ""}" data-history-revision="${revision.id}"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(revision.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button type="button" data-preview="${revision.id}">Preview</button><button type="button" data-revision="${revision.id}">Restore</button><div class="revision__preview" hidden></div></div></article>`;
}
function renderHistoryListView() {
historyList.classList.remove("history-list--archive");
historyList.innerHTML = historyRevisions.length
? historyRevisions.map(revision => historyRevisionCard(revision)).join("")
: '<p class="empty">No history yet.</p>';
}
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
? '<p class="empty">Archive view needs valid revision timestamps.</p>'
: '<p class="empty">No history yet.</p>';
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 => `<button type="button" data-history-year="${year}" class="${year === historyArchiveYear ? "active" : ""}" aria-pressed="${year === historyArchiveYear}">${year}</button>`).join("");
const histogram = months.map(month => {
const count = monthCounts[month];
const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3;
return `<div class="history-archive__bar-cell" title="${escapeHtml(monthNamesLong.format(new Date(historyArchiveYear, month, 1)))}: ${count} revisions"><span class="history-archive__bar" style="--history-bar-height:${height}px"></span><small>${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}</small></div>`;
}).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 }, () => '<span class="history-calendar__blank"></span>').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
? `<button type="button" class="history-calendar__day${active ? " active" : ""}" data-history-day="${key}" title="${revisions.length} revision${revisions.length === 1 ? "" : "s"}"><span>${day}</span><small>${revisions.length}</small></button>`
: `<span class="history-calendar__day history-calendar__day--empty"><span>${day}</span></span>`;
}
return `<section class="history-calendar__month"><div class="history-calendar__month-head"><strong>${escapeHtml(monthNamesLong.format(first))}</strong><span>${monthCounts[month]} revisions</span></div><div class="history-calendar__weekdays"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div><div class="history-calendar__days">${blanks}${days}</div></section>`;
}).join("");
const selected = historyArchiveDay && byDay.get(historyArchiveDay);
const selectedHtml = selected?.length
? `<section class="history-archive__selected"><div class="history-archive__selected-head"><strong>${escapeHtml(historyArchiveDay)}</strong><span>${selected.length} saved version${selected.length === 1 ? "" : "s"}</span></div>${selected.map(revision => historyRevisionCard(revision, true)).join("")}</section>`
: '<p class="history-archive__hint">Select a highlighted day to inspect saved versions.</p>';
historyList.innerHTML = `<div class="history-archive"><div class="history-archive__years" aria-label="Archive year">${yearButtons}</div><div class="history-archive__histogram" aria-label="Revision activity by month">${histogram}</div><div class="history-calendar">${calendars}</div>${selectedHtml}</div>`;
}
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 = '<p class="empty">Loading…</p>';
try {
historyRevisions = await adapter.loadHistory(accessToken);
historyArchiveYear = null;
historyArchiveDay = null;
renderHistory();
} catch (e) {
historyList.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`;
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();
+43 -3
View File
@@ -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; }
});
+9
View File
@@ -42,6 +42,15 @@
required><button class="secondary-button" type="submit">Set</button></div>
<small id="workspace-password-error" class="form-message error" role="alert"></small>
</form>
<form id="workspace-settings-form" class="workspace-settings-card" hidden>
<div class="workspace-settings-card__copy">
<strong>Deleted notes</strong>
<small>Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small>
</div>
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> Archive deleted notes instead of permanently deleting them</label>
<button class="secondary-button" type="submit">Save</button>
<small id="workspace-settings-status" class="form-message" role="status"></small>
</form>
<div class="notes-toolbar">
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search"
placeholder="Search notes…" autocomplete="off"></label>