mirror of
https://github.com/vee1e/meeticulous.git
synced 2026-09-01 10:18:39 +00:00
feat(tui): rename meeting titles via $EDITOR/nvim (single + bulk)
Adds title rename using the external editor, inspired by the bulk_mv script (dump names -> edit -> validate -> apply): - Meetings list: `e` renames the selected meeting, `E` bulk-renames all titles (one per line) in $EDITOR/$VISUAL, defaulting to nvim - Transcript/Summary views: `e` renames the currently open meeting - Terminal is suspended (raw mode + alternate screen) for the editor session via a RearmGuard, then restored on return/error/panic - Bulk rename is all-or-nothing: rejects line-count changes, empty titles, duplicates within the batch, and collisions with existing titles; reports per-line errors before applying anything - db: rename_meetings() applies a batch of title updates in one transaction - tests: plan_bulk_renames validation + db batch rename roundtrip
This commit is contained in:
parent
10165451ef
commit
71f7d64a44
4 changed files with 336 additions and 7 deletions
|
|
@ -78,6 +78,7 @@ meeticulous --paths # print resolved roots
|
|||
### Meetings
|
||||
- List / open / delete (confirm with `y`)
|
||||
- Transcript view with scroll + timestamps
|
||||
- Rename a title with `e`, or bulk-rename all titles with `E` — both open `$EDITOR` (nvim by default), one title per line
|
||||
- Summaries stored in Meetily’s `summary_processes` table
|
||||
|
||||
### Summarization (CLI only)
|
||||
|
|
@ -98,6 +99,8 @@ meeticulous --paths # print resolved roots
|
|||
| `r` | start recording |
|
||||
| `s` | regenerate summary (context screen) |
|
||||
| `v` | open saved summary |
|
||||
| `e` | rename meeting title (opens `$EDITOR`/nvim) |
|
||||
| `E` | bulk-rename all meeting titles in `$EDITOR`/nvim (one per line) |
|
||||
| `d` | delete (confirm `y`) |
|
||||
| `c` | copy summary plaintext (when viewing) |
|
||||
| `m` | models / backend |
|
||||
|
|
@ -113,6 +116,7 @@ meeticulous --paths # print resolved roots
|
|||
| `gg` / `G` | top / bottom |
|
||||
| `s` | regenerate summary |
|
||||
| `c` | copy summary body |
|
||||
| `e` | rename this meeting's title (opens `$EDITOR`/nvim) |
|
||||
| `t` | transcript (from summary) |
|
||||
| `h` / `Esc` | back |
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,25 @@ pub async fn update_meeting_title(
|
|||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Rename multiple meetings in one transaction: `(meeting_id, new_title)`.
|
||||
pub async fn rename_meetings(
|
||||
pool: &SqlitePool,
|
||||
renames: &[(String, String)],
|
||||
) -> Result<usize, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for (id, title) in renames {
|
||||
sqlx::query("UPDATE meetings SET title = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(title)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(renames.len())
|
||||
}
|
||||
|
||||
/// Touch updated_at.
|
||||
pub async fn touch_meeting(pool: &SqlitePool, meeting_id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
|
|
|||
|
|
@ -272,4 +272,32 @@ mod tests {
|
|||
assert_eq!(format_media_timestamp(3600.0), "01:00:00");
|
||||
assert_eq!(format_media_timestamp(3661.0), "01:01:01");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_rename_meetings_in_one_transaction() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app = tmp.path().join("com.meetily.ai");
|
||||
let rec = tmp.path().join("Movies").join("meetily-recordings");
|
||||
let paths = MeetilyPaths::with_dirs(app, rec);
|
||||
paths.ensure_dirs().unwrap();
|
||||
|
||||
let pool = open_database(&paths.db_path).await.unwrap();
|
||||
let a = create_meeting(&pool, "Old A", None).await.unwrap();
|
||||
let b = create_meeting(&pool, "Old B", None).await.unwrap();
|
||||
|
||||
let renames = vec![
|
||||
(a.clone(), "New A".to_string()),
|
||||
(b.clone(), "New B".to_string()),
|
||||
];
|
||||
let n = rename_meetings(&pool, &renames).await.unwrap();
|
||||
assert_eq!(n, 2);
|
||||
|
||||
let all = list_meetings(&pool).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
for m in all {
|
||||
let expected = if m.id == a { "New A" } else { "New B" };
|
||||
assert_eq!(m.title, expected);
|
||||
}
|
||||
cleanup(&pool).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
292
src/tui/mod.rs
292
src/tui/mod.rs
|
|
@ -25,6 +25,7 @@ use ratatui::text::{Line, Span};
|
|||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||
use ratatui::Terminal;
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
use std::io::stdout;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinHandle;
|
||||
|
|
@ -106,6 +107,8 @@ pub struct App {
|
|||
/// Meeting id pending delete confirmation.
|
||||
pending_delete_id: Option<String>,
|
||||
pending_delete_title: String,
|
||||
/// Meeting currently open in the Transcript / Summary view (for `e` rename).
|
||||
current_meeting_id: Option<String>,
|
||||
/// Text buffer for summary context / recording append (shared input).
|
||||
input_buf: String,
|
||||
/// Background summarize job.
|
||||
|
|
@ -152,7 +155,7 @@ impl App {
|
|||
summary_text: String::new(),
|
||||
summary_lines: Vec::new(),
|
||||
summary_meta: String::new(),
|
||||
status: "hjkl · Enter/l open (summary) · t transcript · s regen · c copy · d del · q"
|
||||
status: "hjkl · Enter/l open (summary) · t transcript · s regen · c copy · e rename · E bulk · d del · q"
|
||||
.into(),
|
||||
model_selection,
|
||||
models_list: Vec::new(),
|
||||
|
|
@ -163,6 +166,7 @@ impl App {
|
|||
summary_prefs,
|
||||
pending_delete_id: None,
|
||||
pending_delete_title: String::new(),
|
||||
current_meeting_id: None,
|
||||
input_buf: String::new(),
|
||||
summary_job: None,
|
||||
summary_spin: 0,
|
||||
|
|
@ -241,6 +245,7 @@ impl App {
|
|||
if let Some(m) = self.selected_meeting() {
|
||||
let id = m.id.clone();
|
||||
let title = m.title.clone();
|
||||
self.current_meeting_id = Some(id.clone());
|
||||
self.transcript_text = db::load_transcript_text(&self.pool, &id).await?;
|
||||
if self.transcript_text.trim().is_empty() {
|
||||
self.transcript_text = "(no transcript segments)".into();
|
||||
|
|
@ -249,7 +254,7 @@ impl App {
|
|||
self.pending_g = false;
|
||||
self.screen = Screen::Transcript;
|
||||
self.status = format!(
|
||||
"Transcript: {title} j/k scroll · gg/G · C-d/u · s sum · d del · h/Esc back"
|
||||
"Transcript: {title} j/k scroll · gg/G · C-d/u · s sum · e rename · d del · h/Esc back"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -617,11 +622,12 @@ impl App {
|
|||
async fn load_summary_for_meeting(&mut self, id: &str, title: &str) -> anyhow::Result<bool> {
|
||||
match db::load_summary_plain_text(&self.pool, id).await? {
|
||||
Some((body, status)) => {
|
||||
self.current_meeting_id = Some(id.to_string());
|
||||
self.set_summary_body(body, format!("saved in Meetily DB · status={status}"));
|
||||
self.pending_g = false;
|
||||
self.screen = Screen::Summary;
|
||||
self.status = format!(
|
||||
"Summary: {title} j/k scroll · s regenerate · c copy · t transcript · h back"
|
||||
"Summary: {title} j/k scroll · s regenerate · c copy · t transcript · e rename · h back"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
|
@ -785,6 +791,78 @@ impl App {
|
|||
);
|
||||
}
|
||||
|
||||
/// Rename the currently selected meeting's title via the external editor.
|
||||
async fn rename_selected_title(&mut self, term: &mut TermGuard) -> anyhow::Result<()> {
|
||||
let Some(m) = self.selected_meeting() else {
|
||||
self.status = "Select a meeting first".into();
|
||||
return Ok(());
|
||||
};
|
||||
self.rename_meeting_title(term, m.id.clone(), m.title.clone())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rename one meeting title via the external editor.
|
||||
async fn rename_meeting_title(
|
||||
&mut self,
|
||||
term: &mut TermGuard,
|
||||
id: String,
|
||||
old: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let edited = edit_text_external(term, &format!("{old}\n")).await?;
|
||||
let new = edited.trim().to_string();
|
||||
if new.is_empty() {
|
||||
self.status = "Rename aborted — title cannot be empty".into();
|
||||
return Ok(());
|
||||
}
|
||||
if new == old {
|
||||
self.status = "Title unchanged".into();
|
||||
return Ok(());
|
||||
}
|
||||
db::update_meeting_title(&self.pool, &id, &new).await?;
|
||||
self.refresh_meetings().await?;
|
||||
self.status = format!("Renamed “{old}” → “{new}”");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk-rename every meeting title via the external editor (one per line).
|
||||
async fn bulk_rename_titles(&mut self, term: &mut TermGuard) -> anyhow::Result<()> {
|
||||
if self.meetings.is_empty() {
|
||||
self.status = "No meetings to rename".into();
|
||||
return Ok(());
|
||||
}
|
||||
let orig: Vec<String> = self.meetings.iter().map(|m| m.title.clone()).collect();
|
||||
let initial: String = orig.iter().map(|t| format!("{t}\n")).collect();
|
||||
let edited = edit_text_external(term, &initial).await?;
|
||||
let mut lines: Vec<&str> = edited.split('\n').collect();
|
||||
if lines.last() == Some(&"") {
|
||||
lines.pop();
|
||||
}
|
||||
let plan = match plan_bulk_renames(&orig, &lines) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
self.status = format!("Bulk rename aborted — nothing applied: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if plan.is_empty() {
|
||||
self.status = "No titles changed".into();
|
||||
return Ok(());
|
||||
}
|
||||
let ids: Vec<(String, String)> = plan
|
||||
.iter()
|
||||
.map(|(i, t)| (self.meetings[*i].id.clone(), t.clone()))
|
||||
.collect();
|
||||
db::rename_meetings(&self.pool, &ids).await?;
|
||||
let changed = plan
|
||||
.iter()
|
||||
.map(|(i, t)| format!("“{}”→“{t}”", orig[*i]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.refresh_meetings().await?;
|
||||
self.status = format!("Renamed {} meeting(s): {changed}", plan.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_summary_job(&mut self) {
|
||||
if let Some(job) = self.summary_job.take() {
|
||||
job.abort();
|
||||
|
|
@ -805,6 +883,24 @@ impl TermGuard {
|
|||
let _ = stdout().execute(EnterAlternateScreen);
|
||||
TermGuard { active: true }
|
||||
}
|
||||
|
||||
/// Leave raw mode + alternate screen (e.g. to hand the terminal to an editor).
|
||||
fn suspend(&mut self) {
|
||||
if self.active {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = stdout().execute(LeaveAlternateScreen);
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enter raw mode + alternate screen after an external editor session.
|
||||
fn rearm(&mut self) {
|
||||
if !self.active {
|
||||
let _ = enable_raw_mode();
|
||||
let _ = stdout().execute(EnterAlternateScreen);
|
||||
self.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TermGuard {
|
||||
|
|
@ -818,7 +914,7 @@ impl Drop for TermGuard {
|
|||
|
||||
/// Run the interactive TUI (macOS terminal).
|
||||
pub async fn run_tui(paths: MeetilyPaths, pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let guard = TermGuard::arm();
|
||||
let mut guard = TermGuard::arm();
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
|
||||
let mut app = App::new(paths, pool).await?;
|
||||
|
|
@ -843,7 +939,7 @@ pub async fn run_tui(paths: MeetilyPaths, pool: SqlitePool) -> anyhow::Result<()
|
|||
|
||||
if event::poll(Duration::from_millis(poll_ms))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if let Err(e) = handle_key(&mut app, key).await {
|
||||
if let Err(e) = handle_key(&mut app, &mut guard, key).await {
|
||||
app.status = format!("error: {e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -867,7 +963,11 @@ pub async fn run_tui(paths: MeetilyPaths, pool: SqlitePool) -> anyhow::Result<()
|
|||
result
|
||||
}
|
||||
|
||||
async fn handle_key(app: &mut App, key: event::KeyEvent) -> anyhow::Result<()> {
|
||||
async fn handle_key(
|
||||
app: &mut App,
|
||||
term: &mut TermGuard,
|
||||
key: event::KeyEvent,
|
||||
) -> anyhow::Result<()> {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -1063,6 +1163,17 @@ async fn handle_key(app: &mut App, key: event::KeyEvent) -> anyhow::Result<()> {
|
|||
app.pending_g = false;
|
||||
app.open_transcript().await?;
|
||||
}
|
||||
(false, KeyCode::Char('e')) => {
|
||||
app.pending_g = false;
|
||||
if let Some(id) = app.current_meeting_id.clone() {
|
||||
match db::get_meeting(&app.pool, &id).await? {
|
||||
Some(m) => app.rename_meeting_title(term, id, m.title.clone()).await?,
|
||||
None => app.status = "Meeting not found".into(),
|
||||
}
|
||||
} else {
|
||||
app.status = "No meeting open".into();
|
||||
}
|
||||
}
|
||||
(false, KeyCode::Char('d')) | (_, KeyCode::Delete) => {
|
||||
app.pending_g = false;
|
||||
app.begin_delete_confirm();
|
||||
|
|
@ -1172,6 +1283,14 @@ async fn handle_key(app: &mut App, key: event::KeyEvent) -> anyhow::Result<()> {
|
|||
app.pending_g = false;
|
||||
app.view_cached_summary().await?;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
app.pending_g = false;
|
||||
app.rename_selected_title(term).await?;
|
||||
}
|
||||
KeyCode::Char('E') => {
|
||||
app.pending_g = false;
|
||||
app.bulk_rename_titles(term).await?;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if !ctrl => {
|
||||
app.pending_g = false;
|
||||
app.begin_delete_confirm();
|
||||
|
|
@ -1281,7 +1400,7 @@ fn draw_meetings(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
|
|||
};
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(
|
||||
"Meetings j/k · l/Enter summary · t transcript · r rec · s regen · c copy · d del · q",
|
||||
"Meetings j/k · l/Enter summary · t transcript · r rec · s regen · e rename · E bulk · d del · q",
|
||||
))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
|
|
@ -1681,3 +1800,162 @@ fn draw_delete_confirm(f: &mut ratatui::Frame, app: &App, area: Rect) {
|
|||
);
|
||||
f.render_widget(p, area);
|
||||
}
|
||||
|
||||
/// Re-enter the terminal after an external editor session, even on early
|
||||
/// return or panic.
|
||||
struct RearmGuard<'a>(&'a mut TermGuard);
|
||||
|
||||
impl Drop for RearmGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.rearm();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the editor to run: `$EDITOR` → `$VISUAL` → `nvim`/`vim`/`vi` on PATH.
|
||||
/// Returns `(program, extra args)` so a value like `EDITOR="code --wait"` works.
|
||||
fn editor_parts() -> Option<(String, Vec<String>)> {
|
||||
for var in ["EDITOR", "VISUAL"] {
|
||||
if let Ok(raw) = std::env::var(var) {
|
||||
let parts: Vec<String> = raw.split_whitespace().map(|s| s.to_string()).collect();
|
||||
if let Some(prog) = parts.first() {
|
||||
if !prog.is_empty() {
|
||||
return Some((prog.clone(), parts[1..].to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in ["nvim", "vim", "vi"] {
|
||||
if executable_on_path(name) {
|
||||
return Some((name.to_string(), Vec::new()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn executable_on_path(name: &str) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(name))
|
||||
.filter_map(|p| std::fs::metadata(&p).ok())
|
||||
.any(|m| m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
/// Open `initial` content in the user's editor and return what was saved.
|
||||
/// Suspends the TUI terminal for the editor session, then re-arms it.
|
||||
async fn edit_text_external(term: &mut TermGuard, initial: &str) -> anyhow::Result<String> {
|
||||
term.suspend();
|
||||
let _rearm = RearmGuard(term);
|
||||
let (prog, args) = editor_parts().ok_or_else(|| {
|
||||
anyhow::anyhow!("no editor found — set $EDITOR (e.g. `export EDITOR=nvim`)")
|
||||
})?;
|
||||
let tmp = tempfile::Builder::new()
|
||||
.prefix("meeticulous-rename-")
|
||||
.suffix(".txt")
|
||||
.tempfile()?;
|
||||
std::fs::write(tmp.path(), initial)?;
|
||||
let status = tokio::process::Command::new(&prog)
|
||||
.args(&args)
|
||||
.arg(tmp.path())
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn editor {prog}: {e}"))?;
|
||||
anyhow::ensure!(status.success(), "editor exited {status}");
|
||||
Ok(std::fs::read_to_string(tmp.path())?)
|
||||
}
|
||||
|
||||
/// Plan a bulk title rename. All-or-nothing: returns Err (joined problems) if
|
||||
/// any edited line is invalid, else the list of `(index, new_title)` to apply.
|
||||
fn plan_bulk_renames(orig: &[String], lines: &[&str]) -> Result<Vec<(usize, String)>, String> {
|
||||
if lines.len() != orig.len() {
|
||||
return Err(format!(
|
||||
"line count changed ({} → {}); nothing applied",
|
||||
orig.len(),
|
||||
lines.len()
|
||||
));
|
||||
}
|
||||
let used: HashSet<&str> = orig.iter().map(|s| s.as_str()).collect();
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
let mut renames = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (i, (o, l)) in orig.iter().zip(lines.iter()).enumerate() {
|
||||
let new = l.trim().to_string();
|
||||
if new == *o {
|
||||
continue;
|
||||
}
|
||||
if new.is_empty() {
|
||||
errors.push(format!("line {}: title cannot be empty", i + 1));
|
||||
continue;
|
||||
}
|
||||
if used.contains(new.as_str()) {
|
||||
errors.push(format!(
|
||||
"line {}: “{new}” is already another meeting's title",
|
||||
i + 1
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if taken.contains(&new) {
|
||||
errors.push(format!(
|
||||
"line {}: “{new}” appears twice in this batch",
|
||||
i + 1
|
||||
));
|
||||
continue;
|
||||
}
|
||||
taken.insert(new.clone());
|
||||
renames.push((i, new));
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(renames)
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_rename_plan_rejects_count_change() {
|
||||
let orig = vec!["A".to_string(), "B".to_string()];
|
||||
let err = plan_bulk_renames(&orig, &["A", "B", "C"]).unwrap_err();
|
||||
assert!(err.contains("line count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_rename_plan_rejects_empty_and_collision() {
|
||||
let orig = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||
let err = plan_bulk_renames(&orig, &["A", "", "A"]).unwrap_err();
|
||||
assert!(err.contains("line 2: title cannot be empty"));
|
||||
assert!(err.contains("line 3"));
|
||||
assert!(err.contains("already another meeting's title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_rename_plan_rejects_batch_duplicates() {
|
||||
let orig = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||
let err = plan_bulk_renames(&orig, &["X", "Y", "X"]).unwrap_err();
|
||||
assert!(err.contains("appears twice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_rename_plan_returns_indexed_renames() {
|
||||
let orig = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||
let plan = plan_bulk_renames(&orig, &["A", "Y", "Z"]).unwrap();
|
||||
assert_eq!(plan, vec![(1, "Y".to_string()), (2, "Z".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_rename_plan_allows_unchanged_and_trim() {
|
||||
let orig = vec!["A".to_string(), "B".to_string()];
|
||||
let plan = plan_bulk_renames(&orig, &["A", "B"]).unwrap();
|
||||
assert!(plan.is_empty());
|
||||
let plan = plan_bulk_renames(&orig, &[" A ", "B"]).unwrap();
|
||||
assert!(
|
||||
plan.is_empty(),
|
||||
"trimmed title equal to original is unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue