mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 11:46:41 +01:00
fix some lints, add FromEdn trait
This commit is contained in:
parent
7962bdf86b
commit
e96faeb6d3
14 changed files with 126 additions and 124 deletions
|
|
@ -50,11 +50,11 @@ impl Sampler {
|
|||
pub fn process_midi_in (&mut self, scope: &ProcessScope) {
|
||||
let Sampler { midi_in, mapped, voices, .. } = self;
|
||||
for RawMidi { time, bytes } in midi_in.iter(scope) {
|
||||
if let LiveEvent::Midi { message, .. } = LiveEvent::parse(bytes).unwrap() {
|
||||
if let MidiMessage::NoteOn { ref key, ref vel } = message {
|
||||
if let Some(sample) = mapped.get(key) {
|
||||
voices.write().unwrap().push(Sample::play(sample, time as usize, vel));
|
||||
}
|
||||
if let LiveEvent::Midi {
|
||||
message: MidiMessage::NoteOn { ref key, ref vel }, ..
|
||||
} = LiveEvent::parse(bytes).unwrap() {
|
||||
if let Some(sample) = mapped.get(key) {
|
||||
voices.write().unwrap().push(Sample::play(sample, time as usize, vel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ impl Sampler {
|
|||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -135,13 +135,13 @@ impl Iterator for Voice {
|
|||
type Item = [f32;2];
|
||||
fn next (&mut self) -> Option<Self::Item> {
|
||||
if self.after > 0 {
|
||||
self.after = self.after - 1;
|
||||
self.after -= 1;
|
||||
return Some([0.0, 0.0])
|
||||
}
|
||||
let sample = self.sample.read().unwrap();
|
||||
if self.position < sample.end {
|
||||
let position = self.position;
|
||||
self.position = self.position + 1;
|
||||
self.position += 1;
|
||||
return sample.channels[0].get(position).map(|_amplitude|[
|
||||
sample.channels[0][position] * self.velocity,
|
||||
sample.channels[0][position] * self.velocity,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ impl ArrangerCli {
|
|||
let mut app = ArrangerTui::try_from(jack)?;
|
||||
let jack = jack.read().unwrap();
|
||||
app.color = ItemPalette::random();
|
||||
add_tracks(&jack, &mut app, &self)?;
|
||||
add_tracks(&jack, &mut app, self)?;
|
||||
add_scenes(&mut app, self.scenes)?;
|
||||
Ok(app)
|
||||
})?)?;
|
||||
|
|
@ -82,9 +82,8 @@ fn add_tracks (jack: &JackClient, app: &mut ArrangerTui, cli: &ArrangerCli) -> U
|
|||
panic!("Tried to connect track {track} or {n}. Pass -t {track} to increase number of tracks.")
|
||||
}
|
||||
if let Some(port) = split.next() {
|
||||
if let Some(port) = jack.port_by_name(&port) {
|
||||
jack.client().connect_ports(&port, &app.tracks[track-1].player.midi_ins[0])?;
|
||||
//jack.client().connect_ports(&port, &app.tracks[track].player.midi_ins[0])?;
|
||||
if let Some(port) = jack.port_by_name(port).as_ref() {
|
||||
jack.client().connect_ports(port, &app.tracks[track-1].player.midi_ins[0])?;
|
||||
} else {
|
||||
panic!("Missing MIDI output: {port}. Use jack_lsp to list all port names.");
|
||||
}
|
||||
|
|
@ -106,8 +105,8 @@ fn add_tracks (jack: &JackClient, app: &mut ArrangerTui, cli: &ArrangerCli) -> U
|
|||
panic!("Tried to connect track {track} or {n}. Pass -t {track} to increase number of tracks.")
|
||||
}
|
||||
if let Some(port) = split.next() {
|
||||
if let Some(port) = jack.port_by_name(&port) {
|
||||
jack.client().connect_ports(&app.tracks[track-1].player.midi_outs[0], &port)?;
|
||||
if let Some(port) = jack.port_by_name(port).as_ref() {
|
||||
jack.client().connect_ports(&app.tracks[track-1].player.midi_outs[0], port)?;
|
||||
} else {
|
||||
panic!("Missing MIDI input: {port}. Use jack_lsp to list all port names.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,11 @@ pub struct SequencerCli {
|
|||
/// MIDI ins to connect to (multiple instances accepted)
|
||||
#[arg(short='o', long)]
|
||||
midi_to: Vec<String>,
|
||||
|
||||
/// Default phrase duration (in pulses; default: 4 * PPQ = 1 bar)
|
||||
#[arg(short, long)]
|
||||
length: Option<usize>,
|
||||
}
|
||||
|
||||
impl SequencerCli {
|
||||
fn run (&self) -> Usually<()> {
|
||||
let name = self.name.as_ref().map(|n|n.as_str()).unwrap_or("tek_sequencer");
|
||||
let name = self.name.as_deref().unwrap_or("tek_sequencer");
|
||||
Tui::run(JackClient::new(name)?.activate_with(|jack|{
|
||||
let mut app = SequencerTui::try_from(jack)?;
|
||||
let jack = jack.read().unwrap();
|
||||
|
|
@ -42,11 +38,6 @@ impl SequencerCli {
|
|||
connect_to(&jack, &midi_out, &self.midi_to)?;
|
||||
app.player.midi_ins.push(midi_in);
|
||||
app.player.midi_outs.push(midi_out);
|
||||
if let Some(_) = self.length {
|
||||
// TODO: if let Some(phrase) = sequencer.phrase.as_mut() {
|
||||
//phrase.write().unwrap().length = length;
|
||||
//}
|
||||
}
|
||||
Ok(app)
|
||||
})?)?;
|
||||
Ok(())
|
||||
|
|
@ -55,8 +46,8 @@ impl SequencerCli {
|
|||
|
||||
fn connect_from (jack: &JackClient, input: &Port<MidiIn>, ports: &[String]) -> Usually<()> {
|
||||
for port in ports.iter() {
|
||||
if let Some(port) = jack.port_by_name(&port) {
|
||||
jack.client().connect_ports(&port, &input)?;
|
||||
if let Some(port) = jack.port_by_name(port).as_ref() {
|
||||
jack.client().connect_ports(port, input)?;
|
||||
} else {
|
||||
panic!("Missing MIDI output: {port}. Use jack_lsp to list all port names.");
|
||||
}
|
||||
|
|
@ -66,8 +57,8 @@ fn connect_from (jack: &JackClient, input: &Port<MidiIn>, ports: &[String]) -> U
|
|||
|
||||
fn connect_to (jack: &JackClient, output: &Port<MidiOut>, ports: &[String]) -> Usually<()> {
|
||||
for port in ports.iter() {
|
||||
if let Some(port) = jack.port_by_name(&port) {
|
||||
jack.client().connect_ports(&output, &port)?;
|
||||
if let Some(port) = jack.port_by_name(port).as_ref() {
|
||||
jack.client().connect_ports(output, port)?;
|
||||
} else {
|
||||
panic!("Missing MIDI input: {port}. Use jack_lsp to list all port names.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@ use crate::*;
|
|||
pub use clojure_reader::edn::Edn;
|
||||
//pub use clojure_reader::{edn::{read, Edn}, error::Error as EdnError};
|
||||
|
||||
pub trait FromEdn<C>: Sized {
|
||||
const ID: &'static str;
|
||||
fn from_edn <'e> (context: C, expr: &[Edn<'e>]) -> Usually<Self>;
|
||||
}
|
||||
|
||||
/// Implements the [FromEdn] trait.
|
||||
#[macro_export] macro_rules! from_edn {
|
||||
(|$context:pat = $Context:ty, $id:expr, $args:ident| -> $T:ty $body:block) => {
|
||||
impl FromEdn<$Context> for $T {
|
||||
const ID: &'static str = $id;
|
||||
fn from_edn <'e> ($context: $Context, $args: &[Edn<'e>]) -> Usually<Self> {
|
||||
$body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EDN parsing helper.
|
||||
#[macro_export] macro_rules! edn {
|
||||
($edn:ident { $($pat:pat => $expr:expr),* $(,)? }) => {
|
||||
|
|
@ -16,82 +33,79 @@ pub use clojure_reader::edn::Edn;
|
|||
};
|
||||
}
|
||||
|
||||
impl crate::Sampler {
|
||||
pub fn from_edn <'e> (jack: &Arc<RwLock<JackClient>>, args: &[Edn<'e>]) -> Usually<Self> {
|
||||
let mut name = String::new();
|
||||
let mut dir = String::new();
|
||||
let mut samples = BTreeMap::new();
|
||||
edn!(edn in args {
|
||||
Edn::Map(map) => {
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":name")) {
|
||||
name = String::from(*n);
|
||||
}
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":dir")) {
|
||||
dir = String::from(*n);
|
||||
from_edn!(|jack = &Arc<RwLock<JackClient>>, "sampler", args| -> crate::Sampler {
|
||||
let mut name = String::new();
|
||||
let mut dir = String::new();
|
||||
let mut samples = BTreeMap::new();
|
||||
edn!(edn in args {
|
||||
Edn::Map(map) => {
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":name")) {
|
||||
name = String::from(*n);
|
||||
}
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":dir")) {
|
||||
dir = String::from(*n);
|
||||
}
|
||||
},
|
||||
Edn::List(args) => match args.get(0) {
|
||||
Some(Edn::Symbol("sample")) => {
|
||||
let (midi, sample) = MidiSample::from_edn((jack, &dir), &args[1..])?;
|
||||
if let Some(midi) = midi {
|
||||
samples.insert(midi, sample);
|
||||
} else {
|
||||
panic!("sample without midi binding: {}", sample.read().unwrap().name);
|
||||
}
|
||||
},
|
||||
Edn::List(args) => match args.get(0) {
|
||||
Some(Edn::Symbol("sample")) => {
|
||||
let (midi, sample) = Sample::from_edn(jack, &dir, &args[1..])?;
|
||||
if let Some(midi) = midi {
|
||||
samples.insert(midi, sample);
|
||||
} else {
|
||||
panic!("sample without midi binding: {}", sample.read().unwrap().name);
|
||||
}
|
||||
},
|
||||
_ => panic!("unexpected in sampler {name}: {args:?}")
|
||||
},
|
||||
_ => panic!("unexpected in sampler {name}: {edn:?}")
|
||||
});
|
||||
let midi_in = jack.read().unwrap().client().register_port("in", MidiIn::default())?;
|
||||
Ok(Sampler {
|
||||
jack: jack.clone(),
|
||||
name: name.into(),
|
||||
mapped: samples,
|
||||
unmapped: Default::default(),
|
||||
voices: Default::default(),
|
||||
buffer: Default::default(),
|
||||
audio_outs: vec![],
|
||||
output_gain: 0.,
|
||||
midi_in,
|
||||
})
|
||||
}
|
||||
}
|
||||
_ => panic!("unexpected in sampler {name}: {args:?}")
|
||||
},
|
||||
_ => panic!("unexpected in sampler {name}: {edn:?}")
|
||||
});
|
||||
let midi_in = jack.read().unwrap().client().register_port("in", MidiIn::default())?;
|
||||
Ok(Self {
|
||||
jack: jack.clone(),
|
||||
name: name.into(),
|
||||
mapped: samples,
|
||||
unmapped: Default::default(),
|
||||
voices: Default::default(),
|
||||
buffer: Default::default(),
|
||||
audio_outs: vec![],
|
||||
output_gain: 0.,
|
||||
midi_in,
|
||||
})
|
||||
});
|
||||
|
||||
impl crate::Sample {
|
||||
pub fn from_edn <'e> (jack: &Arc<RwLock<JackClient>>, dir: &str, args: &[Edn<'e>]) -> Usually<(Option<u7>, Arc<RwLock<Self>>)> {
|
||||
let mut name = String::new();
|
||||
let mut file = String::new();
|
||||
let mut midi = None;
|
||||
let mut start = 0usize;
|
||||
edn!(edn in args {
|
||||
Edn::Map(map) => {
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":name")) {
|
||||
name = String::from(*n);
|
||||
}
|
||||
if let Some(Edn::Str(f)) = map.get(&Edn::Key(":file")) {
|
||||
file = String::from(*f);
|
||||
}
|
||||
if let Some(Edn::Int(i)) = map.get(&Edn::Key(":start")) {
|
||||
start = *i as usize;
|
||||
}
|
||||
if let Some(Edn::Int(m)) = map.get(&Edn::Key(":midi")) {
|
||||
midi = Some(u7::from(*m as u8));
|
||||
}
|
||||
},
|
||||
_ => panic!("unexpected in sample {name}"),
|
||||
});
|
||||
let (end, data) = Sample::read_data(&format!("{dir}/{file}"))?;
|
||||
Ok((midi, Arc::new(RwLock::new(Self {
|
||||
name: name.into(),
|
||||
start,
|
||||
end,
|
||||
channels: data,
|
||||
rate: None
|
||||
}))))
|
||||
}
|
||||
}
|
||||
type MidiSample = (Option<u7>, Arc<RwLock<crate::Sample>>);
|
||||
|
||||
from_edn!(|(jack, dir) = (&Arc<RwLock<JackClient>>, &str), "sample", args| -> MidiSample {
|
||||
let mut name = String::new();
|
||||
let mut file = String::new();
|
||||
let mut midi = None;
|
||||
let mut start = 0usize;
|
||||
edn!(edn in args {
|
||||
Edn::Map(map) => {
|
||||
if let Some(Edn::Str(n)) = map.get(&Edn::Key(":name")) {
|
||||
name = String::from(*n);
|
||||
}
|
||||
if let Some(Edn::Str(f)) = map.get(&Edn::Key(":file")) {
|
||||
file = String::from(*f);
|
||||
}
|
||||
if let Some(Edn::Int(i)) = map.get(&Edn::Key(":start")) {
|
||||
start = *i as usize;
|
||||
}
|
||||
if let Some(Edn::Int(m)) = map.get(&Edn::Key(":midi")) {
|
||||
midi = Some(u7::from(*m as u8));
|
||||
}
|
||||
},
|
||||
_ => panic!("unexpected in sample {name}"),
|
||||
});
|
||||
let (end, data) = Sample::read_data(&format!("{dir}/{file}"))?;
|
||||
Ok((midi, Arc::new(RwLock::new(crate::Sample {
|
||||
name,
|
||||
start,
|
||||
end,
|
||||
channels: data,
|
||||
rate: None
|
||||
}))))
|
||||
});
|
||||
|
||||
//impl ArrangerScene {
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ pub struct PlayerAudio<'a, T: MidiPlayerApi>(
|
|||
);
|
||||
|
||||
/// JACK process callback for a sequencer's phrase player/recorder.
|
||||
impl<'a, T: MidiPlayerApi> Audio for PlayerAudio<'a, T> {
|
||||
impl<T: MidiPlayerApi> Audio for PlayerAudio<'_, T> {
|
||||
fn process (&mut self, _: &Client, scope: &ProcessScope) -> Control {
|
||||
let model = &mut self.0;
|
||||
let note_buf = &mut self.1;
|
||||
|
|
@ -217,7 +217,7 @@ impl MidiRecordApi for MidiPlayer {
|
|||
|
||||
impl MidiPlaybackApi for MidiPlayer {
|
||||
fn notes_out (&self) -> &Arc<RwLock<[bool; 128]>> {
|
||||
&self.notes_in
|
||||
&self.notes_out
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,13 +76,12 @@ impl MidiClip {
|
|||
}
|
||||
/// Check if a range `start..end` contains MIDI Note On `k`
|
||||
pub fn contains_note_on (&self, k: u7, start: usize, end: usize) -> bool {
|
||||
//panic!("{:?} {start} {end}", &self);
|
||||
for events in self.notes[start.max(0)..end.min(self.notes.len())].iter() {
|
||||
for event in events.iter() {
|
||||
if let MidiMessage::NoteOn {key,..} = event { if *key == k { return true } }
|
||||
}
|
||||
}
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ pub trait HasMidiIns {
|
|||
fn midi_ins (&self) -> &Vec<Port<MidiIn>>;
|
||||
fn midi_ins_mut (&mut self) -> &mut Vec<Port<MidiIn>>;
|
||||
fn has_midi_ins (&self) -> bool {
|
||||
self.midi_ins().len() > 0
|
||||
!self.midi_ins().is_empty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,8 @@ pub trait HasPlayPhrase: HasClock {
|
|||
}
|
||||
fn enqueue_next (&mut self, phrase: Option<&Arc<RwLock<MidiClip>>>) {
|
||||
let start = self.clock().next_launch_pulse() as f64;
|
||||
let instant = Moment::from_pulse(&self.clock().timebase(), start);
|
||||
let phrase = phrase.map(|p|p.clone());
|
||||
*self.next_phrase_mut() = Some((instant, phrase));
|
||||
let instant = Moment::from_pulse(self.clock().timebase(), start);
|
||||
*self.next_phrase_mut() = Some((instant, phrase.cloned()));
|
||||
*self.reset_mut() = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pub trait HasMidiOuts {
|
|||
fn midi_outs (&self) -> &Vec<Port<MidiOut>>;
|
||||
fn midi_outs_mut (&mut self) -> &mut Vec<Port<MidiOut>>;
|
||||
fn has_midi_outs (&self) -> bool {
|
||||
self.midi_outs().len() > 0
|
||||
!self.midi_outs().is_empty()
|
||||
}
|
||||
/// Buffer for serializing a MIDI event. FIXME rename
|
||||
fn midi_note (&mut self) -> &mut Vec<u8>;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub trait MidiPlaybackApi: HasPlayPhrase + HasClock + HasMidiOuts {
|
|||
// Samples elapsed since phrase was supposed to start
|
||||
let skipped = sample0 - start;
|
||||
// Switch over to enqueued phrase
|
||||
let started = Moment::from_sample(&self.clock().timebase(), start as f64);
|
||||
let started = Moment::from_sample(self.clock().timebase(), start as f64);
|
||||
// Launch enqueued phrase
|
||||
*self.play_phrase_mut() = Some((started, phrase.clone()));
|
||||
// Unset enqueuement (TODO: where to implement looping?)
|
||||
|
|
@ -123,7 +123,7 @@ pub trait MidiPlaybackApi: HasPlayPhrase + HasClock + HasMidiOuts {
|
|||
// Append serialized message to output buffer.
|
||||
out[sample].push(note_buf.clone());
|
||||
// Update the list of currently held notes.
|
||||
update_keys(&mut*notes, &message);
|
||||
update_keys(&mut*notes, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,10 +138,11 @@ pub trait MidiPlaybackApi: HasPlayPhrase + HasClock + HasMidiOuts {
|
|||
|
||||
/// Write a chunk of MIDI data from the output buffer to an output port.
|
||||
fn write_port (writer: &mut MidiWriter, samples: usize, out: &[Vec<Vec<u8>>]) {
|
||||
for time in 0..samples {
|
||||
for event in out[time].iter() {
|
||||
writer.write(&RawMidi { time: time as u32, bytes: &event })
|
||||
.expect(&format!("{event:?}"));
|
||||
for (time, events) in out.iter().enumerate().take(samples) {
|
||||
for bytes in events.iter() {
|
||||
writer.write(&RawMidi { time: time as u32, bytes }).unwrap_or_else(|_|{
|
||||
panic!("Failed to write MIDI data: {bytes:?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,11 @@ pub trait MidiRecordApi: HasClock + HasPlayPhrase + HasMidiIns {
|
|||
let sample = (sample0 + sample - start) as f64;
|
||||
let pulse = timebase.samples_to_pulse(sample);
|
||||
let quantized = (pulse / quant).round() * quant;
|
||||
let looped = quantized as usize % length;
|
||||
looped
|
||||
quantized as usize % length
|
||||
}, message);
|
||||
}
|
||||
}
|
||||
update_keys(&mut*notes_in.write().unwrap(), &message);
|
||||
update_keys(&mut notes_in.write().unwrap(), &message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +60,7 @@ pub trait MidiRecordApi: HasClock + HasPlayPhrase + HasMidiIns {
|
|||
for (sample, event, bytes) in parse_midi_input(input.iter(scope)) {
|
||||
if let LiveEvent::Midi { message, .. } = event {
|
||||
midi_buf[sample].push(bytes.to_vec());
|
||||
update_keys(&mut*notes_in.write().unwrap(), &message);
|
||||
update_keys(&mut notes_in.write().unwrap(), &message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub trait Coordinate: Send + Sync + Copy
|
|||
0.into()
|
||||
}
|
||||
}
|
||||
fn ZERO () -> Self {
|
||||
fn zero () -> Self {
|
||||
0.into()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ where
|
|||
let mut h: E::Unit = 0.into();
|
||||
(self.0)(&mut |component: &dyn Render<E>| {
|
||||
let max = to.h().minus(h);
|
||||
if max > E::Unit::ZERO() {
|
||||
if max > E::Unit::zero() {
|
||||
let item = E::max_y(max, E::push_y(h, component));
|
||||
let size = item.min_size(to)?.map(|size|size.wh());
|
||||
if let Some([width, height]) = size {
|
||||
|
|
@ -98,7 +98,7 @@ where
|
|||
let mut h: E::Unit = 0.into();
|
||||
(self.0)(&mut |component: &dyn Render<E>| {
|
||||
let max = to.w().minus(w);
|
||||
if max > E::Unit::ZERO() {
|
||||
if max > E::Unit::zero() {
|
||||
let item = E::max_x(max, E::push_x(h, component));
|
||||
let size = item.min_size(to)?.map(|size|size.wh());
|
||||
if let Some([width, height]) = size {
|
||||
|
|
@ -116,7 +116,7 @@ where
|
|||
let mut h: E::Unit = 0.into();
|
||||
(self.0)(&mut |component: &dyn Render<E>| {
|
||||
let max = to.h().minus(h);
|
||||
if max > E::Unit::ZERO() {
|
||||
if max > E::Unit::zero() {
|
||||
let item = E::max_y(to.h() - h, component);
|
||||
let size = item.min_size(to)?.map(|size|size.wh());
|
||||
if let Some([width, height]) = size {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ render!(<Tui>|self: ArrangerVHead<'a>|Tui::push_x(self.scenes_w, row!(
|
|||
|
||||
impl<'a> ArrangerVHead<'a> {
|
||||
/// name and width of track
|
||||
fn format_name (track: &ArrangerTrack, w: usize) -> impl Render<Tui> {
|
||||
fn format_name (track: &ArrangerTrack, _w: usize) -> impl Render<Tui> {
|
||||
let name = track.name().read().unwrap().clone();
|
||||
Tui::bold(true, Tui::fg(track.color.lightest.rgb, name))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue