clipmem 0.2.5

macOS clipboard memory backed by SQLite and searchable from OpenClaw
Documentation
use std::collections::HashSet;

use serde::Serialize;

use super::builders::{
    html_to_text_lossy, normalise_whitespace, rtf_to_text_lossy, truncate_chars,
};
use super::{ClipboardItem, ClipboardKind};

const TEXT_SUMMARY_LIMIT: usize = 320;

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TextFragment {
    text: String,
    uti: String,
    kind: ClipboardKind,
    item_index: usize,
    representation_index: usize,
}

#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct FlattenedTextProjection {
    best_text: String,
    best_text_uti: Option<String>,
    text_fragments: Vec<TextFragment>,
    urls: Vec<String>,
    file_paths: Vec<String>,
    html_text: Option<String>,
    rtf_text: Option<String>,
    text_summary: String,
}

#[derive(Debug, Clone)]
struct BestTextCandidate {
    priority: u8,
    text: String,
    uti: String,
}

impl TextFragment {
    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }

    #[must_use]
    pub fn uti(&self) -> &str {
        &self.uti
    }

    #[must_use]
    pub fn kind(&self) -> ClipboardKind {
        self.kind
    }

    #[must_use]
    pub fn item_index(&self) -> usize {
        self.item_index
    }

    #[must_use]
    pub fn representation_index(&self) -> usize {
        self.representation_index
    }
}

impl FlattenedTextProjection {
    #[must_use]
    pub fn from_items(items: &[ClipboardItem]) -> Self {
        let mut fragments = Vec::new();
        let mut fragment_keys = HashSet::new();
        let mut urls = Vec::new();
        let mut url_keys = HashSet::new();
        let mut file_paths = Vec::new();
        let mut file_path_keys = HashSet::new();
        let mut html_fragments = Vec::new();
        let mut html_keys = HashSet::new();
        let mut rtf_fragments = Vec::new();
        let mut rtf_keys = HashSet::new();
        let mut best_candidate: Option<BestTextCandidate> = None;

        for item in items {
            for (representation_index, representation) in item.representations().iter().enumerate()
            {
                let Some(text_value) = representation.text_value() else {
                    continue;
                };

                let Some(normalized_text) = normalize_nonempty_text(text_value) else {
                    continue;
                };
                let projected_text =
                    projected_fragment_text(representation.kind(), &normalized_text);
                let Some(fragment_text) = normalize_nonempty_text(&projected_text) else {
                    continue;
                };

                if let Some(priority) = best_text_priority(representation.kind(), &fragment_text) {
                    consider_best_candidate(
                        &mut best_candidate,
                        BestTextCandidate {
                            priority,
                            text: fragment_text.clone(),
                            uti: representation.uti().to_string(),
                        },
                    );
                }

                let fragment_key = fragment_text.to_lowercase();
                if fragment_keys.insert(fragment_key) {
                    fragments.push(TextFragment {
                        text: fragment_text.clone(),
                        uti: representation.uti().to_string(),
                        kind: representation.kind(),
                        item_index: item.item_index(),
                        representation_index,
                    });
                }

                match representation.kind() {
                    ClipboardKind::Url => {
                        push_distinct(&mut urls, &mut url_keys, normalized_text.clone())
                    }
                    ClipboardKind::FileUrl => {
                        let decoded_path = decode_file_url_path(&normalized_text);
                        push_distinct(&mut file_paths, &mut file_path_keys, decoded_path);
                    }
                    ClipboardKind::Html => {
                        let plain = html_to_text_lossy(&normalized_text);
                        if let Some(normalized_plain) = normalize_nonempty_text(&plain) {
                            push_distinct(&mut html_fragments, &mut html_keys, normalized_plain);
                        }
                    }
                    ClipboardKind::Rtf => {
                        let plain = rtf_to_text_lossy(&normalized_text);
                        if let Some(normalized_plain) = normalize_nonempty_text(&plain) {
                            push_distinct(&mut rtf_fragments, &mut rtf_keys, normalized_plain);
                        }
                    }
                    _ => {}
                }
            }
        }

        let best_text = best_candidate
            .as_ref()
            .map(|candidate| candidate.text.clone())
            .unwrap_or_default();
        let best_text_uti = best_candidate.map(|candidate| candidate.uti);
        let html_text = join_optional_fragments(&html_fragments);
        let rtf_text = join_optional_fragments(&rtf_fragments);
        let text_summary = truncate_chars(&fragments_text_summary(&fragments), TEXT_SUMMARY_LIMIT);

        Self {
            best_text,
            best_text_uti,
            text_fragments: fragments,
            urls,
            file_paths,
            html_text,
            rtf_text,
            text_summary,
        }
    }

    #[must_use]
    pub fn best_text(&self) -> &str {
        &self.best_text
    }

    #[must_use]
    pub fn best_text_uti(&self) -> Option<&str> {
        self.best_text_uti.as_deref()
    }

    #[must_use]
    pub fn text_fragments(&self) -> &[TextFragment] {
        &self.text_fragments
    }

    #[must_use]
    pub fn urls(&self) -> &[String] {
        &self.urls
    }

    #[must_use]
    pub fn file_paths(&self) -> &[String] {
        &self.file_paths
    }

    #[must_use]
    pub fn html_text(&self) -> Option<&str> {
        self.html_text.as_deref()
    }

    #[must_use]
    pub fn rtf_text(&self) -> Option<&str> {
        self.rtf_text.as_deref()
    }

    #[must_use]
    pub fn text_summary(&self) -> &str {
        &self.text_summary
    }
}

