Honor bread.command.shot.region via breadshot listen
Some checks failed
dev release / build (push) Failing after 0s
Some checks failed
dev release / build (push) Failing after 0s
Pin bread-utils to bread-ecosystem v0.7.2. breadshot listen subscribes to bread.command.shot.**, runs the same region capture as the CLI (clipboard-only), and emits bread.shot.region.done / .failed.
This commit is contained in:
parent
c68214a6ab
commit
365072d65e
7 changed files with 227 additions and 52 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -95,8 +95,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
77
EVENTS.md
77
EVENTS.md
|
|
@ -1,33 +1,37 @@
|
||||||
# breadshot — bread event integration
|
# breadshot — bread event integration
|
||||||
|
|
||||||
breadshot is a standalone, one-shot Wayland screenshot orchestrator: it
|
breadshot is a standalone Wayland screenshot orchestrator: it works
|
||||||
works exactly the same with or without `breadd` running. When breadd *is*
|
exactly the same with or without `breadd` running. When breadd *is*
|
||||||
present, a successful capture publishes one event into the shared bread
|
present, a successful capture publishes into the shared bread automation
|
||||||
automation fabric. See the parent `bread` repo's `Documentation.md` —
|
fabric. See the parent `bread` repo's `Documentation.md` — specifically
|
||||||
specifically its "Namespaces" and "Integrating a bread\* app" sections —
|
its "Namespaces" and "Integrating a bread\* app" sections — for the
|
||||||
for the general convention this follows.
|
general convention this follows.
|
||||||
|
|
||||||
This is a different job from `bread-screenshots` (the crate in
|
This is a different job from `bread-screenshots` (the crate in
|
||||||
`bread-ecosystem`): that one is a capture harness for screenshotting
|
`bread-ecosystem`): that one is a capture harness for screenshotting
|
||||||
sibling apps in CI. Do not merge the two.
|
sibling apps in CI. Do not merge the two.
|
||||||
|
|
||||||
App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module
|
App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
(feature `bread-client`). breadshot is a short-lived CLI, not a daemon —
|
(feature `bread-client`). One-shot CLI invocations (`breadshot region`,
|
||||||
each `emit` is its own fire-and-forget connection (the same stance
|
…) each `emit` on their own fire-and-forget connection (the same stance
|
||||||
`bread-emit` takes for occasional callers). There is no process to hold a
|
`bread-emit` takes for occasional callers). Command verbs are only
|
||||||
command subscription open.
|
received while `breadshot listen` is running — that process holds the
|
||||||
|
`bread.command.shot.**` subscription open.
|
||||||
|
|
||||||
## Events published (`bread.shot.*`)
|
## Events published (`bread.shot.*`)
|
||||||
|
|
||||||
| Event | Data | When |
|
| Event | Data | When |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": <string or null> }` | A capture completed successfully (grim + clipboard write both returned). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. |
|
| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": <string or null> }` | A capture completed successfully (grim + clipboard write both returned), whether triggered by the CLI or by `bread.command.shot.region`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. |
|
||||||
|
| `bread.shot.region.done` | `{ "clipboard": true, "path": null }` | `bread.command.shot.region` was received and the region capture succeeded. |
|
||||||
|
| `bread.shot.region.failed` | `{ "error": "<message>" }` | `bread.command.shot.region` was received but the capture failed (cancelled slurp, missing dependency, grim/wl-copy error). |
|
||||||
|
|
||||||
`mode` is the CLI mode name (same strings `breadshot <mode>` accepts).
|
`mode` is the CLI mode name (same strings `breadshot <mode>` accepts).
|
||||||
`clipboard` is whether the PNG was written to the clipboard — both current
|
`clipboard` is whether the PNG was written to the clipboard — both current
|
||||||
capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is
|
capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is
|
||||||
the saved file, or `null` when `--clipboard-only` was used (no file on
|
the saved file, or `null` when `--clipboard-only` was used (no file on
|
||||||
disk).
|
disk). The listen-triggered region path is clipboard-only, so `path` is
|
||||||
|
always `null` on `bread.shot.region.done`.
|
||||||
|
|
||||||
The image bytes themselves are never included in the payload. The event
|
The image bytes themselves are never included in the payload. The event
|
||||||
bus is a notification that a capture happened, not a channel for the
|
bus is a notification that a capture happened, not a channel for the
|
||||||
|
|
@ -35,32 +39,47 @@ screenshot.
|
||||||
|
|
||||||
## Commands honored (`bread.command.shot.*`)
|
## Commands honored (`bread.command.shot.*`)
|
||||||
|
|
||||||
None. breadshot is a one-shot CLI with no persistent process to subscribe
|
These are only received while `breadshot listen` is running. Publishing a
|
||||||
to `bread.command.shot.*`. A Lua workflow that wants a screenshot should
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
shell out:
|
bread convention, not a breadshot bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `region` | none | Same interactive region capture as `breadshot region --clipboard-only`. Emits `bread.shot.region.done`/`.failed`. A successful capture also publishes `bread.shot.captured` the same way the CLI path does. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.shot.region")
|
||||||
|
bread.wait("bread.shot.region.done", { timeout = 30000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
A workflow that wants a file on disk (not just the clipboard) should
|
||||||
|
still shell out:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
bread.exec("breadshot region")
|
bread.exec("breadshot region")
|
||||||
-- or
|
|
||||||
bread.exec("breadshot region --clipboard-only")
|
|
||||||
bread.exec("breadshot active-output")
|
bread.exec("breadshot active-output")
|
||||||
```
|
```
|
||||||
|
|
||||||
The outcome of that exec is the same `bread.shot.captured` event the
|
### Not implemented: extra verbs
|
||||||
keybind path already publishes — `bread.wait("bread.shot.captured")`
|
|
||||||
inside a spawned coroutine if the workflow needs to react to the file.
|
|
||||||
|
|
||||||
There is no `pin`, `select`, `edit`, or other command verb. breadshot has
|
There is no `window`, `output`, `active-window`, `active-output`, `pin`,
|
||||||
no editor, no history, and no concept those verbs could hang on.
|
`select`, or `edit` command verb. The CLI already covers the other
|
||||||
`bread.exec("breadshot …")` is the whole command surface. If/when a
|
capture modes as synchronous one-shots, and breadshot has no editor, no
|
||||||
long-running piece exists, verbs should be added then, not stubbed as
|
history, and no concept those other verbs could hang on. If/when that
|
||||||
no-ops ahead of it.
|
changes, the corresponding `bread.command.shot.*` verb should be added
|
||||||
|
at the same time, not stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
(`BreadClient::emit` never blocks or errors the caller) — breadshot's
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — breadshot's
|
||||||
actual grim/slurp/wl-copy path is entirely unaffected either way.
|
actual grim/slurp/wl-copy path is entirely unaffected either way.
|
||||||
- There is no command subscription, so a breadd restart cannot drop one.
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
The next `breadshot` invocation emits (or silently doesn't) on its own
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
short-lived connection.
|
loop); no restart of `breadshot listen` is needed.
|
||||||
|
- If `breadshot listen` is not running, commands are a graceful no-op at
|
||||||
|
the bus (no subscriber). The CLI still works, and one-shot invocations
|
||||||
|
still emit `bread.shot.captured` on their own short-lived connection.
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,14 @@ make install PREFIX=/usr
|
||||||
|
|
||||||
```
|
```
|
||||||
breadshot <mode> [options]
|
breadshot <mode> [options]
|
||||||
|
breadshot listen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`breadshot listen` is the long-running process that honors
|
||||||
|
`bread.command.shot.region` on the bread event bus (clipboard-only
|
||||||
|
region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still
|
||||||
|
works; bus commands are a silent no-op.
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::ValueEnum;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
io::Write,
|
io::Write,
|
||||||
|
|
@ -12,9 +11,9 @@ use crate::config::Config;
|
||||||
|
|
||||||
/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as
|
/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as
|
||||||
/// `bread.shot.*`. See `EVENTS.md`.
|
/// `bread.shot.*`. See `EVENTS.md`.
|
||||||
const APP_ID: &str = "shot";
|
pub(crate) const APP_ID: &str = "shot";
|
||||||
|
|
||||||
#[derive(Debug, Clone, ValueEnum)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
/// Select a region interactively
|
/// Select a region interactively
|
||||||
Region,
|
Region,
|
||||||
|
|
@ -23,10 +22,8 @@ pub enum Mode {
|
||||||
/// Click to select a monitor
|
/// Click to select a monitor
|
||||||
Output,
|
Output,
|
||||||
/// Capture the active window
|
/// Capture the active window
|
||||||
#[value(name = "active-window")]
|
|
||||||
ActiveWindow,
|
ActiveWindow,
|
||||||
/// Capture the active monitor
|
/// Capture the active monitor
|
||||||
#[value(name = "active-output")]
|
|
||||||
ActiveOutput,
|
ActiveOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,7 +350,11 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
|
||||||
|
|
||||||
fn hyprctl_json(subcmd: &str) -> Result<Value> {
|
fn hyprctl_json(subcmd: &str) -> Result<Value> {
|
||||||
// Was a bare Command::new("hyprctl").output() with no timeout.
|
// Was a bare Command::new("hyprctl").output() with no timeout.
|
||||||
bread_utils::proc::run_json("hyprctl", &["-j", subcmd], std::time::Duration::from_secs(3))
|
bread_utils::proc::run_json(
|
||||||
|
"hyprctl",
|
||||||
|
&["-j", subcmd],
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
)
|
||||||
.with_context(|| format!("running/parsing hyprctl {subcmd}"))
|
.with_context(|| format!("running/parsing hyprctl {subcmd}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
110
src/listen.rs
Normal file
110
src/listen.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
//! Long-running command subscription for `bread.command.shot.*`.
|
||||||
|
//!
|
||||||
|
//! `breadshot` is still a one-shot CLI by default. `breadshot listen` is the
|
||||||
|
//! optional persistent process that can honor bus commands. See `EVENTS.md`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
use crate::capture::{self, Mode, Overrides, APP_ID};
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.shot.**` and block until the process is killed.
|
||||||
|
///
|
||||||
|
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
||||||
|
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
||||||
|
pub fn run(config: &Config) -> Result<()> {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
tracing::warn!("breadd unreachable; command subscription will connect when it comes back");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = config.clone();
|
||||||
|
let _commands = client.subscribe("bread.command.shot.**", move |event| {
|
||||||
|
handle_command(&event, &config);
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("listening for bread.command.shot.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.shot.*` verbs. Only `region` is honored today —
|
||||||
|
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
///
|
||||||
|
/// Emits `bread.shot.region.done` / `.failed` per the confirmation convention
|
||||||
|
/// in bread's Documentation.md.
|
||||||
|
fn handle_command(event: &BreadEvent, config: &Config) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"region" => handle_region(config),
|
||||||
|
other => {
|
||||||
|
tracing::debug!("ignoring unrecognized command verb '{other}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_region(config: &Config) {
|
||||||
|
// Clipboard-only matches the default region *bus* path: a Lua workflow
|
||||||
|
// that wants a file on disk can still `bread.exec("breadshot region")`.
|
||||||
|
let result = capture::run(
|
||||||
|
&Mode::Region,
|
||||||
|
config,
|
||||||
|
Overrides {
|
||||||
|
clipboard_only: true,
|
||||||
|
silent: false,
|
||||||
|
freeze: false,
|
||||||
|
output_dir: None,
|
||||||
|
filename: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(()) => client.emit("bread.shot.region.done", region_done_payload()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bread.command.shot.region failed: {e}");
|
||||||
|
client.emit("bread.shot.region.failed", region_failed_payload(&e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.shot.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn region_done_payload() -> serde_json::Value {
|
||||||
|
serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn region_failed_payload(error: &impl ToString) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "error": error.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_shot_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.shot.region"), Some("region"));
|
||||||
|
assert_eq!(command_verb("bread.command.shot.window"), Some("window"));
|
||||||
|
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
||||||
|
assert_eq!(command_verb("bread.shot.captured"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn region_done_payload_is_clipboard_only() {
|
||||||
|
let v = region_done_payload();
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert!(v["path"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn region_failed_payload_includes_error() {
|
||||||
|
let v = region_failed_payload(&"selection cancelled");
|
||||||
|
assert_eq!(v["error"], "selection cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main.rs
65
src/main.rs
|
|
@ -1,8 +1,9 @@
|
||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod listen;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::{Args, Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
|
@ -14,12 +15,19 @@ use config::Config;
|
||||||
name = "breadshot",
|
name = "breadshot",
|
||||||
version,
|
version,
|
||||||
about = "Screenshot utility for the bread ecosystem",
|
about = "Screenshot utility for the bread ecosystem",
|
||||||
disable_help_subcommand = true,
|
disable_help_subcommand = true
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Capture mode
|
#[command(subcommand)]
|
||||||
mode: Mode,
|
command: Command,
|
||||||
|
|
||||||
|
/// Path to config file
|
||||||
|
#[arg(long, value_name = "FILE", global = true)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct CaptureOpts {
|
||||||
/// Copy to clipboard only, don't save to disk
|
/// Copy to clipboard only, don't save to disk
|
||||||
#[arg(long, short = 'c')]
|
#[arg(long, short = 'c')]
|
||||||
clipboard_only: bool,
|
clipboard_only: bool,
|
||||||
|
|
@ -39,10 +47,37 @@ struct Cli {
|
||||||
/// Override output filename (without path)
|
/// Override output filename (without path)
|
||||||
#[arg(long, short = 'f', value_name = "NAME")]
|
#[arg(long, short = 'f', value_name = "NAME")]
|
||||||
filename: Option<String>,
|
filename: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Path to config file
|
#[derive(Subcommand)]
|
||||||
#[arg(long, value_name = "FILE")]
|
enum Command {
|
||||||
config: Option<PathBuf>,
|
/// Select a region interactively
|
||||||
|
Region(CaptureOpts),
|
||||||
|
/// Click to select a window
|
||||||
|
Window(CaptureOpts),
|
||||||
|
/// Click to select a monitor
|
||||||
|
Output(CaptureOpts),
|
||||||
|
/// Capture the active window
|
||||||
|
#[command(name = "active-window")]
|
||||||
|
ActiveWindow(CaptureOpts),
|
||||||
|
/// Capture the active monitor
|
||||||
|
#[command(name = "active-output")]
|
||||||
|
ActiveOutput(CaptureOpts),
|
||||||
|
/// Subscribe to bread.command.shot.** and honor region captures
|
||||||
|
Listen,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
fn into_capture(self) -> Option<(Mode, CaptureOpts)> {
|
||||||
|
match self {
|
||||||
|
Self::Region(opts) => Some((Mode::Region, opts)),
|
||||||
|
Self::Window(opts) => Some((Mode::Window, opts)),
|
||||||
|
Self::Output(opts) => Some((Mode::Output, opts)),
|
||||||
|
Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)),
|
||||||
|
Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)),
|
||||||
|
Self::Listen => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
@ -58,15 +93,19 @@ fn main() -> Result<()> {
|
||||||
None => Config::load()?,
|
None => Config::load()?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some((mode, opts)) = cli.command.into_capture() else {
|
||||||
|
return listen::run(&config);
|
||||||
|
};
|
||||||
|
|
||||||
capture::run(
|
capture::run(
|
||||||
&cli.mode,
|
&mode,
|
||||||
&config,
|
&config,
|
||||||
Overrides {
|
Overrides {
|
||||||
clipboard_only: cli.clipboard_only,
|
clipboard_only: opts.clipboard_only,
|
||||||
silent: cli.silent,
|
silent: opts.silent,
|
||||||
freeze: cli.freeze,
|
freeze: opts.freeze,
|
||||||
output_dir: cli.output_dir,
|
output_dir: opts.output_dir,
|
||||||
filename: cli.filename,
|
filename: opts.filename,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue