aggregate data
This commit is contained in:
+21
-1
@@ -135,6 +135,10 @@ pub struct NoteListItem {
|
|||||||
url: String,
|
url: String,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
|
participant_count: i64,
|
||||||
|
file_count: i64,
|
||||||
|
file_size_bytes: i64,
|
||||||
|
revision_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -224,10 +228,17 @@ pub async fn open_workspace(
|
|||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let stats = db::list_note_stats(&state.db, workspace.id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|stats| (stats.note_id, stats))
|
||||||
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
let notes = db::list_notes(&state.db, workspace.id)
|
let notes = db::list_notes(&state.db, workspace.id)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|note| NoteListItem {
|
.map(|note| {
|
||||||
|
let stats = stats.get(¬e.id);
|
||||||
|
NoteListItem {
|
||||||
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
||||||
slug: note.slug,
|
slug: note.slug,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
@@ -235,6 +246,11 @@ pub async fn open_workspace(
|
|||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
created_by: note.created_by,
|
created_by: note.created_by,
|
||||||
|
participant_count: stats.map_or(0, |value| value.participant_count),
|
||||||
|
file_count: stats.map_or(0, |value| value.file_count),
|
||||||
|
file_size_bytes: stats.map_or(0, |value| value.file_size_bytes),
|
||||||
|
revision_count: stats.map_or(0, |value| value.revision_count),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -307,6 +323,10 @@ pub async fn create_note(
|
|||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
created_by: note.created_by,
|
created_by: note.created_by,
|
||||||
|
participant_count: 0,
|
||||||
|
file_count: 0,
|
||||||
|
file_size_bytes: 0,
|
||||||
|
revision_count: 0,
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ pub struct Revision {
|
|||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NoteStats {
|
||||||
|
pub note_id: i64,
|
||||||
|
pub participant_count: i64,
|
||||||
|
pub file_count: i64,
|
||||||
|
pub file_size_bytes: i64,
|
||||||
|
pub revision_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_workspace(pool: &Database, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
pub async fn find_workspace(pool: &Database, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
|
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
@@ -164,6 +173,45 @@ pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>,
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_note_stats(
|
||||||
|
pool: &Database,
|
||||||
|
workspace_id: i64,
|
||||||
|
) -> Result<Vec<NoteStats>, sqlx::Error> {
|
||||||
|
let placeholder = match pool.kind() {
|
||||||
|
DatabaseKind::Postgres => "$1",
|
||||||
|
DatabaseKind::Sqlite | DatabaseKind::MySql => "?",
|
||||||
|
};
|
||||||
|
let query = format!(
|
||||||
|
"SELECT n.id AS note_id, \
|
||||||
|
((SELECT COUNT(DISTINCT r.author) FROM note_revisions r \
|
||||||
|
WHERE r.note_id = n.id AND r.author IS NOT NULL AND TRIM(r.author) <> '') + \
|
||||||
|
CASE WHEN n.created_by IS NOT NULL AND TRIM(n.created_by) <> '' AND NOT EXISTS (\
|
||||||
|
SELECT 1 FROM note_revisions r WHERE r.note_id = n.id AND r.author = n.created_by\
|
||||||
|
) THEN 1 ELSE 0 END) AS participant_count, \
|
||||||
|
(SELECT COUNT(*) FROM note_files f WHERE f.note_id = n.id) AS file_count, \
|
||||||
|
(SELECT COALESCE(SUM(f.size_bytes), 0) 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}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query(&query)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_all(pool.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
Ok(NoteStats {
|
||||||
|
note_id: row.try_get("note_id")?,
|
||||||
|
participant_count: row.try_get("participant_count")?,
|
||||||
|
file_count: row.try_get("file_count")?,
|
||||||
|
file_size_bytes: row.try_get("file_size_bytes")?,
|
||||||
|
revision_count: row.try_get("revision_count")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_note(
|
pub async fn find_note(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
|
|||||||
@@ -1998,7 +1998,7 @@ dialog::backdrop {
|
|||||||
|
|
||||||
.notes-table table {
|
.notes-table table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 680px;
|
min-width: 980px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -25,6 +25,18 @@ let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid
|
|||||||
let notesCache = [];
|
let notesCache = [];
|
||||||
|
|
||||||
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
||||||
|
function formatBytes(value) {
|
||||||
|
const bytes = Math.max(0, Number(value) || 0);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let amount = bytes;
|
||||||
|
let unit = -1;
|
||||||
|
do { amount /= 1024; unit++; } while (amount >= 1024 && unit < units.length - 1);
|
||||||
|
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
function noteStats(note) {
|
||||||
|
return `<span>Participants: ${Number(note.participant_count) || 0}</span><span>Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})</span><span>Revisions: ${Number(note.revision_count) || 0}</span>`;
|
||||||
|
}
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (value == null || value === "") return "—";
|
if (value == null || value === "") return "—";
|
||||||
let raw = String(value).trim();
|
let raw = String(value).trim();
|
||||||
@@ -58,10 +70,13 @@ function renderNotes(notes = notesCache) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (notesView === "table") {
|
if (notesView === "table") {
|
||||||
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
|
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
|
||||||
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
||||||
|
<td>${Number(note.participant_count) || 0}</td>
|
||||||
|
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
|
||||||
|
<td>${Number(note.revision_count) || 0}</td>
|
||||||
<td>${note.protected ? '<span class="protect-badge">Protected</span>' : '<span class="note-status">Unprotected</span>'}</td>
|
<td>${note.protected ? '<span class="protect-badge">Protected</span>' : '<span class="note-status">Unprotected</span>'}</td>
|
||||||
<td>${formatDate(note.updated_at)}</td>
|
<td>${formatDate(note.updated_at)}</td>
|
||||||
<td class="notes-table-actions">${deleteButton(note, true)}</td>
|
<td class="notes-table-actions">${deleteButton(note, true)}</td>
|
||||||
@@ -72,7 +87,7 @@ function renderNotes(notes = notesCache) {
|
|||||||
<article class="note-card-wrap">
|
<article class="note-card-wrap">
|
||||||
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
|
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
|
||||||
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
||||||
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span><span>Updated: ${formatDate(note.updated_at)}</span></div>
|
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
|
||||||
</a>
|
</a>
|
||||||
${deleteButton(note)}
|
${deleteButton(note)}
|
||||||
</article>`).join("");
|
</article>`).join("");
|
||||||
|
|||||||
Reference in New Issue
Block a user