fn projected_fragment_text(kind: ClipboardKind, normalized_text: &str) -> String {
    match kind {
        ClipboardKind::Html => html_to_text_lossy(normalized_text),
        ClipboardKind::Rtf => rtf_to_text_lossy(normalized_text),
        _ => normalized_text.to_string(),
    }
}

fn normalize_nonempty_text(text: &str) -> Option<String> {
    let normalized = normalise_whitespace(text);
    let trimmed = normalized.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_string())
}

fn push_distinct(values: &mut Vec<String>, seen: &mut HashSet<String>, candidate: String) {
    let normalized = normalize_nonempty_text(&candidate).unwrap_or_default();
    if normalized.is_empty() {
        return;
    }

    let key = normalized.to_lowercase();
    if seen.insert(key) {
        values.push(normalized);
    }
}

fn join_optional_fragments(fragments: &[String]) -> Option<String> {
    (!fragments.is_empty()).then(|| fragments.join("\n\n"))
}

fn fragments_text_summary(fragments: &[TextFragment]) -> String {
    fragments
        .iter()
        .map(|fragment| fragment.text().to_string())
        .collect::<Vec<_>>()
        .join("\n\n")
}

fn consider_best_candidate(current: &mut Option<BestTextCandidate>, candidate: BestTextCandidate) {
    match current {
        Some(existing) if existing.priority <= candidate.priority => {}
        slot => *slot = Some(candidate),
    }
}

fn best_text_priority(kind: ClipboardKind, text: &str) -> Option<u8> {
    if text.is_empty() {
        return None;
    }

    Some(match kind {
        ClipboardKind::PlainText => 0,
        ClipboardKind::Json | ClipboardKind::Xml => 1,
        ClipboardKind::Html => 2,
        ClipboardKind::Rtf => 3,
        ClipboardKind::Url | ClipboardKind::FileUrl => 4,
        _ if kind.is_textual() => 1,
        _ => return None,
    })
}

fn decode_file_url_path(file_url: &str) -> String {
    const PREFIX: &str = "file://";
    if let Some(remainder) = file_url.strip_prefix(PREFIX) {
        percent_decode(remainder)
    } else {
        file_url.to_string()
    }
}

fn percent_decode(value: &str) -> String {
    let bytes = value.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut index = 0;

    while index < bytes.len() {
        if bytes[index] == b'%' && index + 2 < bytes.len() {
            let hex = &value[index + 1..index + 3];
            if let Ok(byte) = u8::from_str_radix(hex, 16) {
                out.push(byte);
                index += 3;
                continue;
            }
        }

        out.push(bytes[index]);
        index += 1;
    }

    String::from_utf8(out).unwrap_or_else(|_| value.to_string())
}

#[cfg(test)]
mod tests {
    use super::FlattenedTextProjection;
    use crate::model::{build_item, build_representation, ClipboardKind};

    #[test]
    fn projection_prefers_plain_text_and_collects_urls_and_paths() {
        let item = build_item(
            0,
            vec![
                build_representation(
                    "public.url".to_string(),
                    Some("https://example.com".to_string()),
                    b"https://example.com".to_vec(),
                ),
                build_representation(
                    "public.file-url".to_string(),
                    Some("file:///Users/test/Report%20Q2.txt".to_string()),
                    b"file:///Users/test/Report%20Q2.txt".to_vec(),
                ),
                build_representation(
                    "public.utf8-plain-text".to_string(),
                    Some("git status".to_string()),
                    b"git status".to_vec(),
                ),
            ],
        );

        let projection = FlattenedTextProjection::from_items(&[item]);

        assert_eq!(projection.best_text(), "git status");
        assert_eq!(projection.best_text_uti(), Some("public.utf8-plain-text"));
        assert_eq!(projection.urls(), &["https://example.com".to_string()]);
        assert_eq!(
            projection.file_paths(),
            &["/Users/test/Report Q2.txt".to_string()]
        );
        assert_eq!(projection.text_fragments().len(), 3);
    }

    #[test]
    fn projection_extracts_html_and_rtf_plain_text() {
        let item = build_item(
            0,
            vec![
                build_representation(
                    "public.html".to_string(),
                    Some("<p>Hello <strong>world</strong></p>".to_string()),
                    b"<p>Hello <strong>world</strong></p>".to_vec(),
                ),
                build_representation(
                    "public.rtf".to_string(),
                    Some(r"{\rtf1\ansi hello\par world}".to_string()),
                    br"{\rtf1\ansi hello\par world}".to_vec(),
                ),
            ],
        );

        let projection = FlattenedTextProjection::from_items(&[item]);

        assert_eq!(projection.best_text_uti(), Some("public.html"));
        assert_eq!(projection.html_text(), Some("Hello world"));
        assert_eq!(projection.rtf_text(), Some("hello world"));
        assert!(!projection.text_summary().is_empty());
    }

    #[test]
    fn projection_dedupes_text_fragments_preserving_first_provenance() {
        let item = build_item(
            0,
            vec![
                build_representation(
                    "public.utf8-plain-text".to_string(),
                    Some(" Git   Status ".to_string()),
                    b" Git   Status ".to_vec(),
                ),
                build_representation(
                    "public.json".to_string(),
                    Some("git status".to_string()),
                    b"git status".to_vec(),
                ),
            ],
        );

        let projection = FlattenedTextProjection::from_items(&[item]);

        assert_eq!(projection.text_fragments().len(), 1);
        assert_eq!(
            projection.text_fragments()[0].kind(),
            ClipboardKind::PlainText
        );
        assert_eq!(projection.text_fragments()[0].representation_index(), 0);
    }
}