use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::Context as _;
use crate::config::{IndexConfig, IndexFonts, ValidatedConfig};
use crate::manifest::{GenerationManifest, RepoEntry};
pub const INDEX_FILE: &str = "index.html";
pub const FAVICON_SVG_FILE: &str = "favicon.svg";
pub const FAVICON_PNG_FILE: &str = "favicon-32.png";
pub const APPLE_TOUCH_ICON_FILE: &str = "apple-touch-icon.png";
pub const OG_IMAGE_FILE: &str = "og.png";
pub const FAVICON_SVG: &[u8] = include_bytes!("../favicon/favicon.svg");
pub const FAVICON_PNG: &[u8] = include_bytes!("../favicon/favicon-32.png");
pub const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../favicon/apple-touch-icon.png");
pub const OG_IMAGE_WIDTH: u32 = 1200;
pub const OG_IMAGE_HEIGHT: u32 = 630;
pub const THEME_COLOR_LIGHT: &str = "#fdf7fa";
pub const THEME_COLOR_DARK: &str = "#1b1519";
const UNWIND_THRESHOLD: usize = 12;
const POPOVER_CHAR_THRESHOLD: usize = 45;
mod actor_profile;
mod avatar_sizes;
mod fonts;
mod handles;
pub mod og;
mod pulse;
#[cfg(test)]
mod tests;
pub use handles::sync_handles;
pub(crate) use handles::{
StoredHandles, cached_handles, cached_owner_labels, served_digest, verify_unseen,
};
use handles::{read_activity, read_descriptions};
pub(crate) use pulse::Counted;
pub const KAOMOJI: &[&str] = &[
"(˶ᵔ ᵕ ᵔ˶)",
"( ´ ▽ ` )ノ",
"(◕‿◕✿)",
"(=^・ω・^=)",
"(っ˘ω˘ς )",
"(。•̀ᴗ-)✧",
"( ˘▽˘)っ♨",
"( ´ ω ` )",
"૮ ˶ᵔ ᵕ ᵔ˶ ა",
"(≧◡≦)",
"(´。• ᵕ •。`)",
"( ˙꒳˙ )",
"(¬‿¬)",
"₍ᐢ.ˬ.ᐢ₎",
"(✿◠‿◠)",
];
const COPY_SVG: &str = r#"<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5.25" y="5.25" width="8.5" height="8.5"/><path d="M10.75 3V2.25H2.25V10.75H3"/></svg>"#;
const STYLE: &str = include_str!("index.css");
const SCRIPT: &str = include_str!("index.js");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersonSection {
pub label: String,
pub did: String,
pub repos: Vec<ServedRepo>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServedRepo {
pub did: String,
pub name: String,
pub description: Option<String>,
/// unix seconds of the newest commit its public refs name
pub active_at: Option<i64>,
}
pub fn kaomoji_for(generation_id: &str) -> &'static str {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in generation_id.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
KAOMOJI[(hash as usize) % KAOMOJI.len()]
}
/// what `{name}` expands to in the `[index]` templates
pub struct Vars<'a> {
pub hostname: &'a str,
pub owner: &'a str,
pub owner_did: &'a str,
pub knot_did: &'a str,
pub repos: usize,
pub users: usize,
pub kaomoji: &'a str,
pub generation: &'a str,
}
impl Vars<'_> {
fn get(&self, name: &str) -> Option<String> {
Some(match name {
"hostname" => self.hostname.to_string(),
"owner" => self.owner.to_string(),
"owner_did" => self.owner_did.to_string(),
"knot_did" => self.knot_did.to_string(),
"repos" | "repo_count" => self.repos.to_string(),
"users" | "user_count" | "owners" => self.users.to_string(),
"kaomoji" => self.kaomoji.to_string(),
"generation" => self.generation.to_string(),
_ => return None,
})
}
/// an unknown `{name}` stays as written
pub fn expand(&self, template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after
.find('}')
.and_then(|close| Some((close, self.get(&after[..close])?)))
{
Some((close, value)) => {
out.push_str(&value);
rest = &after[close + 1..];
}
None => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
}
/// the `[index]` templates, expanded
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page {
pub title: String,
pub subtitle: String,
pub description: String,
pub og_title: String,
pub og_subtitle: String,
pub og_description: String,
pub og_alt: String,
pub links: String,
pub fonts: Option<IndexFonts>,
}
impl Page {
pub fn new(index: &IndexConfig, vars: &Vars) -> Self {
let og_title = vars.expand(&index.og_title);
let og_subtitle = vars.expand(&index.og_subtitle);
let og_description = vars.expand(&index.og_description);
let og_alt = match &index.og_alt {
Some(alt) => vars.expand(alt),
None => [
og_title.clone(),
og_subtitle.clone(),
og_description.lines().collect::<Vec<_>>().join(" "),
]
.iter()
.map(|part| part.replace('*', "").trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" — "),
};
Self {
title: vars.expand(&index.title),
subtitle: vars.expand(&index.subtitle),
description: vars.expand(&index.description),
og_title,
og_subtitle,
og_description,
og_alt,
links: vars.expand(&index.links).trim_end_matches('/').to_string(),
fonts: index.fonts.clone(),
}
}
}
/// labels come from the handle cache only. a new owner shows as its did until post-activate runs.
pub async fn emit(
config: &ValidatedConfig,
manifest: &GenerationManifest,
build_dir: &Path,
) -> anyhow::Result<()> {
let chain_root = config.output_root.join("atproto");
let served: Vec<&RepoEntry> = manifest
.repos
.iter()
.filter(|entry| !entry.public_refs.is_empty())
.collect();
let owners: BTreeSet<&str> = served.iter().map(|entry| entry.owner.as_str()).collect();
let mut labelled = owners.clone();
labelled.insert(config.owner.as_str());
let labels = cached_owner_labels(&config.output_root, &labelled);
let descriptions = read_descriptions(&chain_root, &served).await?;
let activity = read_activity(config, &served);
let sections = group_sections_with_activity(&served, &labels, &descriptions, &activity);
let public_url = config.service_url()?;
let generation_id = manifest.generation_id.as_str();
let page = Page::new(
&config.index,
&Vars {
hostname: &manifest.hostname,
owner: &labels[config.owner.as_str()],
owner_did: config.owner.as_str(),
knot_did: config.knot_did.as_str(),
repos: served.len(),
users: sections.len(),
kaomoji: kaomoji_for(generation_id),
generation: &generation_id[..generation_id.len().min(16)],
},
);
let now = chrono::Utc::now();
let pushes = pulse::read(&config.output_root, now.date_naive());
let html = render(
&public_url,
&manifest.hostname,
generation_id,
§ions,
&page,
&Moment {
now: now.timestamp(),
pushes,
},
);
let og_image = og::render(&page.og_title, &page.og_subtitle, &page.og_description)?;
let www = build_dir.join("www");
for (name, bytes) in [
(INDEX_FILE, html.as_bytes()),
(FAVICON_SVG_FILE, FAVICON_SVG),
(FAVICON_PNG_FILE, FAVICON_PNG),
(APPLE_TOUCH_ICON_FILE, APPLE_TOUCH_ICON),
(OG_IMAGE_FILE, og_image.as_slice()),
] {
let path = www.join(name);
crate::cas::atomic_write(&path, bytes)
.with_context(|| format!("write {}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
pub fn group_sections(
entries: &[&RepoEntry],
owner_labels: &BTreeMap<String, String>,
descriptions: &BTreeMap<String, String>,
) -> Vec<PersonSection> {
group_sections_with_activity(entries, owner_labels, descriptions, &BTreeMap::new())
}
pub fn group_sections_with_activity(
entries: &[&RepoEntry],
owner_labels: &BTreeMap<String, String>,
descriptions: &BTreeMap<String, String>,
activity: &BTreeMap<String, i64>,
) -> Vec<PersonSection> {
let mut by_owner: BTreeMap<&str, Vec<(Option<i64>, ServedRepo)>> = BTreeMap::new();
for entry in entries {
if entry.public_refs.is_empty() {
continue;
}
let name = entry
.name
.as_deref()
.unwrap_or(entry.rkey.as_str())
.to_string();
let active_at = activity.get(&entry.repo_did).copied();
by_owner.entry(entry.owner.as_str()).or_default().push((
active_at,
ServedRepo {
did: entry.repo_did.clone(),
name,
description: descriptions.get(&entry.repo_did).cloned(),
active_at,
},
));
}
let mut sections: Vec<(Option<i64>, String, &str, Vec<ServedRepo>)> = by_owner
.into_iter()
.filter(|(_, repos)| !repos.is_empty())
.map(|(owner, mut repos)| {
repos.sort_by(|(ts_a, a), (ts_b, b)| {
ts_b.cmp(ts_a)
.then_with(|| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
})
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.did.cmp(&b.did))
});
let owner_active = repos.iter().filter_map(|(ts, _)| *ts).max();
let repos = repos.into_iter().map(|(_, repo)| repo).collect();
let label = owner_labels
.get(owner)
.cloned()
.unwrap_or_else(|| owner.to_string());
(owner_active, label, owner, repos)
})
.collect();
sections.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| a.1.cmp(&b.1))
.then_with(|| a.2.cmp(b.2))
});
sections
.into_iter()
.map(|(_, label, did, repos)| PersonSection {
label,
did: did.to_string(),
repos,
})
.collect()
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
/// `[label](https://...)` becomes a link. anything else, and any other scheme, stays text
fn render_links(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(open) = rest.find('[') {
out.push_str(&escape(&rest[..open]));
let after = &rest[open + 1..];
let link = after.find("](").and_then(|mid| {
let close = after[mid + 2..].find(')')? + mid + 2;
let href = &after[mid + 2..close];
let allowed = (href.starts_with("https://") || href.starts_with("http://"))
&& !href.contains(char::is_whitespace);
allowed.then(|| (&after[..mid], href, close))
});
match link {
Some((label, href, close)) => {
out.push_str(&format!(
"<a href=\"{}\">{}</a>",
escape(href),
escape(label)
));
rest = &after[close + 1..];
}
None => {
out.push('[');
rest = after;
}
}
}
out.push_str(&escape(rest));
out
}
fn render_hostname(hostname: &str) -> String {
let mut out = String::new();
for (idx, part) in hostname.split('.').enumerate() {
if idx > 0 {
out.push_str("<span>.</span>");
}
out.push_str(&escape(part));
}
out
}
/// when the page is drawn: ages count from `now`, and `pushes` fill the header's strip
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Moment {
/// unix seconds, 0 draws no ages
pub now: i64,
/// oldest first, empty draws no strip
pub pushes: Vec<(chrono::NaiveDate, u32)>,
}
/// owners with this many repos or fewer share the commons, once someone has a section of their own
const COMMONS_MAX: usize = 2;
const HUES: [&str; 5] = ["--accent", "--lav", "--teal", "--amber", "--sky"];
/// svfe's `authorColour`, so a person has one colour on both sites: fnv-1a from one past its
/// offset basis, which keeps the owners on the colours the design settled
pub fn hue(did: &str) -> &'static str {
HUES[hue_index(did)]
}
pub(crate) fn hue_index(did: &str) -> usize {
let mut hash: u32 = 0x811c_9dc6;
for byte in did.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash as usize % HUES.len()
}
/// the age a row shows, the page's script recounts it from `data-at`
pub fn age(now: i64, at: i64) -> String {
const HOUR: i64 = 3600;
const DAY: i64 = 24 * HOUR;
let secs = (now - at).max(0);
let (count, unit) = match secs {
s if s < HOUR => ((s / 60).max(1), "m"),
s if s < DAY => (s / HOUR, "h"),
s if s < 7 * DAY => (s / DAY, "d"),
s if s < 30 * DAY => (s / (7 * DAY), "w"),
s if s < 365 * DAY => (s / (30 * DAY), "mo"),
s => (s / (365 * DAY), "y"),
};
format!("{count}{unit}")
}
/// which of two columns each section starts in, the shorter one so far taking the next. the
/// page's script redoes this as sections unwind or filter
fn columns(sections: &[&PersonSection]) -> [Vec<usize>; 2] {
let mut columns: [Vec<usize>; 2] = Default::default();
let mut heights = [0; 2];
for (idx, section) in sections.iter().enumerate() {
let rows = section.repos.len().min(UNWIND_THRESHOLD);
let fold = if section.repos.len() > UNWIND_THRESHOLD {
3
} else {
0
};
let shorter = usize::from(heights[1] < heights[0]);
columns[shorter].push(idx);
heights[shorter] += 5 + 2 * rows + fold;
}
columns
}
fn render_pushes(pushes: &[(chrono::NaiveDate, u32)]) -> String {
if pushes.is_empty() {
return String::new();
}
let most = pushes
.iter()
.map(|(_, count)| *count)
.max()
.unwrap_or(0)
.max(1);
let mut out = String::from(
"<span class=\"pulse\" role=\"img\" aria-label=\"pushes per day, the last 30 days\">",
);
for (day, count) in pushes {
let when = day.format("%b %-d").to_string().to_lowercase();
let noun = if *count == 1 { "push" } else { "pushes" };
if *count == 0 {
out.push_str(&format!("<i class=\"z\" title=\"{when}: 0 pushes\"></i>"));
} else {
let height = 2 + (u64::from(*count) * 14).div_ceil(u64::from(most));
out.push_str(&format!(
"<i style=\"height:{height}px\" title=\"{when}: {count} {noun}\"></i>"
));
}
}
out.push_str("</span>");
out
}
/// a label is a handle, or the did when the handle didn't verify
fn is_did(label: &str) -> bool {
label.starts_with("did:")
}
fn handle_mark(label: &str) -> &'static str {
if is_did(label) { "" } else { "@" }
}
fn did_class(label: &str) -> &'static str {
if is_did(label) { " class=\"did\"" } else { "" }
}
struct Rows<'a> {
links: &'a str,
base: &'a str,
now: i64,
}
impl Rows<'_> {
/// `owner` is set in the commons, where each row names whose it is
fn row(
&self,
out: &mut String,
label: &str,
repo: &ServedRepo,
idx: usize,
count: usize,
owner: Option<&str>,
) {
let label_esc = escape(label);
let name = escape(&repo.name);
let git_url = format!("{}/{}", self.base, escape(&repo.did));
let class = if idx >= UNWIND_THRESHOLD {
" class=\"more\""
} else if count > UNWIND_THRESHOLD && idx + 1 == UNWIND_THRESHOLD {
" class=\"fold-last\""
} else {
""
};
let query = escape(&format!(
"{} {} {}",
label.to_ascii_lowercase(),
repo.name.to_ascii_lowercase(),
repo.description
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
));
let line = owner.map_or(String::new(), |did| {
format!(" style=\"--line:var({})\"", hue(did))
});
out.push_str(&format!(
"\n <li{class} data-q=\"{query}\"{line}><div class=\"row\">"
));
if owner.is_some() {
let at = handle_mark(label);
let class = if is_did(label) { "own did" } else { "own" };
out.push_str(&format!("<span class=\"{class}\">{at}{label_esc}/</span>"));
}
out.push_str(&format!(
"<a href=\"{}/{label_esc}/{name}\" data-t=\"{name}\">{name}</a>",
self.links
));
if let Some(desc) = &repo.description {
out.push_str(&format!("<div class=\"desc\">{}</div>", escape(desc)));
}
if let Some(at) = repo.active_at.filter(|_| self.now > 0) {
out.push_str(&format!(
"<span class=\"age\" data-at=\"{at}\">{}</span>",
age(self.now, at)
));
}
if let Some(desc) = repo
.description
.as_deref()
.filter(|desc| desc.chars().count() > POPOVER_CHAR_THRESHOLD)
{
out.push_str(&format!(
"<span class=\"pop\" role=\"tooltip\">{}</span>",
escape(desc)
));
}
out.push_str(&format!(
"<button class=\"clone\" type=\"button\" title=\"{git_url}\" aria-label=\"copy git url\" data-clone=\"{git_url}\" onclick=\"copy(this)\">{COPY_SVG}</button></div></li>"
));
}
fn fold(out: &mut String, id: &str, count: usize) {
if count > UNWIND_THRESHOLD {
let rest = count - UNWIND_THRESHOLD;
out.push_str(&format!(
"\n <label class=\"unwind\" for=\"u-{id}\"><span class=\"a\">{rest} more ⌄</span><span class=\"b\">wind up ⌃</span></label>"
));
}
}
fn repos_open(out: &mut String, id: &str, count: usize) {
if count > UNWIND_THRESHOLD {
out.push_str(&format!(
"\n <input class=\"u-chk\" type=\"checkbox\" id=\"u-{id}\">"
));
}
}
fn list_open(out: &mut String, count: usize) {
let folds = if count > UNWIND_THRESHOLD {
" folds"
} else {
""
};
out.push_str(&format!("\n <ul class=\"repos{folds}\">"));
}
}
pub fn render(
public_url: &url::Url,
hostname: &str,
generation_id: &str,
sections: &[PersonSection],
page: &Page,
moment: &Moment,
) -> String {
let title_esc = escape(&page.title);
let description_esc = escape(&page.description);
let subtitle_html = render_links(&page.subtitle);
let base_url = public_url.as_str().trim_end_matches('/');
let base_esc = escape(base_url);
let og_url_esc = format!("{base_esc}/");
let og_image_esc = format!("{base_esc}/{OG_IMAGE_FILE}");
let og_image_alt_esc = escape(&page.og_alt);
let links_esc = escape(&page.links);
let h1_inner = render_hostname(hostname);
let kaomoji = escape(kaomoji_for(generation_id));
let total_repos: usize = sections.iter().map(|s| s.repos.len()).sum();
let pulse_html = render_pushes(&moment.pushes);
let rows = Rows {
links: &links_esc,
base: &base_esc,
now: moment.now,
};
let (own, shared): (Vec<&PersonSection>, Vec<&PersonSection>) = sections
.iter()
.partition(|section| section.repos.len() > COMMONS_MAX);
let (own, shared) = if own.is_empty() {
(shared, Vec::new())
} else {
(own, shared)
};
let mut people_html = String::new();
for column in columns(&own) {
people_html.push_str("\n <div class=\"mc\">");
for idx in column {
let section = own[idx];
let owner = escape(§ion.label);
let at = handle_mark(§ion.label);
let count = section.repos.len();
let id = idx.to_string();
people_html.push_str(&format!(
"\n <section class=\"person\" id=\"{owner}\" data-group style=\"--line:var({});order:{idx}\">",
hue(§ion.did)
));
Rows::repos_open(&mut people_html, &id, count);
people_html.push_str(&format!(
"\n <header class=\"ph\"><a{} href=\"{links_esc}/{owner}\">{at}{owner}</a><span>{count}</span></header>",
did_class(§ion.label)
));
Rows::list_open(&mut people_html, count);
for (row_idx, repo) in section.repos.iter().enumerate() {
rows.row(&mut people_html, §ion.label, repo, row_idx, count, None);
}
people_html.push_str("\n </ul>");
Rows::fold(&mut people_html, &id, count);
people_html.push_str("\n </section>");
}
people_html.push_str("\n </div>");
}
let mut commons_html = String::new();
if !shared.is_empty() {
let mut shared_rows: Vec<(&PersonSection, &ServedRepo)> = shared
.iter()
.flat_map(|section| section.repos.iter().map(move |repo| (*section, repo)))
.collect();
shared_rows.sort_by(|(_, a), (_, b)| b.active_at.cmp(&a.active_at));
let count = shared_rows.len();
let people = if shared.len() == 1 {
"person"
} else {
"people"
};
commons_html.push_str("\n <section class=\"person commons\" id=\"commons\" data-group>");
Rows::repos_open(&mut commons_html, "c", count);
commons_html.push_str(&format!(
"\n <header class=\"ph\"><span>commons</span><span>{} {people}</span></header>",
shared.len()
));
Rows::list_open(&mut commons_html, count);
for (row_idx, (section, repo)) in shared_rows.iter().enumerate() {
rows.row(
&mut commons_html,
§ion.label,
repo,
row_idx,
count,
Some(§ion.did),
);
}
commons_html.push_str("\n </ul>");
Rows::fold(&mut commons_html, "c", count);
commons_html.push_str("\n </section>");
}
let fonts_head = fonts::head(
page.fonts.as_ref(),
fonts::Drawn {
did_heads: own.iter().any(|section| is_did(§ion.label)),
did_rows: shared.iter().any(|section| is_did(§ion.label)),
},
);
let faces_js: String = KAOMOJI
.iter()
.map(|face| serde_json::to_string(face).expect("serialize kaomoji"))
.collect::<Vec<_>>()
.join(",");
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<title>{title_esc}</title>
<meta name="description" content="{description_esc}">
<meta property="og:title" content="{title_esc}">
<meta property="og:description" content="{description_esc}">
<meta property="og:url" content="{og_url_esc}">
<meta property="og:site_name" content="{title_esc}">
<meta property="og:type" content="website">
<meta property="og:image" content="{og_image_esc}">
<meta property="og:image:width" content="{OG_IMAGE_WIDTH}">
<meta property="og:image:height" content="{OG_IMAGE_HEIGHT}">
<meta property="og:image:alt" content="{og_image_alt_esc}">
<meta name="twitter:card" content="summary_large_image">
<meta name="theme-color" content="{THEME_COLOR_LIGHT}" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="{THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)">
<style>{STYLE}</style>{fonts_head}
<noscript><style>.search,.clone{{display:none}}.repos li:hover .age{{visibility:visible}}</style></noscript>
<script>document.documentElement.classList.add("js")</script>
</head>
<body>
<main>
<header class="bar"><h1 class="host">{h1_inner}</h1><span class="face">「{subtitle_html} <span id="kaomoji">{kaomoji}</span>」</span>{pulse_html}<span class="sp"></span><label class="search"><svg class="mg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.25" y="2.25" width="8.5" height="8.5"/><path d="M10.5 10.5L14 14"/></svg><input id="q" type="search" placeholder="filter {total_repos} repos" autocomplete="off" spellcheck="false" aria-label="filter repos"><kbd>/</kbd></label></header>
<div id="people">{people_html}
</div>{commons_html}
<p id="empty" hidden>no matching repos ( ´•︵•` )</p>
<footer><span>just files, served by nginx ฅ^•ﻌ•^ฅ</span></footer>
</main>
<script>
const FACES = [{faces_js}];
{SCRIPT}</script>
</body>
</html>
"#
)
}