···
6
-
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
8
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession};
use jacquard::oauth::client::OAuthClient;
use jacquard::oauth::loopback::LoopbackConfig;
use jacquard::prelude::IdentityResolver;
···
use jacquard_common::types::blob::MimeType;
use miette::IntoDiagnostic;
use std::path::{Path, PathBuf};
16
+
use std::collections::HashMap;
use flate2::write::GzEncoder;
···
println!("Deploying site '{}'...", site_name);
110
-
// Build directory tree
111
-
let root_dir = build_directory(agent, &path).await?;
113
+
// Try to fetch existing manifest for incremental updates
114
+
let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = {
115
+
use jacquard_common::types::string::AtUri;
117
+
// Get the DID for this session
118
+
let session_info = agent.session_info().await;
119
+
if let Some((did, _)) = session_info {
120
+
// Construct the AT URI for the record
121
+
let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name);
122
+
if let Ok(uri) = AtUri::new(&uri_string) {
123
+
match agent.get_record::<Fs>(&uri).await {
125
+
match response.into_output() {
126
+
Ok(record_output) => {
127
+
let existing_manifest = record_output.value;
128
+
let blob_map = blob_map::extract_blob_map(&existing_manifest.root);
129
+
println!("Found existing manifest with {} files, checking for changes...", blob_map.len());
133
+
println!("No existing manifest found, uploading all files...");
139
+
// Record doesn't exist yet - this is a new site
140
+
println!("No existing manifest found, uploading all files...");
145
+
println!("No existing manifest found (invalid URI), uploading all files...");
149
+
println!("No existing manifest found (could not get DID), uploading all files...");
113
-
// Count total files
114
-
let file_count = count_files(&root_dir);
154
+
// Build directory tree
155
+
let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map).await?;
156
+
let uploaded_count = total_files - reused_count;
let fs_record = Fs::new()
.site(CowStr::from(site_name.clone()))
120
-
.file_count(file_count as i64)
162
+
.file_count(total_files as i64)
.created_at(Datetime::now())
···
.and_then(|s| s.split('/').next())
.ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
135
-
println!("Deployed site '{}': {}", site_name, output.uri);
136
-
println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
177
+
println!("\n✓ Deployed site '{}': {}", site_name, output.uri);
178
+
println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
179
+
println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
···
agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
145
-
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
188
+
existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
189
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
// Collect all directory entries first
···
// Process files concurrently with a limit of 5
180
-
let file_entries: Vec<Entry> = stream::iter(file_tasks)
224
+
let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
.map(|(name, path)| async move {
182
-
let file_node = process_file(agent, &path).await?;
183
-
Ok::<_, miette::Report>(Entry::new()
226
+
let (file_node, reused) = process_file(agent, &path, &name, existing_blobs).await?;
227
+
let entry = Entry::new()
.name(CowStr::from(name))
.node(EntryNode::File(Box::new(file_node)))
231
+
Ok::<_, miette::Report>((entry, reused))
.collect::<miette::Result<Vec<_>>>()?;
239
+
let mut file_entries = Vec::new();
240
+
let mut reused_count = 0;
241
+
let mut total_files = 0;
243
+
for (entry, reused) in file_results {
244
+
file_entries.push(entry);
// Process directories recursively (sequentially to avoid too much nesting)
let mut dir_entries = Vec::new();
for (name, path) in dir_tasks {
197
-
let subdir = build_directory(agent, &path).await?;
254
+
let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs).await?;
dir_entries.push(Entry::new()
.name(CowStr::from(name))
.node(EntryNode::Directory(Box::new(subdir)))
259
+
total_files += sub_total;
260
+
reused_count += sub_reused;
// Combine file and directory entries
let mut entries = file_entries;
entries.extend(dir_entries);
208
-
Ok(Directory::new()
267
+
let directory = Directory::new()
.r#type(CowStr::from("directory"))
272
+
Ok((directory, total_files, reused_count))
215
-
/// Process a single file: gzip -> base64 -> upload blob
276
+
/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
277
+
/// Returns (File, reused: bool)
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
219
-
) -> miette::Result<File<'static>>
282
+
existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
283
+
) -> miette::Result<(File<'static>, bool)>
let file_data = std::fs::read(file_path).into_diagnostic()?;
···
// Base64 encode the gzipped data
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
237
-
// Upload blob as octet-stream
301
+
// Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
302
+
let file_cid = cid::compute_cid(&base64_bytes);
304
+
// Normalize the file path for comparison
305
+
let normalized_path = blob_map::normalize_path(file_name);
307
+
// Check if we have an existing blob with the same CID
308
+
let existing_blob = existing_blobs.get(&normalized_path)
309
+
.or_else(|| existing_blobs.get(file_name));
311
+
if let Some((existing_blob_ref, existing_cid)) = existing_blob {
312
+
if existing_cid == &file_cid {
313
+
// CIDs match - reuse existing blob
314
+
println!(" ✓ Reusing blob for {} (CID: {})", file_name, file_cid);
317
+
.r#type(CowStr::from("file"))
318
+
.blob(existing_blob_ref.clone())
319
+
.encoding(CowStr::from("gzip"))
320
+
.mime_type(CowStr::from(original_mime))
328
+
// File is new or changed - upload it
329
+
println!(" ↑ Uploading {} ({} bytes, CID: {})", file_name, base64_bytes.len(), file_cid);
let blob = agent.upload_blob(
MimeType::new_static("application/octet-stream"),
244
-
.r#type(CowStr::from("file"))
246
-
.encoding(CowStr::from("gzip"))
247
-
.mime_type(CowStr::from(original_mime))
337
+
.r#type(CowStr::from("file"))
339
+
.encoding(CowStr::from("gzip"))
340
+
.mime_type(CowStr::from(original_mime))
252
-
/// Count total files in a directory tree
253
-
fn count_files(dir: &Directory) -> usize {
255
-
for entry in &dir.entries {
256
-
match &entry.node {
257
-
EntryNode::File(_) => count += 1,
258
-
EntryNode::Directory(subdir) => count += count_files(subdir),
259
-
_ => {} // Unknown variants