592 lines
20 KiB
Rust
592 lines
20 KiB
Rust
//! Plugin de comunicação com Supabase via REST API
|
|
//! Substitui completamente o fetch() do JS + n8n como intermediário.
|
|
|
|
use reqwest::blocking::Client;
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use tauri::State;
|
|
use thiserror::Error;
|
|
|
|
/// Remove acentos de uma string (ex: "União" → "Uniao")
|
|
fn sem_acento(s: &str) -> String {
|
|
s.replace("ã", "a")
|
|
.replace("á", "a")
|
|
.replace("à", "a")
|
|
.replace("â", "a")
|
|
.replace("é", "e")
|
|
.replace("è", "e")
|
|
.replace("ê", "e")
|
|
.replace("í", "i")
|
|
.replace("ì", "i")
|
|
.replace("î", "i")
|
|
.replace("õ", "o")
|
|
.replace("ó", "o")
|
|
.replace("ò", "o")
|
|
.replace("ô", "o")
|
|
.replace("ú", "u")
|
|
.replace("ù", "u")
|
|
.replace("û", "u")
|
|
.replace("ç", "c")
|
|
.replace("ñ", "n")
|
|
}
|
|
|
|
#[derive(Error, Debug)]
|
|
#[allow(dead_code)]
|
|
pub enum SupabaseError {
|
|
#[error("HTTP error: {0}")]
|
|
Http(#[from] reqwest::Error),
|
|
#[error("API error: {0}")]
|
|
Api(String),
|
|
#[error("Lock error")]
|
|
Lock,
|
|
}
|
|
|
|
impl serde::Serialize for SupabaseError {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(&self.to_string())
|
|
}
|
|
}
|
|
|
|
pub struct SupabaseClient {
|
|
http: Client,
|
|
anon_key: String,
|
|
service_role_key: String,
|
|
base_url: String,
|
|
}
|
|
|
|
impl SupabaseClient {
|
|
pub fn new(anon_key: String, service_role_key: String) -> Self {
|
|
let http = Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.build()
|
|
.expect("reqwest client");
|
|
|
|
Self {
|
|
http,
|
|
anon_key,
|
|
service_role_key,
|
|
base_url: "https://supabase.ofrangao.com.br".to_string(),
|
|
}
|
|
}
|
|
|
|
fn headers(&self, use_service: bool) -> reqwest::header::HeaderMap {
|
|
let key = if use_service { &self.service_role_key } else { &self.anon_key };
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert(
|
|
reqwest::header::AUTHORIZATION,
|
|
format!("Bearer {}", key).parse().unwrap(),
|
|
);
|
|
headers.insert(
|
|
reqwest::header::HeaderName::from_static("apikey"),
|
|
key.parse().unwrap(),
|
|
);
|
|
headers.insert(
|
|
reqwest::header::CONTENT_TYPE,
|
|
"application/json".parse().unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
/// Salva/atualiza um fechamento na tabela `fechamentos_web`.
|
|
pub fn salvar_fechamento(&self, estado: &serde_json::Value) -> Result<Value, SupabaseError> {
|
|
// Normaliza loja e turno para minúsculo (CHECK constraints do Postgres)
|
|
let loja = estado
|
|
.get("loja")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| sem_acento(&s.to_lowercase()).replace(" ", "_"))
|
|
.unwrap_or_else(|| "uniao".to_string());
|
|
let data = estado.get("data").and_then(|v| v.as_str()).unwrap_or("");
|
|
let operador = estado.get("operador").and_then(|v| v.as_str()).unwrap_or("");
|
|
let id_fechamento = format!("{}_{}_{}", loja, data, operador)
|
|
.to_lowercase()
|
|
.replace(" ", "_");
|
|
let turno = estado
|
|
.get("turno")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| sem_acento(&s.to_lowercase()))
|
|
.unwrap_or_else(|| "manha".to_string());
|
|
|
|
let payload = serde_json::json!({
|
|
"id_fechamento": id_fechamento,
|
|
"loja": loja,
|
|
"data": data,
|
|
"operador": operador,
|
|
"turno": turno,
|
|
"saldo_troco": estado.get("saldo_troco").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_credito": estado.get("credito").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_debito": estado.get("debito").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_alimentacao": estado.get("alimentacao").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_vales": estado.get("vales").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_areceber": estado.get("areceber").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_pixcnpj": estado.get("pixcnpj").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"fechamento_dinheiro": estado.get("fd").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"fechamento_cartoes": estado.get("fc").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"fechamento_areceber": estado.get("fr").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_sangrias": estado.get("total_sangrias").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_despesas": estado.get("total_despesas").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_cancelamentos": estado.get("total_cancelamentos").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_vales": estado.get("total_vales").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_pixcnpj": estado.get("total_pixcnpj").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_areceber": estado.get("total_areceber").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_cartao_credito": estado.get("total_cartao_credito").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_cartao_debito": estado.get("total_cartao_debito").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_cartao_alimentacao": estado.get("total_cartao_alimentacao").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"total_cartao_pix": estado.get("total_cartao_pix").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"saldo_esperado": estado.get("saldo_esperado").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"dados": estado,
|
|
"observacoes": estado.get("observacoes").and_then(|v| v.as_str()).unwrap_or(""),
|
|
"clientes": estado.get("clientes").and_then(|v| v.as_i64()).unwrap_or(0),
|
|
"frango": estado.get("frango").and_then(|v| v.as_i64()).unwrap_or(0),
|
|
"vendas": estado.get("vendas").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
});
|
|
|
|
// Primeiro tenta INSERT (upsert via Prefer)
|
|
let resp = self
|
|
.http
|
|
.post(&format!("{}/rest/v1/fechamentos_web", self.base_url))
|
|
.headers(self.headers(true))
|
|
.header("Prefer", "resolution=merge-duplicates")
|
|
.json(&payload)
|
|
.send()?;
|
|
|
|
let status = resp.status();
|
|
if status.is_success() || status.as_u16() == 409 {
|
|
Ok(serde_json::json!({ "ok": true, "id_fechamento": id_fechamento }))
|
|
} else {
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Busca o rascunho mais recente para loja+data.
|
|
pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result<Option<Value>, SupabaseError> {
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?loja=eq.{}&data=eq.{}&observacoes=eq.rascunho&order=atualizado_em.desc&limit=1&select=*",
|
|
self.base_url,
|
|
urlencoding::encode(loja),
|
|
urlencoding::encode(data),
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr.into_iter().next())
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Busca um fechamento pelo campo "id" (UUID externo).
|
|
pub fn buscar_por_uuid(&self, uuid: &str) -> Result<Option<Value>, SupabaseError> {
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?id=eq.{}&select=*",
|
|
self.base_url, uuid
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr.into_iter().next())
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Busca um fechamento pelo id_fechamento (ex: uniao_2026-06-24_silanea).
|
|
pub fn buscar_por_id_fechamento(&self, id_fechamento: &str) -> Result<Option<Value>, SupabaseError> {
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?id_fechamento=eq.{}&select=*",
|
|
self.base_url, urlencoding::encode(id_fechamento)
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr.into_iter().next())
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Atualiza campos específicos de um fechamento pelo id_fechamento.
|
|
pub fn atualizar_fechamento(&self, id_fechamento: &str, updates: &serde_json::Value) -> Result<Value, SupabaseError> {
|
|
// Campos que o frontend envia mas NÃO existem como colunas na tabela.
|
|
// Arrays e objetos são guardados no JSONB `dados` — não em colunas escalar.
|
|
static BLOCKED: &[&str] = &[
|
|
"id", "uuid", "criado_em", "atualizado_em", "enviado_por",
|
|
"ip_origem", "user_agent", "id_fechamento",
|
|
// arrays e objetos que existem dentro do JSONB `dados`
|
|
"vales", "despesas", "sangrias", "cancelamentos",
|
|
"pixcnpj", "receber",
|
|
"cartoes_credito", "cartoes_debito",
|
|
"cartoes_alimentacao", "cartoes_pix",
|
|
];
|
|
|
|
let map = updates.as_object().cloned().unwrap_or_default();
|
|
|
|
// Remove campos bloqueados
|
|
let filtered: serde_json::Map<String, serde_json::Value> = map
|
|
.into_iter()
|
|
.filter(|(k, _)| !BLOCKED.contains(&k.as_str()))
|
|
.collect();
|
|
|
|
if filtered.is_empty() {
|
|
return Err(SupabaseError::Api("Nenhum campo para atualizar".into()));
|
|
}
|
|
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?id_fechamento=eq.{}",
|
|
self.base_url,
|
|
urlencoding::encode(id_fechamento)
|
|
);
|
|
|
|
log::info!("[PATCH] url={} body={:#?}", url, filtered);
|
|
|
|
let resp = self
|
|
.http
|
|
.patch(&url)
|
|
.headers(self.headers(true))
|
|
.header("Prefer", "return=representation")
|
|
.json(&filtered)
|
|
.send()?;
|
|
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
// Ler a resposta real do Supabase (retorna o registro atualizado)
|
|
if let Ok(ret) = resp.json::<serde_json::Value>() {
|
|
log::info!("[PATCH] sucesso — resposta: {}", ret);
|
|
} else {
|
|
log::warn!("[PATCH] sucesso mas não conseguiu ler resposta");
|
|
}
|
|
Ok(serde_json::json!({ "ok": true, "id_fechamento": id_fechamento }))
|
|
} else {
|
|
let body = resp.text().unwrap_or_default();
|
|
log::error!("[PATCH] HTTP {} body: {}", status.as_u16(), body);
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, SupabaseError> {
|
|
let loja_normalized = sem_acento(&loja.to_lowercase());
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,id_fechamento,data,operador,turno,saldo_troco,saldo_esperado,fechamento_dinheiro,fechamento_cartoes,fechamento_areceber,diferenca,status_diferenca,observacoes,atualizado_em,dados",
|
|
self.base_url,
|
|
urlencoding::encode(&loja_normalized),
|
|
limite,
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr)
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Busca um fechamento por ID.
|
|
pub fn buscar_por_id(&self, id: i64) -> Result<Option<Value>, SupabaseError> {
|
|
let url = format!(
|
|
"{}/rest/v1/fechamentos_web?id=eq.{}&select=*",
|
|
self.base_url, id
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr.into_iter().next())
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Salva operadores/gerentes/despesas customizadas.
|
|
pub fn salvar_listas(&self, loja: &str, listas: &serde_json::Value) -> Result<Value, SupabaseError> {
|
|
let payload = serde_json::json!({
|
|
"loja": loja,
|
|
"operadores": listas.get("operadores"),
|
|
"gerentes": listas.get("gerentes"),
|
|
"despesas_custom": listas.get("despesas_custom"),
|
|
"clientes": listas.get("clientes"),
|
|
});
|
|
|
|
let _url = format!(
|
|
"{}/rest/v1/listas_personalizadas?loja=eq.{}&select=id",
|
|
self.base_url,
|
|
urlencoding::encode(loja),
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.post(&format!("{}/rest/v1/listas_personalizadas", self.base_url))
|
|
.headers(self.headers(true))
|
|
.header("Prefer", "resolution=merge-duplicates")
|
|
.json(&payload)
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
Ok(serde_json::json!({ "ok": true }))
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Carrega listas personalizadas do servidor.
|
|
pub fn carregar_listas(&self, loja: &str) -> Result<Option<Value>, SupabaseError> {
|
|
let url = format!(
|
|
"{}/rest/v1/listas_personalizadas?loja=eq.{}&limit=1&select=*",
|
|
self.base_url,
|
|
urlencoding::encode(loja),
|
|
);
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()?;
|
|
|
|
if resp.status().is_success() {
|
|
let arr: Vec<Value> = resp.json()?;
|
|
Ok(arr.into_iter().next())
|
|
} else {
|
|
let status = resp.status();
|
|
let body = resp.text().unwrap_or_default();
|
|
Err(SupabaseError::Api(format!(
|
|
"HTTP {}: {}",
|
|
status.as_u16(),
|
|
body
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Tenta forçar PostgREST a recarregar schema com novas colunas (vendas, edited_at, edited_by).
|
|
/// Estratégia: INSERT com novas colunas → se PGRST204, espera 1.5s → retry.
|
|
/// Fire-and-forget — falha não bloqueia.
|
|
pub fn run_migration(&self) {
|
|
let payload = serde_json::json!({
|
|
"id_fechamento": "_mig_probe_1900",
|
|
"loja": "_probe_",
|
|
"data": "1900-01-01",
|
|
"operador": "_probe_",
|
|
"vendas": 0,
|
|
"edited_at": null,
|
|
"edited_by": null
|
|
});
|
|
let r1 = self.http
|
|
.post(&format!("{}/rest/v1/fechamentos_web", self.base_url))
|
|
.headers(self.headers(true))
|
|
.header("Prefer", "resolution=merge-duplicates")
|
|
.json(&payload)
|
|
.send();
|
|
if let Ok(resp) = r1 {
|
|
if resp.status().as_u16() == 400 {
|
|
std::thread::sleep(std::time::Duration::from_millis(1500));
|
|
let _ = self.http
|
|
.post(&format!("{}/rest/v1/fechamentos_web", self.base_url))
|
|
.headers(self.headers(true))
|
|
.header("Prefer", "resolution=merge-duplicates")
|
|
.json(&serde_json::json!({
|
|
"id_fechamento": "_mig_probe_1900",
|
|
"vendas": 0,
|
|
"edited_at": null,
|
|
"edited_by": null
|
|
}))
|
|
.send();
|
|
}
|
|
}
|
|
// Limpa probe
|
|
let _ = self.http
|
|
.delete(&format!("{}/rest/v1/fechamentos_web?id_fechamento=eq._mig_probe_1900", self.base_url))
|
|
.headers(self.headers(true))
|
|
.send();
|
|
}
|
|
|
|
/// Verifica se está online.
|
|
pub fn health_check(&self) -> bool {
|
|
let url = format!("{}/rest/v1/?limit=0", self.base_url);
|
|
self.http
|
|
.get(&url)
|
|
.headers(self.headers(false))
|
|
.send()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
// ── Tauri Commands ─────────────────────────────────────────────────────────
|
|
|
|
#[tauri::command]
|
|
pub fn sb_salvar_fechamento(
|
|
sb: State<'_, SupabaseClient>,
|
|
estado: serde_json::Value,
|
|
) -> Result<serde_json::Value, SupabaseError> {
|
|
sb.salvar_fechamento(&estado)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_carregar_rascunho(
|
|
sb: State<'_, SupabaseClient>,
|
|
loja: String,
|
|
data: String,
|
|
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
|
sb.carregar_rascunho(&loja, &data)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_listar_recentes(
|
|
sb: State<'_, SupabaseClient>,
|
|
loja: String,
|
|
limite: Option<i64>,
|
|
) -> Result<Vec<serde_json::Value>, SupabaseError> {
|
|
sb.listar_recentes(&loja, limite.unwrap_or(30))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_buscar_por_id(
|
|
sb: State<'_, SupabaseClient>,
|
|
id: i64,
|
|
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
|
sb.buscar_por_id(id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_buscar_por_uuid(
|
|
sb: State<'_, SupabaseClient>,
|
|
uuid: String,
|
|
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
|
sb.buscar_por_uuid(&uuid)
|
|
}
|
|
|
|
/// Busca um fechamento pelo id_fechamento (ex: uniao_2026-06-24_silanea).
|
|
#[tauri::command]
|
|
pub fn sb_buscar_por_id_fechamento(
|
|
sb: State<'_, SupabaseClient>,
|
|
id_fechamento: String,
|
|
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
|
sb.buscar_por_id_fechamento(&id_fechamento)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "snake_case")]
|
|
pub fn sb_atualizar_fechamento(
|
|
sb: State<'_, SupabaseClient>,
|
|
id_fechamento: String,
|
|
updates: serde_json::Value,
|
|
) -> Result<serde_json::Value, SupabaseError> {
|
|
sb.atualizar_fechamento(&id_fechamento, &updates)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_salvar_listas(
|
|
sb: State<'_, SupabaseClient>,
|
|
loja: String,
|
|
listas: serde_json::Value,
|
|
) -> Result<serde_json::Value, SupabaseError> {
|
|
sb.salvar_listas(&loja, &listas)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_carregar_listas(
|
|
sb: State<'_, SupabaseClient>,
|
|
loja: String,
|
|
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
|
sb.carregar_listas(&loja)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sb_online(sb: State<'_, SupabaseClient>) -> bool {
|
|
sb.health_check()
|
|
}
|
|
|
|
pub fn init(anon_key: String, service_role_key: String) -> SupabaseClient {
|
|
SupabaseClient::new(anon_key, service_role_key)
|
|
}
|
|
|
|
// urlencoding helper (we'll add it as a dep)
|
|
mod urlencoding {
|
|
pub fn encode(s: &str) -> String {
|
|
let mut out = String::new();
|
|
for b in s.bytes() {
|
|
match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
out.push(b as char);
|
|
}
|
|
_ => {
|
|
out.push_str(&format!("%{:02X}", b));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|