frontend: Add basic information about collections

This commit is contained in:
Lennart K
2025-07-04 20:54:37 +02:00
parent 32f5c01716
commit 6bcc03d659
13 changed files with 219 additions and 22 deletions

View File

@@ -202,14 +202,16 @@ ul.collection-list {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%); background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%);
display: grid; display: grid;
min-height: 80px; min-height: 80px;
height: fit-content;
grid-template-areas: grid-template-areas:
". . color-chip" ". . color-chip"
"title comps color-chip" "title comps color-chip"
"description description color-chip" "description description color-chip"
"subscription-url subscription-url color-chip" "subscription-url subscription-url color-chip"
"metadata metadata color-chip"
"actions actions color-chip" "actions actions color-chip"
". . color-chip"; ". . color-chip";
grid-template-rows: 12px auto auto auto auto 12px; grid-template-rows: 12px auto auto auto auto auto 12px;
grid-template-columns: min-content auto 80px; grid-template-columns: min-content auto 80px;
row-gap: 4px; row-gap: 4px;
color: inherit; color: inherit;
@@ -251,6 +253,11 @@ ul.collection-list {
white-space: nowrap; white-space: nowrap;
} }
.metadata {
grid-area: metadata;
white-space: nowrap;
}
.subscription-url { .subscription-url {
grid-area: subscription-url; grid-area: subscription-url;
white-space: nowrap; white-space: nowrap;

View File

@@ -1,6 +1,6 @@
<h2>{{user.id }}'s Addressbooks</h2> <h2>{{user.id }}'s Addressbooks</h2>
<ul class="collection-list"> <ul class="collection-list">
{% for addressbook in addressbooks %} {% for (meta, addressbook) in addressbooks %}
<li class="collection-list-item"> <li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"> <a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title"> <span class="title">
@@ -18,6 +18,9 @@
<delete-button trash <delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button> href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div> </div>
<div class="metadata">
{{ meta.len }} objects, {{ meta.deleted_len }} deleted objects
</div>
</a> </a>
</li> </li>
{% else %} {% else %}
@@ -27,7 +30,7 @@
{%if !deleted_addressbooks.is_empty() %} {%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3> <h3>Deleted Addressbooks</h3>
<ul class="collection-list"> <ul class="collection-list">
{% for addressbook in deleted_addressbooks %} {% for (meta, addressbook) in deleted_addressbooks %}
<li class="collection-list-item"> <li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"> <a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title"> <span class="title">
@@ -44,6 +47,9 @@
</form> </form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button> <delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div> </div>
<div class="metadata">
{{ meta.len }} objects, {{ meta.deleted_len }} deleted objects
</div>
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View File

@@ -1,6 +1,6 @@
<h2>{{ user.id }}'s Calendars</h2> <h2>{{ user.id }}'s Calendars</h2>
<ul class="collection-list"> <ul class="collection-list">
{% for calendar in calendars %} {% for (meta, calendar) in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %} {% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}"> <li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}"> <a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
@@ -27,6 +27,9 @@
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button> <delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %} {% endif %}
</div> </div>
<div class="metadata">
{{ meta.len }} objects, {{ meta.deleted_len }} deleted objects
</div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </a>
</li> </li>
@@ -37,7 +40,7 @@
{%if !deleted_calendars.is_empty() %} {%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3> <h3>Deleted Calendars</h3>
<ul class="collection-list"> <ul class="collection-list">
{% for calendar in deleted_calendars %} {% for (meta, calendar) in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %} {% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}"> <li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}"> <a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
@@ -60,6 +63,9 @@
</form> </form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button> <delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div> </div>
<div class="metadata">
{{ meta.len }} objects, {{ meta.deleted_len }} deleted objects
</div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </a>
</li> </li>

View File

@@ -4,7 +4,7 @@ use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse}; use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode; use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, auth::Principal}; use rustical_store::{Addressbook, AddressbookStore, CollectionMetadata, auth::Principal};
use crate::pages::user::{Section, UserPage}; use crate::pages::user::{Section, UserPage};
@@ -18,8 +18,8 @@ impl Section for AddressbooksSection {
#[template(path = "components/sections/addressbooks_section.html")] #[template(path = "components/sections/addressbooks_section.html")]
pub struct AddressbooksSection { pub struct AddressbooksSection {
pub user: Principal, pub user: Principal,
pub addressbooks: Vec<Addressbook>, pub addressbooks: Vec<(CollectionMetadata, Addressbook)>,
pub deleted_addressbooks: Vec<Addressbook>, pub deleted_addressbooks: Vec<(CollectionMetadata, Addressbook)>,
} }
pub async fn route_addressbooks<AS: AddressbookStore>( pub async fn route_addressbooks<AS: AddressbookStore>(
@@ -41,11 +41,33 @@ pub async fn route_addressbooks<AS: AddressbookStore>(
deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap()); deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
} }
let mut addressbook_infos = vec![];
for addressbook in addressbooks {
addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
let mut deleted_addressbook_infos = vec![];
for addressbook in deleted_addressbooks {
deleted_addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
UserPage { UserPage {
section: AddressbooksSection { section: AddressbooksSection {
user: user.clone(), user: user.clone(),
addressbooks, addressbooks: addressbook_infos,
deleted_addressbooks, deleted_addressbooks: deleted_addressbook_infos,
}, },
user, user,
} }

View File

@@ -5,7 +5,7 @@ use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse}; use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode; use http::StatusCode;
use rustical_store::{Calendar, CalendarStore, auth::Principal}; use rustical_store::{Calendar, CalendarStore, CollectionMetadata, auth::Principal};
impl Section for CalendarsSection { impl Section for CalendarsSection {
fn name() -> &'static str { fn name() -> &'static str {
@@ -17,8 +17,8 @@ impl Section for CalendarsSection {
#[template(path = "components/sections/calendars_section.html")] #[template(path = "components/sections/calendars_section.html")]
pub struct CalendarsSection { pub struct CalendarsSection {
pub user: Principal, pub user: Principal,
pub calendars: Vec<Calendar>, pub calendars: Vec<(CollectionMetadata, Calendar)>,
pub deleted_calendars: Vec<Calendar>, pub deleted_calendars: Vec<(CollectionMetadata, Calendar)>,
} }
pub async fn route_calendars<CS: CalendarStore>( pub async fn route_calendars<CS: CalendarStore>(
@@ -35,16 +35,38 @@ pub async fn route_calendars<CS: CalendarStore>(
calendars.extend(cal_store.get_calendars(group).await.unwrap()); calendars.extend(cal_store.get_calendars(group).await.unwrap());
} }
let mut calendar_infos = vec![];
for calendar in calendars {
calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
let mut deleted_calendars = vec![]; let mut deleted_calendars = vec![];
for group in user.memberships() { for group in user.memberships() {
deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap()); deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
} }
let mut deleted_calendar_infos = vec![];
for calendar in deleted_calendars {
deleted_calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
UserPage { UserPage {
section: CalendarsSection { section: CalendarsSection {
user: user.clone(), user: user.clone(),
calendars, calendars: calendar_infos,
deleted_calendars, deleted_calendars: deleted_calendar_infos,
}, },
user, user,
} }

View File

@@ -1,4 +1,4 @@
use crate::{Error, addressbook::Addressbook}; use crate::{CollectionMetadata, Error, addressbook::Addressbook};
use async_trait::async_trait; use async_trait::async_trait;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
@@ -35,6 +35,12 @@ pub trait AddressbookStore: Send + Sync + 'static {
synctoken: i64, synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>; ) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -1,4 +1,4 @@
use crate::{Calendar, error::Error}; use crate::{Calendar, CollectionMetadata, error::Error};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::NaiveDate; use chrono::NaiveDate;
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
@@ -53,6 +53,12 @@ pub trait CalendarStore: Send + Sync + 'static {
self.get_objects(principal, cal_id).await self.get_objects(principal, cal_id).await
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -135,6 +135,20 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
} }
} }
#[inline]
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.calendar_metadata(principal, cal_id)
.await
} else {
self.cal_store.calendar_metadata(principal, cal_id).await
}
}
#[inline] #[inline]
async fn get_objects( async fn get_objects(
&self, &self,

View File

@@ -16,7 +16,7 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id), id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id),
displayname: addressbook displayname: addressbook
.displayname .displayname
.map(|name| format!("{} birthdays", name)), .map(|name| format!("{name} birthdays")),
order: 0, order: 0,
description: None, description: None,
color: None, color: None,
@@ -104,6 +104,17 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
Ok((objects, deleted_objects, new_synctoken)) Ok((objects, deleted_objects, new_synctoken))
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
self.0.addressbook_metadata(principal, cal_id).await
}
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -37,3 +37,11 @@ pub struct CollectionOperation {
pub topic: String, pub topic: String,
pub data: CollectionOperationInfo, pub data: CollectionOperationInfo,
} }
#[derive(Default, Debug, Clone)]
pub struct CollectionMetadata {
pub len: usize,
pub deleted_len: usize,
pub size: u64,
pub deleted_size: u64,
}

