Skip to main content

qualia_client_core/
ontology_workbench.rs

1//! Ontology Hub workbench — URI import, `.c.q42` distribution, WebTorrent magnets, sharing.
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use sha1::{Digest as Sha1Digest, Sha1};
10use sha2::Sha256;
11
12use crate::q42_compress;
13use crate::resource_import;
14use crate::social_connect::ChatContact;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum ShareAudience {
19    Private,
20    AddressbookAll,
21    AddressbookCategory,
22    SpecificDids,
23    /// Solo chats and group sessions identified by `session_did`.
24    ChatSessions,
25    PublicSeed,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct OntologyTorrentPolicy {
30    pub seed_enabled: bool,
31    pub share_enabled: bool,
32    pub audience: ShareAudience,
33    #[serde(default)]
34    pub allowed_categories: Vec<String>,
35    #[serde(default)]
36    pub allowed_contact_dids: Vec<String>,
37    #[serde(default)]
38    pub allowed_session_dids: Vec<String>,
39    /// 0 = unlimited upload rate (KiB/s).
40    #[serde(default)]
41    pub bandwidth_limit_kbps: u32,
42    #[serde(default)]
43    pub max_upload_mb_per_day: Option<u32>,
44}
45
46impl Default for OntologyTorrentPolicy {
47    fn default() -> Self {
48        Self {
49            seed_enabled: false,
50            share_enabled: false,
51            audience: ShareAudience::Private,
52            allowed_categories: vec![],
53            allowed_contact_dids: vec![],
54            allowed_session_dids: vec![],
55            bandwidth_limit_kbps: 512,
56            max_upload_mb_per_day: Some(500),
57        }
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TorrentBandwidthGlobal {
63    #[serde(default)]
64    pub global_limit_kbps: u32,
65    #[serde(default = "default_metered")]
66    pub metered_mode: bool,
67}
68
69fn default_metered() -> bool {
70    true
71}
72
73impl Default for TorrentBandwidthGlobal {
74    fn default() -> Self {
75        Self {
76            global_limit_kbps: 1024,
77            metered_mode: true,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct OntologyWorkbenchEntry {
84    pub ontology_id: String,
85    pub title: String,
86    pub source_uri: String,
87    pub domain: String,
88    pub c_q42_path: String,
89    pub quin_count: u64,
90    pub sha256: String,
91    pub info_hash_sha1: String,
92    pub magnet_uri: String,
93    pub imported_at: u64,
94    pub torrent: OntologyTorrentPolicy,
95    pub seed_active: bool,
96    pub bytes_uploaded_total: u64,
97    pub bytes_uploaded_today: u64,
98    pub uploaded_day_epoch: u64,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WorkbenchImportResult {
103    pub entry: OntologyWorkbenchEntry,
104    pub compress_ratio: f64,
105    pub source_removed: bool,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct OntologyShareCard {
110    pub ontology_id: String,
111    pub title: String,
112    pub domain: String,
113    pub magnet_uri: String,
114    pub info_hash_sha1: String,
115    pub quin_count: u64,
116}
117
118fn workbench_path(storage_root: &Path) -> PathBuf {
119    resource_import::index_dir(storage_root).join("workbench.jsonl")
120}
121
122fn bandwidth_policy_path() -> PathBuf {
123    crate::state::app_meta_dir().join("torrent_bandwidth.json")
124}
125
126fn unix_now() -> u64 {
127    std::time::SystemTime::now()
128        .duration_since(std::time::UNIX_EPOCH)
129        .unwrap_or_default()
130        .as_secs()
131}
132
133fn day_epoch(ts: u64) -> u64 {
134    ts / 86_400
135}
136
137pub fn derive_ontology_id_from_uri(uri: &str) -> String {
138    let trimmed = uri.trim();
139    if let Ok(url) = url::Url::parse(trimmed) {
140        if let Some(segments) = url.path_segments() {
141            let last = segments
142                .filter(|s| !s.is_empty())
143                .last()
144                .unwrap_or("ontology");
145            let stem = Path::new(last)
146                .file_stem()
147                .and_then(|s| s.to_str())
148                .unwrap_or(last);
149            return sanitize_id(stem);
150        }
151        if let Some(host) = url.host_str() {
152            return sanitize_id(host);
153        }
154    }
155    sanitize_id(trimmed)
156}
157
158fn sanitize_id(raw: &str) -> String {
159    let lower = raw.to_lowercase();
160    let mut out = String::new();
161    for ch in lower.chars() {
162        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
163            out.push(ch);
164        } else if ch == '.' || ch == '/' {
165            out.push('-');
166        }
167    }
168    if out.is_empty() {
169        format!("ontology-{}", unix_now())
170    } else {
171        out.trim_matches('-').to_string()
172    }
173}
174
175fn extension_from_uri(uri: &str) -> String {
176    if let Ok(url) = url::Url::parse(uri) {
177        if let Some(path) = url.path_segments() {
178            if let Some(last) = path.filter(|s| !s.is_empty()).last() {
179                return Path::new(last)
180                    .extension()
181                    .and_then(|s| s.to_str())
182                    .unwrap_or("ttl")
183                    .to_lowercase();
184            }
185        }
186    }
187    "ttl".to_string()
188}
189
190fn sha256_file(path: &Path) -> Result<String, String> {
191    use sha2::Digest as Sha2Digest;
192    let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
193    let mut hasher = Sha256::new();
194    let mut buf = [0u8; 65_536];
195    loop {
196        let n = std::io::Read::read(&mut file, &mut buf).map_err(|e| e.to_string())?;
197        if n == 0 {
198            break;
199        }
200        hasher.update(&buf[..n]);
201    }
202    Ok(hex::encode(hasher.finalize()))
203}
204
205pub fn sha1_file(path: &Path) -> Result<String, String> {
206    let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
207    let mut hasher = Sha1::new();
208    let mut buf = [0u8; 8192];
209    loop {
210        let n = std::io::Read::read(&mut file, &mut buf).map_err(|e| e.to_string())?;
211        if n == 0 {
212            break;
213        }
214        hasher.update(&buf[..n]);
215    }
216    Ok(hasher
217        .finalize()
218        .iter()
219        .map(|b| format!("{b:02x}"))
220        .collect())
221}
222
223pub fn build_magnet_uri(info_hash_sha1: &str, display_name: &str) -> String {
224    qualia_core_db::webtorrent_seeder::build_magnet_uri(
225        info_hash_sha1,
226        display_name,
227        crate::api::get_active_daemon_port(),
228    )
229}
230
231fn daemon_base_url() -> String {
232    format!("http://127.0.0.1:{}", crate::api::get_active_daemon_port())
233}
234
235fn daemon_get(path: &str) -> Result<serde_json::Value, String> {
236    let client = reqwest::blocking::Client::builder()
237        .timeout(Duration::from_secs(6))
238        .build()
239        .map_err(|e| e.to_string())?;
240    let url = format!("{}{}", daemon_base_url(), path);
241    let resp = client.get(&url).send().map_err(|e| e.to_string())?;
242    if !resp.status().is_success() {
243        return Err(format!("daemon GET {path} returned {}", resp.status()));
244    }
245    resp.json().map_err(|e| e.to_string())
246}
247
248fn daemon_post(path: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
249    let client = reqwest::blocking::Client::builder()
250        .timeout(Duration::from_secs(8))
251        .build()
252        .map_err(|e| e.to_string())?;
253    let url = format!("{}{}", daemon_base_url(), path);
254    let resp = client
255        .post(&url)
256        .json(body)
257        .send()
258        .map_err(|e| e.to_string())?;
259    if !resp.status().is_success() {
260        return Err(format!("daemon POST {path} returned {}", resp.status()));
261    }
262    resp.json().map_err(|e| e.to_string())
263}
264
265fn sync_daemon_bandwidth_policy() {
266    let global = load_bandwidth_policy();
267    let body = serde_json::json!({
268        "global_limit_kbps": global.global_limit_kbps,
269        "metered_mode": global.metered_mode,
270    });
271    let _ = daemon_post("/torrent/policy", &body);
272}
273
274fn register_seed_on_daemon(entry: &OntologyWorkbenchEntry) -> Result<(), String> {
275    let body = serde_json::json!({
276        "info_hash": entry.info_hash_sha1,
277        "file_path": entry.c_q42_path,
278        "display_name": format!("{}.c.q42", entry.ontology_id),
279        "ontology_id": entry.ontology_id,
280        "bandwidth_limit_kbps": entry.torrent.bandwidth_limit_kbps,
281    });
282    daemon_post("/torrent/seed", &body)?;
283    Ok(())
284}
285
286fn unregister_seed_on_daemon(info_hash: &str) -> Result<(), String> {
287    let body = serde_json::json!({ "info_hash": info_hash });
288    let _ = daemon_post("/torrent/unseed", &body)?;
289    Ok(())
290}
291
292fn refresh_entry_magnet(entry: &mut OntologyWorkbenchEntry) {
293    let display = format!("{}.c.q42", entry.ontology_id);
294    entry.magnet_uri = qualia_core_db::webtorrent_seeder::ensure_magnet_webseed(
295        &entry.magnet_uri,
296        &entry.info_hash_sha1,
297        &display,
298        crate::api::get_active_daemon_port(),
299    );
300}
301
302fn load_entries(storage_root: &Path) -> Result<Vec<OntologyWorkbenchEntry>, String> {
303    let path = workbench_path(storage_root);
304    if !path.is_file() {
305        return Ok(vec![]);
306    }
307    let file = fs::File::open(path).map_err(|e| e.to_string())?;
308    let reader = BufReader::new(file);
309    let mut out = Vec::new();
310    for line in reader.lines() {
311        let line = line.map_err(|e| e.to_string())?;
312        if line.trim().is_empty() {
313            continue;
314        }
315        out.push(serde_json::from_str(&line).map_err(|e| e.to_string())?);
316    }
317    Ok(out)
318}
319
320fn write_entries(storage_root: &Path, entries: &[OntologyWorkbenchEntry]) -> Result<(), String> {
321    let path = workbench_path(storage_root);
322    if let Some(parent) = path.parent() {
323        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
324    }
325    let mut file = OpenOptions::new()
326        .create(true)
327        .write(true)
328        .truncate(true)
329        .open(path)
330        .map_err(|e| e.to_string())?;
331    for e in entries {
332        writeln!(
333            file,
334            "{}",
335            serde_json::to_string(e).map_err(|e| e.to_string())?
336        )
337        .map_err(|e| e.to_string())?;
338    }
339    Ok(())
340}
341
342pub fn load_bandwidth_policy() -> TorrentBandwidthGlobal {
343    let path = bandwidth_policy_path();
344    if let Ok(text) = fs::read_to_string(path) {
345        if let Ok(p) = serde_json::from_str(&text) {
346            return p;
347        }
348    }
349    TorrentBandwidthGlobal::default()
350}
351
352pub fn save_bandwidth_policy(policy: &TorrentBandwidthGlobal) -> Result<(), String> {
353    let path = bandwidth_policy_path();
354    if let Some(parent) = path.parent() {
355        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
356    }
357    let text = serde_json::to_string_pretty(policy).map_err(|e| e.to_string())?;
358    fs::write(path, text).map_err(|e| e.to_string())?;
359    sync_daemon_bandwidth_policy();
360    Ok(())
361}
362
363pub fn contact_eligible_for_share(entry: &OntologyWorkbenchEntry, contact: &ChatContact) -> bool {
364    if !entry.torrent.share_enabled {
365        return false;
366    }
367    match entry.torrent.audience {
368        ShareAudience::Private => false,
369        ShareAudience::PublicSeed | ShareAudience::AddressbookAll => true,
370        ShareAudience::SpecificDids => entry
371            .torrent
372            .allowed_contact_dids
373            .iter()
374            .any(|d| d == &contact.did),
375        ShareAudience::AddressbookCategory => entry
376            .torrent
377            .allowed_categories
378            .iter()
379            .any(|cat| contact.categories.iter().any(|c| c == cat)),
380        ShareAudience::ChatSessions => false,
381    }
382}
383
384pub fn session_eligible_for_share(entry: &OntologyWorkbenchEntry, session_did: &str) -> bool {
385    if !entry.torrent.share_enabled || session_did.is_empty() {
386        return false;
387    }
388    match entry.torrent.audience {
389        ShareAudience::Private => false,
390        ShareAudience::PublicSeed | ShareAudience::AddressbookAll => true,
391        ShareAudience::ChatSessions => entry
392            .torrent
393            .allowed_session_dids
394            .iter()
395            .any(|d| d == session_did),
396        ShareAudience::SpecificDids | ShareAudience::AddressbookCategory => false,
397    }
398}
399
400pub fn list_share_cards_for_contact(
401    storage_root: &Path,
402    contact_did: &str,
403) -> Result<Vec<OntologyShareCard>, String> {
404    let contact = crate::social_connect::find_contact_by_did(contact_did)
405        .ok_or_else(|| format!("Contact not found: {contact_did}"))?;
406    let entries = load_entries(storage_root)?;
407    Ok(entries
408        .iter()
409        .filter(|e| contact_eligible_for_share(e, &contact))
410        .map(|e| OntologyShareCard {
411            ontology_id: e.ontology_id.clone(),
412            title: e.title.clone(),
413            domain: e.domain.clone(),
414            magnet_uri: e.magnet_uri.clone(),
415            info_hash_sha1: e.info_hash_sha1.clone(),
416            quin_count: e.quin_count,
417        })
418        .collect())
419}
420
421pub fn list_share_cards_for_session(
422    storage_root: &Path,
423    session_did: &str,
424) -> Result<Vec<OntologyShareCard>, String> {
425    if session_did.trim().is_empty() {
426        return Err("session_did required".to_string());
427    }
428    let entries = load_entries(storage_root)?;
429    Ok(entries
430        .iter()
431        .filter(|e| session_eligible_for_share(e, session_did))
432        .map(|e| OntologyShareCard {
433            ontology_id: e.ontology_id.clone(),
434            title: e.title.clone(),
435            domain: e.domain.clone(),
436            magnet_uri: e.magnet_uri.clone(),
437            info_hash_sha1: e.info_hash_sha1.clone(),
438            quin_count: e.quin_count,
439        })
440        .collect())
441}
442
443pub async fn import_from_uri(
444    storage_root: &Path,
445    uri: String,
446    ontology_id: Option<String>,
447    domain: Option<String>,
448    title: Option<String>,
449) -> Result<WorkbenchImportResult, String> {
450    let uri = uri.trim().to_string();
451    if uri.is_empty() {
452        return Err("URI required".to_string());
453    }
454    if !uri.starts_with("http://") && !uri.starts_with("https://") {
455        return Err("Only http(s) URIs are supported".to_string());
456    }
457
458    let id = ontology_id
459        .map(|s| sanitize_id(&s))
460        .filter(|s| !s.is_empty())
461        .unwrap_or_else(|| derive_ontology_id_from_uri(&uri));
462    let ext = extension_from_uri(&uri);
463    let index = resource_import::index_dir(storage_root);
464    fs::create_dir_all(&index).map_err(|e| e.to_string())?;
465
466    let source_path = index.join(format!("{id}.source.{ext}"));
467    resource_import::stream_download(&uri, &source_path)
468        .await
469        .map_err(|e| e.to_string())?;
470
471    let quin_count = resource_import::ingest_local_rdf(&source_path, &id, storage_root, None)
472        .map_err(|e| e.to_string())?;
473
474    let q42_path = index.join(format!("{id}.q42"));
475    let c_q42_path = index.join(format!("{id}.c.q42"));
476    let stats = q42_compress::finalize_c_q42(&q42_path, &c_q42_path)?;
477
478    let source_removed = fs::remove_file(&source_path).is_ok();
479    let _ = fs::remove_file(&q42_path);
480
481    let sha256 = sha256_file(&c_q42_path)?;
482    let info_hash = sha1_file(&c_q42_path)?;
483    let display = format!("{id}.c.q42");
484    let magnet = build_magnet_uri(&info_hash, &display);
485    let now = unix_now();
486    let domain_label = domain.unwrap_or_else(|| "general".to_string());
487    let title_label = title.unwrap_or_else(|| id.clone());
488
489    let entry = OntologyWorkbenchEntry {
490        ontology_id: id.clone(),
491        title: title_label,
492        source_uri: uri,
493        domain: domain_label,
494        c_q42_path: c_q42_path.to_string_lossy().into_owned(),
495        quin_count,
496        sha256,
497        info_hash_sha1: info_hash,
498        magnet_uri: magnet,
499        imported_at: now,
500        torrent: OntologyTorrentPolicy::default(),
501        seed_active: false,
502        bytes_uploaded_total: 0,
503        bytes_uploaded_today: 0,
504        uploaded_day_epoch: day_epoch(now),
505    };
506
507    let mut entries = load_entries(storage_root)?;
508    entries.retain(|e| e.ontology_id != id);
509    entries.push(entry.clone());
510    write_entries(storage_root, &entries)?;
511
512    let meta_path = index.join(format!("{id}.c.q42.meta.json"));
513    let meta = serde_json::json!({
514        "ontology_id": id,
515        "source_uri": entry.source_uri,
516        "c_q42_path": entry.c_q42_path,
517        "quin_count": quin_count,
518        "sha256": entry.sha256,
519        "magnet_uri": entry.magnet_uri,
520        "imported_at": now,
521    });
522    fs::write(
523        meta_path,
524        serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?,
525    )
526    .map_err(|e| e.to_string())?;
527
528    Ok(WorkbenchImportResult {
529        entry,
530        compress_ratio: stats.ratio,
531        source_removed,
532    })
533}
534
535pub fn list_workbench_entries(storage_root: &Path) -> Result<Vec<OntologyWorkbenchEntry>, String> {
536    load_entries(storage_root)
537}
538
539pub fn set_torrent_policy(
540    storage_root: &Path,
541    ontology_id: &str,
542    policy: OntologyTorrentPolicy,
543) -> Result<OntologyWorkbenchEntry, String> {
544    let mut entries = load_entries(storage_root)?;
545    let idx = entries
546        .iter()
547        .position(|e| e.ontology_id == ontology_id)
548        .ok_or_else(|| format!("Workbench entry not found: {ontology_id}"))?;
549    entries[idx].torrent = policy.clone();
550    if policy.seed_enabled && entries[idx].seed_active {
551        refresh_entry_magnet(&mut entries[idx]);
552        let _ = register_seed_on_daemon(&entries[idx]);
553    }
554    let updated = entries[idx].clone();
555    write_entries(storage_root, &entries)?;
556    Ok(updated)
557}
558
559pub fn set_seed_active(
560    storage_root: &Path,
561    ontology_id: &str,
562    active: bool,
563) -> Result<OntologyWorkbenchEntry, String> {
564    let mut entries = load_entries(storage_root)?;
565    let idx = entries
566        .iter()
567        .position(|e| e.ontology_id == ontology_id)
568        .ok_or_else(|| format!("Workbench entry not found: {ontology_id}"))?;
569    let should_seed = active && entries[idx].torrent.seed_enabled;
570    entries[idx].seed_active = should_seed;
571    refresh_entry_magnet(&mut entries[idx]);
572    if should_seed {
573        register_seed_on_daemon(&entries[idx])?;
574    } else {
575        let _ = unregister_seed_on_daemon(&entries[idx].info_hash_sha1);
576    }
577    let updated = entries[idx].clone();
578    write_entries(storage_root, &entries)?;
579    Ok(updated)
580}
581
582fn effective_limit_kbps(entry: &OntologyWorkbenchEntry, global: &TorrentBandwidthGlobal) -> u32 {
583    let local = entry.torrent.bandwidth_limit_kbps;
584    let global_limit = global.global_limit_kbps;
585    match (local, global_limit) {
586        (0, 0) => 0,
587        (0, g) => g,
588        (l, 0) => l,
589        (l, g) => l.min(g),
590    }
591}
592
593pub fn torrent_telemetry(storage_root: &Path) -> serde_json::Value {
594    if let Ok(remote) = daemon_get("/torrent/telemetry") {
595        return remote;
596    }
597
598    let entries = load_entries(storage_root).unwrap_or_default();
599    let global = load_bandwidth_policy();
600    let active: Vec<_> = entries.iter().filter(|e| e.seed_active).collect();
601    let seeders = active.len();
602    let total_up: u64 = active.iter().map(|e| e.bytes_uploaded_today).sum();
603    let limit = active
604        .first()
605        .map(|e| effective_limit_kbps(e, &global))
606        .unwrap_or(global.global_limit_kbps);
607    let speed = if limit == 0 {
608        "unlimited".to_string()
609    } else {
610        format!("{limit} KiB/s cap")
611    };
612    serde_json::json!({
613        "seeders": seeders,
614        "leechers": 0,
615        "speed": speed,
616        "status": if seeders > 0 { "seeding" } else { "idle" },
617        "uploaded_today_kb": total_up / 1024,
618        "active_ontologies": active.iter().map(|e| &e.ontology_id).collect::<Vec<_>>(),
619        "global_bandwidth_kbps": global.global_limit_kbps,
620        "metered_mode": global.metered_mode,
621        "seeder": "qualia-daemon (unreachable)",
622    })
623}
624
625/// Push workbench active seeds to the Qualia daemon (call after daemon boot).
626pub fn sync_workbench_seeds_to_daemon(storage_root: &Path) -> Result<serde_json::Value, String> {
627    sync_daemon_bandwidth_policy();
628    let entries = load_entries(storage_root)?;
629    let mut registered = 0usize;
630    for entry in &entries {
631        if entry.seed_active && entry.torrent.seed_enabled {
632            register_seed_on_daemon(entry)?;
633            registered += 1;
634        }
635    }
636    daemon_post("/torrent/sync", &serde_json::json!({})).map(|v| {
637        serde_json::json!({
638            "registered": registered,
639            "daemon": v,
640        })
641    })
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn derive_id_from_uri() {
650        let id = derive_ontology_id_from_uri("https://example.org/vocab/foaf.ttl");
651        assert!(id.contains("foaf"));
652    }
653
654    #[test]
655    fn magnet_format() {
656        let m = build_magnet_uri("abc123", "wordnet.c.q42");
657        assert!(m.starts_with("magnet:?xt=urn:btih:abc123"));
658        assert!(m.contains("&ws="));
659        assert!(m.contains("webseed"));
660    }
661
662    #[test]
663    fn session_share_eligibility() {
664        let did = "did:qualia:chat:group:abc123";
665        let mut entry = OntologyWorkbenchEntry {
666            ontology_id: "wordnet".into(),
667            title: "WordNet".into(),
668            source_uri: "https://example.org".into(),
669            domain: "lexicon".into(),
670            c_q42_path: "/tmp/wordnet.c.q42".into(),
671            quin_count: 100,
672            sha256: "sha".into(),
673            info_hash_sha1: "ih".into(),
674            magnet_uri: "magnet:?xt=urn:btih:ih".into(),
675            imported_at: 0,
676            torrent: OntologyTorrentPolicy {
677                share_enabled: true,
678                audience: ShareAudience::ChatSessions,
679                allowed_session_dids: vec![did.to_string()],
680                ..OntologyTorrentPolicy::default()
681            },
682            seed_active: false,
683            bytes_uploaded_total: 0,
684            bytes_uploaded_today: 0,
685            uploaded_day_epoch: 0,
686        };
687        assert!(session_eligible_for_share(&entry, did));
688        assert!(!session_eligible_for_share(
689            &entry,
690            "did:qualia:chat:solo:other"
691        ));
692        entry.torrent.share_enabled = false;
693        assert!(!session_eligible_for_share(&entry, did));
694    }
695}