Use rich text format for chat

This commit is contained in:
oxalica 2024-08-30 12:22:19 -04:00
parent 4d3371e485
commit c492bb2537
8 changed files with 361 additions and 28 deletions

75
Cargo.lock generated
View file

@ -107,7 +107,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -231,6 +231,7 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"futures-util", "futures-util",
"hex", "hex",
"html-escape",
"humantime", "humantime",
"rand_core", "rand_core",
"rusqlite", "rusqlite",
@ -239,7 +240,8 @@ dependencies = [
"serde-aux", "serde-aux",
"serde-constant", "serde-constant",
"serde_json", "serde_json",
"syn", "serde_tuple",
"syn 2.0.76",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower-http", "tower-http",
@ -350,7 +352,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -430,7 +432,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -583,7 +585,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -697,6 +699,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.1.0" version = "1.1.0"
@ -1040,7 +1051,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -1099,7 +1110,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -1421,7 +1432,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -1446,6 +1457,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_tuple"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f025b91216f15a2a32aa39669329a475733590a015835d1783549a56d09427"
dependencies = [
"serde",
"serde_tuple_macros",
]
[[package]]
name = "serde_tuple_macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4076151d1a2b688e25aaf236997933c66e18b870d0369f8b248b8ab2be630d7e"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -1547,6 +1579,17 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.76" version = "2.0.76"
@ -1656,7 +1699,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -1769,7 +1812,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]
@ -1857,6 +1900,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@ -1928,7 +1977,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -1962,7 +2011,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -2144,7 +2193,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.76",
] ]
[[package]] [[package]]

View file

@ -13,6 +13,7 @@ clap = { version = "4.5.16", features = ["derive"] }
ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] } ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] }
futures-util = "0.3.30" futures-util = "0.3.30"
hex = { version = "0.4.3", features = ["serde"] } hex = { version = "0.4.3", features = ["serde"] }
html-escape = "0.2.13"
humantime = "2.1.0" humantime = "2.1.0"
rand_core = "0.6.4" rand_core = "0.6.4"
rusqlite = { version = "0.32.1", features = ["uuid"] } rusqlite = { version = "0.32.1", features = ["uuid"] }
@ -21,6 +22,7 @@ serde = { version = "1.0.209", features = ["derive"] }
serde-aux = "4.5.0" serde-aux = "4.5.0"
serde-constant = "0.1.0" serde-constant = "0.1.0"
serde_json = "1.0.127" serde_json = "1.0.127"
serde_tuple = "0.5.0"
tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] } tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] }
tokio-stream = { version = "0.1.15", features = ["sync"] } tokio-stream = { version = "0.1.15", features = ["sync"] }
tower-http = { version = "0.5.2", features = ["cors", "limit"] } tower-http = { version = "0.5.2", features = ["cors", "limit"] }

View file