View File

@@ -1,7 +1,7 @@
const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/"; const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/";
pub fn format_synctoken(synctoken: i64) -> String { pub fn format_synctoken(synctoken: i64) -> String {
format!("{}{}", SYNC_NAMESPACE, synctoken) format!("{SYNC_NAMESPACE}{synctoken}")
} }
pub fn parse_synctoken(synctoken: &str) -> Option<i64> { pub fn parse_synctoken(synctoken: &str) -> Option<i64> {

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationInfo, Error, Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation,
synctoken::format_synctoken, CollectionOperationInfo, Error, synctoken::format_synctoken,
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
@@ -223,6 +223,28 @@ impl SqliteAddressbookStore {
Ok((objects, deleted_objects, new_synctoken)) Ok((objects, deleted_objects, new_synctoken))
} }
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
principal,
addressbook_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E, executor: E,
principal: &str, principal: &str,
@@ -442,6 +464,29 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
} }
#[instrument]
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, rustical_store::Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, addressbook_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument] #[instrument]
async fn get_objects( async fn get_objects(
&self, &self,

View File

@@ -5,7 +5,7 @@ use derive_more::derive::Constructor;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType}; use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery; use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken; use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarStore, Error}; use rustical_store::{Calendar, CalendarStore, CollectionMetadata, Error};
use rustical_store::{CollectionOperation, CollectionOperationInfo}; use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
@@ -242,6 +242,28 @@ impl SqliteCalendarStore {
Ok(()) Ok(())
} }
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
principal,
cal_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E, executor: E,
principal: &str, principal: &str,
@@ -552,6 +574,28 @@ impl CalendarStore for SqliteCalendarStore {
Self::_calendar_query(&self.db, principal, cal_id, query).await Self::_calendar_query(&self.db, principal, cal_id, query).await
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, cal_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument] #[instrument]
async fn get_objects( async fn get_objects(
&self, &self,