v0.2.84
This commit is contained in:
1 parent
6329ac33d1
commit
90e3c399ea
22 files changed
+729
-46
No files matched your search
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.83"
|
version = "0.2.84"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.83"
|
version = "0.2.84"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
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
@@ -405,6 +405,11 @@ pub async fn delete_note(
|
|||||||
"This note is protected. Only its owner can delete it.",
|
"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? {
|
for file in db::list_note_files(&state.db, note.id).await? {
|
||||||
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
|
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
|
||||||
.await
|
.await
|
||||||
@@ -417,7 +422,7 @@ pub async fn delete_note(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
db::delete_note(&state.db, note.id).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(
|
pub async fn note_files(
|
||||||
|
|||||||
+80
-9
@@ -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(
|
async fn has_write_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -354,6 +370,11 @@ pub struct SetWorkspacePasswordRequest {
|
|||||||
client_id: Option<String>,
|
client_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetWorkspaceSettingsRequest {
|
||||||
|
archive_deleted_notes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RestoreRequest {
|
pub struct RestoreRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -370,6 +391,8 @@ pub struct WorkspaceInfo {
|
|||||||
protected: bool,
|
protected: bool,
|
||||||
access_level: String,
|
access_level: String,
|
||||||
can_set_password: bool,
|
can_set_password: bool,
|
||||||
|
can_manage_settings: bool,
|
||||||
|
archive_deleted_notes: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -442,6 +465,7 @@ pub struct NoteInfo {
|
|||||||
public_page_unprotected: bool,
|
public_page_unprotected: bool,
|
||||||
public_page_enabled: bool,
|
public_page_enabled: bool,
|
||||||
private: bool,
|
private: bool,
|
||||||
|
archive_deleted_notes: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete: bool,
|
can_delete: bool,
|
||||||
@@ -714,10 +738,53 @@ pub async fn workspace_info(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).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(
|
Ok(Json(workspace_info_from(
|
||||||
&workspace,
|
&workspace,
|
||||||
access_level,
|
access_level,
|
||||||
can_set_password,
|
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 notes = notes.into_iter().skip(start).take(per_page).collect();
|
||||||
|
|
||||||
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).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(WorkspaceOpenResponse {
|
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,
|
notes,
|
||||||
pagination: ListPaginationMeta {
|
pagination: ListPaginationMeta {
|
||||||
page,
|
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_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
|
||||||
public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?,
|
public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?,
|
||||||
private: workspace.is_private != 0,
|
private: workspace.is_private != 0,
|
||||||
|
archive_deleted_notes: workspace.archive_deleted_notes != 0,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
can_delete: can_delete_files,
|
can_delete: can_delete_files,
|
||||||
@@ -1628,6 +1702,7 @@ fn workspace_info_from(
|
|||||||
workspace: &db::Workspace,
|
workspace: &db::Workspace,
|
||||||
access_level: AccessLevel,
|
access_level: AccessLevel,
|
||||||
can_set_password: bool,
|
can_set_password: bool,
|
||||||
|
can_manage_settings: bool,
|
||||||
) -> WorkspaceInfo {
|
) -> WorkspaceInfo {
|
||||||
WorkspaceInfo {
|
WorkspaceInfo {
|
||||||
slug: workspace.slug.clone(),
|
slug: workspace.slug.clone(),
|
||||||
@@ -1635,6 +1710,8 @@ fn workspace_info_from(
|
|||||||
protected: workspace.password_hash.is_some(),
|
protected: workspace.password_hash.is_some(),
|
||||||
access_level: access_level_name(access_level).into(),
|
access_level: access_level_name(access_level).into(),
|
||||||
can_set_password,
|
can_set_password,
|
||||||
|
can_manage_settings,
|
||||||
|
archive_deleted_notes: workspace.archive_deleted_notes != 0,
|
||||||
created_at: db::normalize_timestamp(&workspace.created_at),
|
created_at: db::normalize_timestamp(&workspace.created_at),
|
||||||
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
||||||
}
|
}
|
||||||
@@ -1701,18 +1778,12 @@ async fn unique_note_slug(
|
|||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
base: &str,
|
base: &str,
|
||||||
) -> Result<String, ApiError> {
|
) -> Result<String, ApiError> {
|
||||||
if db::find_note(&state.db, workspace_id, base)
|
if !db::note_slug_exists_any(&state.db, workspace_id, base).await? {
|
||||||
.await?
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Ok(base.to_owned());
|
return Ok(base.to_owned());
|
||||||
}
|
}
|
||||||
for _ in 0..8 {
|
for _ in 0..8 {
|
||||||
let candidate = format!("{base}-{}", db::random_suffix(6));
|
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||||
if db::find_note(&state.db, workspace_id, &candidate)
|
if !db::note_slug_exists_any(&state.db, workspace_id, &candidate).await? {
|
||||||
.await?
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Ok(candidate);
|
return Ok(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ pub fn router(
|
|||||||
"/api/workspaces/{workspace_slug}/password",
|
"/api/workspaces/{workspace_slug}/password",
|
||||||
post(api::set_workspace_password),
|
post(api::set_workspace_password),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/settings",
|
||||||
|
post(api::set_workspace_settings),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces/{workspace_slug}/open",
|
"/api/workspaces/{workspace_slug}/open",
|
||||||
post(api::open_workspace),
|
post(api::open_workspace),
|
||||||
|
|||||||
+43
-1
@@ -52,6 +52,7 @@ pub struct Workspace {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub is_private: i64,
|
pub is_private: i64,
|
||||||
|
pub archive_deleted_notes: i64,
|
||||||
pub created_by_guest_id: Option<String>,
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +180,22 @@ pub async fn set_workspace_password(
|
|||||||
Ok(())
|
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 {
|
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
||||||
match (
|
match (
|
||||||
&workspace.password_hash,
|
&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 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 {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 \
|
(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)
|
let rows = sqlx::query(&query)
|
||||||
@@ -284,6 +301,30 @@ pub async fn find_note(
|
|||||||
.await
|
.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(
|
pub async fn create_note(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
@@ -665,6 +706,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
|||||||
created_at: crate::row_decode::text(row, "created_at")?,
|
created_at: crate::row_decode::text(row, "created_at")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
is_private: row.try_get("is_private")?,
|
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")?,
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ pub enum Query {
|
|||||||
USER_SET_PAD_PASSWORD,
|
USER_SET_PAD_PASSWORD,
|
||||||
USER_SET_WORKSPACE_PRIVACY,
|
USER_SET_WORKSPACE_PRIVACY,
|
||||||
USER_SET_PAD_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_RESOURCE,
|
||||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||||
RESOURCE_ACCESS_TOKENS_INSERT,
|
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_PAD_PASSWORD: Query = Query::USER_SET_PAD_PASSWORD;
|
||||||
pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY;
|
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 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 =
|
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
|
||||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE;
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE;
|
||||||
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query =
|
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query =
|
||||||
|
|||||||
+16
-7
@@ -200,10 +200,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::USER_FAVORITE_NOTE_LIST => {
|
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 => {
|
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 => {
|
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
|
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 => {
|
Query::USER_SET_PAD_PRIVACY => {
|
||||||
r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#
|
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 => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
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 = ?"#
|
r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
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 => {
|
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 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
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::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
||||||
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
||||||
Query::Q021 => {
|
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::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::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::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::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::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 => {
|
Query::Q028 => {
|
||||||
r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM note_revisions WHERE id = ? AND note_id = ?"#
|
r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM note_revisions WHERE id = ? AND note_id = ?"#
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -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"#
|
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"#
|
||||||
}
|
}
|
||||||
Query::USER_FAVORITE_NOTE_LIST => {
|
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 => {
|
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 => {
|
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_pad_favorites WHERE user_id = $1"#
|
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 => {
|
Query::USER_SET_PAD_PRIVACY => {
|
||||||
r#"UPDATE pads SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"#
|
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 => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"#
|
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"#
|
r#"SELECT public_page_disabled FROM notes WHERE id = $1"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#,
|
||||||
Query::Q003 => {
|
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 => {
|
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 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES ($1, $2, $3, $4, $5, $6)"#
|
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::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::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#,
|
||||||
Query::Q021 => {
|
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::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::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::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::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::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::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::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"#,
|
Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = $1 AND pad_id = $2"#,
|
||||||
|
|||||||
+16
-7
@@ -200,10 +200,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::USER_FAVORITE_NOTE_LIST => {
|
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 => {
|
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 => {
|
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
|
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 => {
|
Query::USER_SET_PAD_PRIVACY => {
|
||||||
r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#
|
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 => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
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 = ?"#
|
r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
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 => {
|
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 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
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::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
||||||
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
||||||
Query::Q021 => {
|
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::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::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::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::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::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::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::Q029 => r#"SELECT content FROM revisions WHERE id = ? AND pad_id = ?"#,
|
||||||
Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"#,
|
Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"#,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ fn workspace(password_hash: Option<String>) -> Workspace {
|
|||||||
created_at: String::new(),
|
created_at: String::new(),
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
is_private: 1,
|
is_private: 1,
|
||||||
|
archive_deleted_notes: 0,
|
||||||
created_by_guest_id: None,
|
created_by_guest_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ fn every_backend_has_explicit_queries() {
|
|||||||
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
|
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
|
||||||
USER_FAVORITE_PADS_DELETE_BY_USER,
|
USER_FAVORITE_PADS_DELETE_BY_USER,
|
||||||
USER_FAVORITE_NOTES_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,
|
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||||
SHARE_LINK_SESSION_SOURCE,
|
SHARE_LINK_SESSION_SOURCE,
|
||||||
SHARE_SESSION_INSERT,
|
SHARE_SESSION_INSERT,
|
||||||
|
|||||||
+273
-2
@@ -1115,12 +1115,16 @@ textarea::selection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body blockquote {
|
.markdown-body blockquote {
|
||||||
|
margin: .35em 0;
|
||||||
padding: .2em 1em;
|
padding: .2em 1em;
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-quote-line {
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body a {
|
.markdown-body a {
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
@@ -1153,7 +1157,7 @@ textarea::selection {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 24;
|
z-index: 24;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
width: 340px;
|
width: 340px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1184,6 +1188,33 @@ textarea::selection {
|
|||||||
padding: 18px;
|
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 {
|
.icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1203,6 +1234,190 @@ textarea::selection {
|
|||||||
padding: 10px 18px 24px;
|
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 {
|
.revision {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3367,6 +3582,62 @@ dialog::backdrop {
|
|||||||
display: none;
|
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 {
|
.notes-view-switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
|||||||
@@ -272,6 +272,10 @@
|
|||||||
<p>Author, time, and version preview</p>
|
<p>Author, time, and version preview</p>
|
||||||
</div><button id="close-history" class="icon-button">×</button>
|
</div><button id="close-history" class="icon-button">×</button>
|
||||||
</div>
|
</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>
|
<div id="history-list" class="history-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+16
-1
@@ -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 heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
|
||||||
const listItem = listLine(line);
|
const listItem = listLine(line);
|
||||||
|
const quote = line.match(/^>\s?(.*)$/);
|
||||||
|
|
||||||
if (listItem) {
|
if (listItem) {
|
||||||
const rendered = renderList(lines, index, lineOffset, listItem.indent, listItem.type);
|
const rendered = renderList(lines, index, lineOffset, listItem.indent, listItem.type);
|
||||||
html += rendered.html;
|
html += rendered.html;
|
||||||
index = rendered.end - 1;
|
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) {
|
} else if (heading) {
|
||||||
closeList();
|
closeList();
|
||||||
const n = heading[1].length;
|
const n = heading[1].length;
|
||||||
@@ -555,7 +571,6 @@ export function renderMarkdown(source, lineOffset = 0) {
|
|||||||
html += `</dl>`;
|
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 (/^\[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 (/^---+$/.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 if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
||||||
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,18 +112,22 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
: "Delete note";
|
: "Delete note";
|
||||||
},
|
},
|
||||||
async deleteNote(info, accessToken) {
|
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",
|
title: "Delete note",
|
||||||
confirmText: "Delete",
|
confirmText: archive ? "Archive" : "Delete",
|
||||||
danger: true,
|
danger: true,
|
||||||
})) return;
|
})) return;
|
||||||
await api(base, {
|
const result = await api(base, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({ access_token: accessToken || null }),
|
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",
|
type: "success",
|
||||||
title: "Note deleted",
|
title: result.archived ? "Note archived" : "Note deleted",
|
||||||
});
|
});
|
||||||
location.assign(`/w/${encode(workspaceSlug)}`);
|
location.assign(`/w/${encode(workspaceSlug)}`);
|
||||||
},
|
},
|
||||||
|
|||||||
+173
-1
@@ -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" }); } });
|
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" }); } });
|
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();
|
initialize();
|
||||||
|
|||||||
+43
-3
@@ -45,6 +45,9 @@ const notesPagination = document.querySelector("#notes-pagination");
|
|||||||
const workspacePasswordForm = document.querySelector("#workspace-password-form");
|
const workspacePasswordForm = document.querySelector("#workspace-password-form");
|
||||||
const workspacePasswordInput = document.querySelector("#workspace-set-password");
|
const workspacePasswordInput = document.querySelector("#workspace-set-password");
|
||||||
const workspacePasswordError = document.querySelector("#workspace-password-error");
|
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 notesPage = 1;
|
||||||
let notesSearchTimer;
|
let notesSearchTimer;
|
||||||
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
||||||
@@ -55,6 +58,11 @@ function updateWorkspacePasswordControl() {
|
|||||||
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
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() {
|
function stopWorkspaceWatch() {
|
||||||
clearInterval(workspaceWatchPingTimer);
|
clearInterval(workspaceWatchPingTimer);
|
||||||
clearTimeout(workspaceWatchReconnectTimer);
|
clearTimeout(workspaceWatchReconnectTimer);
|
||||||
@@ -293,6 +301,7 @@ async function openWorkspace() {
|
|||||||
renderNotes();
|
renderNotes();
|
||||||
renderNotesPagination(data.pagination);
|
renderNotesPagination(data.pagination);
|
||||||
updateWorkspacePasswordControl();
|
updateWorkspacePasswordControl();
|
||||||
|
updateWorkspaceSettingsControl();
|
||||||
workspaceLockedForPassword = false;
|
workspaceLockedForPassword = false;
|
||||||
workspaceContent.hidden = false;
|
workspaceContent.hidden = false;
|
||||||
if (dialog.open) dialog.close();
|
if (dialog.open) dialog.close();
|
||||||
@@ -313,6 +322,7 @@ async function init() {
|
|||||||
document.querySelector("#workspace-title").textContent = info.title;
|
document.querySelector("#workspace-title").textContent = info.title;
|
||||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||||
updateWorkspacePasswordControl();
|
updateWorkspacePasswordControl();
|
||||||
|
updateWorkspaceSettingsControl();
|
||||||
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
|
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
|
||||||
else await openWorkspace();
|
else await openWorkspace();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -364,6 +374,29 @@ workspacePasswordForm.addEventListener("submit", async event => {
|
|||||||
submit.disabled = false;
|
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("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
||||||
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
||||||
document.querySelector("#note-form").addEventListener("submit", async e => {
|
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]");
|
const button = event.target.closest("[data-delete-note]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const title = button.dataset.noteTitle;
|
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;
|
button.disabled = true;
|
||||||
try {
|
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 })
|
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();
|
await openWorkspace();
|
||||||
} catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; }
|
} catch (error) { toast.danger(error.message, { title: "Could not delete note" }); button.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,15 @@
|
|||||||
required><button class="secondary-button" type="submit">Set</button></div>
|
required><button class="secondary-button" type="submit">Set</button></div>
|
||||||
<small id="workspace-password-error" class="form-message error" role="alert"></small>
|
<small id="workspace-password-error" class="form-message error" role="alert"></small>
|
||||||
</form>
|
</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">
|
<div class="notes-toolbar">
|
||||||
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search"
|
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search"
|
||||||
placeholder="Search notes…" autocomplete="off"></label>
|
placeholder="Search notes…" autocomplete="off"></label>
|
||||||
|
|||||||
Reference in new issue
Block a user