@ -5,8 +5,8 @@ use std::{fs, io};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bitflags::Flags; use bitflags::Flags;
use blah::types::{ use blah::types::{
ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, RoomMember, RoomMemberList, ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, RoomMember,
ServerPermission, UserKey, WithSig, RoomMemberList, ServerPermission, UserKey, WithSig,
}; };
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
@ -238,7 +238,10 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
text, text,
} => { } => {
let key = load_signing_key(&private_key_file)?; let key = load_signing_key(&private_key_file)?;
let payload = ChatPayload { room, text }; let payload = ChatPayload {
room,
rich_text: RichText::from(text),
};
let payload = WithSig::sign(&key, &mut OsRng, payload)?; let payload = WithSig::sign(&key, &mut OsRng, payload)?;
let ret = client let ret = client

View file

@ -83,7 +83,7 @@ paths:
payload: payload:
typ: chat typ: chat
room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd
text: helloo rich_text: [["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]]
timestamp: 1724966284 timestamp: 1724966284
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
responses: responses:

View file

@ -28,5 +28,5 @@ CREATE TABLE IF NOT EXISTS `room_item` (
`timestamp` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL,
`nonce` INTEGER NOT NULL, `nonce` INTEGER NOT NULL,
`sig` BLOB NOT NULL, `sig` BLOB NOT NULL,
`message` TEXT NOT NULL `rich_text` TEXT NOT NULL
) STRICT; ) STRICT;

View file

@ -111,10 +111,47 @@ async function showChatMsg(chat) {
const el = document.createElement('div', {}); const el = document.createElement('div', {});
el.classList.add('msg'); el.classList.add('msg');
el.innerText = `${shortUser} [${time}] [${verifyRet}]: ${chat.signee.payload.text}`; const elHeader = document.createElement('span', {});
const elContent = document.createElement('span', {});
elHeader.innerText = `${shortUser} [${time}] [${verifyRet}]:`;
elContent.innerHTML = richTextToHtml(chat.signee.payload.rich_text);
el.appendChild(elHeader);
el.appendChild(elContent);
appendMsg(el) appendMsg(el)
} }
function richTextToHtml(richText) {
let ret = ''
for (let [text, attrs] of richText) {
if (attrs === undefined) attrs = {};
// Incomplete cases.
const tags = [
[attrs.b, 'b'],
[attrs.i, 'i'],
[attrs.m, 'code'],
[attrs.s, 'strike'],
[attrs.u, 'u'],
];
for (const [cond, tag] of tags) {
if (cond) ret += `<${tag}>`;
}
ret += escapeHtml(text);
tags.reverse();
for (const [cond, tag] of tags) {
if (cond) ret += `</${tag}>`;
}
}
return ret;
}
function escapeHtml(text) {
return text.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
async function connectRoom(url) { async function connectRoom(url) {
if (url === '' || url == roomUrl) return; if (url === '' || url == roomUrl) return;
const match = url.match(/^https?:\/\/.*\/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\/?/); const match = url.match(/^https?:\/\/.*\/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\/?/);
@ -200,10 +237,16 @@ async function postChat(text) {
chatInput.disabled = true; chatInput.disabled = true;
try { try {
let richText;
if (text.startsWith('[')) {
richText = JSON.parse(text);
} else {
richText = [[text]];
}
const signedPayload = await signData({ const signedPayload = await signData({
typ: 'chat', typ: 'chat',
rich_text: richText,
room: roomUuid, room: roomUuid,
text,
}); });
const resp = await fetch(`${roomUrl}/item`, { const resp = await fetch(`${roomUrl}/item`, {
method: 'POST', method: 'POST',

View file

@ -247,7 +247,7 @@ async fn room_get_feed(
}; };
FeedItem { FeedItem {
id: cid.to_string(), id: cid.to_string(),
content_text: item.signee.payload.text, content_html: item.signee.payload.rich_text.html().to_string(),
date_published: humantime::format_rfc3339(time).to_string(), date_published: humantime::format_rfc3339(time).to_string(),
authors: (author,), authors: (author,),
extra: FeedItemExtra { extra: FeedItemExtra {
@ -292,7 +292,7 @@ struct FeedRoom {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct FeedItem { struct FeedItem {
id: String, id: String,
content_text: String, content_html: String,
date_published: String, date_published: String,
authors: (FeedAuthor,), authors: (FeedAuthor,),
#[serde(rename = "_blah")] #[serde(rename = "_blah")]
@ -363,7 +363,7 @@ fn query_room_items(
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r" r"
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `message` SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
FROM `room_item` FROM `room_item`
JOIN `user` USING (`uid`) JOIN `user` USING (`uid`)
WHERE `rid` = :rid AND WHERE `rid` = :rid AND
@ -389,7 +389,7 @@ fn query_room_items(
user: row.get("userkey")?, user: row.get("userkey")?,
payload: ChatPayload { payload: ChatPayload {
room: ruuid, room: ruuid,
text: row.get("message")?, rich_text: row.get("rich_text")?,
}, },
}, },
}; };
@ -492,8 +492,8 @@ async fn room_post_item(
let cid = conn let cid = conn
.query_row( .query_row(
r" r"
INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `message`) INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`)
VALUES (:rid, :uid, :timestamp, :nonce, :sig, :message) VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text)
RETURNING `cid` RETURNING `cid`
", ",
named_params! { named_params! {
@ -501,7 +501,7 @@ async fn room_post_item(
":uid": uid, ":uid": uid,
":timestamp": chat.signee.timestamp, ":timestamp": chat.signee.timestamp,
":nonce": chat.signee.nonce, ":nonce": chat.signee.nonce,
":message": &chat.signee.payload.text, ":rich_text": &chat.signee.payload.rich_text,
":sig": chat.sig, ":sig": chat.sig,
}, },
|row| row.get::<_, u64>(0), |row| row.get::<_, u64>(0),

View file

@ -12,7 +12,8 @@ use ed25519_dalek::{
Signature, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, Signature, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH,
}; };
use rand_core::RngCore; use rand_core::RngCore;
use serde::{Deserialize, Serialize}; use serde::{de, Deserialize, Deserializer, Serialize};
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
use uuid::Uuid; use uuid::Uuid;
const TIMESTAMP_TOLERENCE: u64 = 90; const TIMESTAMP_TOLERENCE: u64 = 90;
@ -83,8 +84,184 @@ impl<T: Serialize> WithSig<T> {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "typ", rename = "chat")] #[serde(tag = "typ", rename = "chat")]
pub struct ChatPayload { pub struct ChatPayload {
pub rich_text: RichText,
pub room: Uuid, pub room: Uuid,
}
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct RichText(pub Vec<RichTextPiece>);
// NB. This field is excluded from field order check, because it has tuple representation.
#[derive(Debug, PartialEq, Eq, Serialize_tuple)]
pub struct RichTextPiece {
pub text: String, pub text: String,
#[serde(skip_serializing_if = "is_default::<TextAttrs>")]
pub attrs: TextAttrs,
}
/// The protocol representation of `RichTextPiece` which keeps nullity of `attrs` for
/// canonicalization check.
// NB. This field is excluded from field order check, because it has tuple representation.
#[derive(Debug, Deserialize_tuple)]
struct RichTextPieceRaw {
pub text: String,
#[serde(default, skip_serializing_if = "is_default::<TextAttrs>")]
pub attrs: Option<TextAttrs>,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
impl<'de> Deserialize<'de> for RichText {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let pieces = <Vec<RichTextPieceRaw>>::deserialize(de)?;
if pieces
.iter()
.any(|p| matches!(&p.attrs, Some(attrs) if is_default(attrs)))
{
return Err(de::Error::custom("not in canonical form"));
}
let this = Self(
pieces
.into_iter()
.map(|RichTextPieceRaw { text, attrs }| RichTextPiece {
text,
attrs: attrs.unwrap_or_default(),
})
.collect(),
);
if !this.is_canonical() {
return Err(de::Error::custom("not in canonical form"));
}
Ok(this)
}
}
// TODO: This protocol format is quite large. Could use bitflags for database.
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextAttrs {
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
pub bold: bool,
#[serde(default, rename = "m", skip_serializing_if = "is_default")]
pub code: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub hashtag: bool,
#[serde(default, rename = "i", skip_serializing_if = "is_default")]
pub italic: bool,
// TODO: Should we validate and/or filter the URL.
#[serde(default, skip_serializing_if = "is_default")]
pub link: Option<String>,
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
pub strike: bool,
#[serde(default, rename = "u", skip_serializing_if = "is_default")]
pub underline: bool,
}
impl From<&'_ str> for RichText {
fn from(text: &'_ str) -> Self {
text.to_owned().into()
}
}
impl From<String> for RichText {
fn from(text: String) -> Self {
if text.is_empty() {
Self::default()
} else {
Self(vec![RichTextPiece {
text,
attrs: TextAttrs::default(),
}])
}
}
}
impl From<&'_ str> for RichTextPiece {
fn from(text: &'_ str) -> Self {
text.to_owned().into()
}
}
impl From<String> for RichTextPiece {
fn from(text: String) -> Self {
Self {
text,
attrs: TextAttrs::default(),
}
}
}
impl RichText {
/// Is this rich text valid and in the canonical form?
///
/// This is automatically enforced by `Deserialize` impl.
pub fn is_canonical(&self) -> bool {
self.0.iter().all(|p| !p.text.is_empty())
&& self.0.windows(2).all(|w| w[0].attrs != w[1].attrs)
}
/// Format the text into plain text, stripping all styles.
pub fn plain_text(&self) -> impl fmt::Display + '_ {
struct Fmt<'a>(&'a RichText);
impl fmt::Display for Fmt<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for p in &self.0 .0 {
f.write_str(&p.text)?;
}
Ok(())
}
}
Fmt(self)
}
/// Format the text into HTML.
pub fn html(&self) -> impl fmt::Display + '_ {
struct Fmt<'a>(&'a RichText);
impl fmt::Display for Fmt<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for p in &self.0 .0 {
let tags = [
(p.attrs.bold, "<b>", "</b>"),
(p.attrs.code, "<code>", "</code>"),
(p.attrs.italic, "<i>", "</i>"),
(p.attrs.strike, "<strike>", "</strike>"),
(p.attrs.underline, "<u>", "</u>"),
(p.attrs.hashtag || p.attrs.link.is_some(), "", "</a>"),
];
for (cond, begin, _) in tags {
if cond {
f.write_str(begin)?;
}
}
if p.attrs.hashtag {
// TODO: Link target for hashtag?
write!(f, r#"<a class="hashtag">"#)?;
} else if let Some(link) = &p.attrs.link {
let href = html_escape::encode_quoted_attribute(link);
write!(f, r#"<a target="_blank" href="{href}""#)?;
let href = html_escape::encode_quoted_attribute(link);
write!(f, r#"<a target="_blank" href="{href}""#)?;
}
f.write_str(&p.text)?;
for (cond, _, end) in tags.iter().rev() {
if *cond {
f.write_str(end)?;
}
}
}
Ok(())
}
}
Fmt(self)
}
} }
pub type ChatItem = WithSig<ChatPayload>; pub type ChatItem = WithSig<ChatPayload>;
@ -201,6 +378,21 @@ mod sql_impl {
} }
} }
impl ToSql for RichText {
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
assert!(self.is_canonical());
let json = serde_json::to_string(&self).expect("serialization cannot fail");
Ok(json.into())
}
}
impl FromSql for RichText {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
serde_json::from_str::<Self>(value.as_str()?)
.map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into()))
}
}
macro_rules! impl_u64_flag { macro_rules! impl_u64_flag {
($($name:ident),*) => { ($($name:ident),*) => {
$( $(
@ -228,11 +420,17 @@ mod sql_impl {
mod tests { mod tests {
use std::fmt::Write; use std::fmt::Write;
use syn::visit;
use super::*;
#[derive(Default)] #[derive(Default)]
struct Visitor { struct Visitor {
errors: String, errors: String,
} }
const SKIP_CHECK_STRUCTS: &[&str] = &["RichTextPiece", "RichTextPieceRaw"];
impl<'ast> syn::visit::Visit<'ast> for Visitor { impl<'ast> syn::visit::Visit<'ast> for Visitor {
fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) { fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) {
let fields = i let fields = i
@ -245,6 +443,12 @@ mod tests {
writeln!(self.errors, "unsorted fields: {fields:?}").unwrap(); writeln!(self.errors, "unsorted fields: {fields:?}").unwrap();
} }
} }
fn visit_item_struct(&mut self, i: &'ast syn::ItemStruct) {
if !SKIP_CHECK_STRUCTS.contains(&&*i.ident.to_string()) {
visit::visit_item_struct(self, i);
}
}
} }
#[test] #[test]
@ -258,4 +462,36 @@ mod tests {
panic!("{}", v.errors); panic!("{}", v.errors);
} }
} }
#[test]
fn rich_text_serde() {
let raw =
r#"[["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]]"#;
let text = serde_json::from_str::<RichText>(raw).unwrap();
assert!(text.is_canonical());
assert_eq!(
text,
RichText(vec![
"before ".into(),
RichTextPiece {
text: "bold ".into(),
attrs: TextAttrs {
bold: true,
..TextAttrs::default()
}
},
RichTextPiece {
text: "italic bold ".into(),
attrs: TextAttrs {
italic: true,
bold: true,
..TextAttrs::default()
}
},
"end".into(),
]),
);
let got = serde_json::to_string(&text).unwrap();
assert_eq!(got, raw);
}
} }