this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add deletion

authored by

Julien Calixte and committed by
Julien Calixte
f4b7ce27 0cfe0839

+145 -31
+142 -31
packages/cli/src/commands/publish.ts
··· 18 18 createBlueskyPost, 19 19 addBskyPostRefToDocument, 20 20 deleteRecord, 21 + listDocuments, 21 22 } from "../lib/atproto"; 22 23 import { 23 24 scanContentDirectory, ··· 173 174 posts.map((p) => path.relative(configDir, p.filePath)), 174 175 ); 175 176 const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 177 + 176 178 for (const [filePath, postState] of Object.entries(state.posts)) { 177 179 if (!scannedPaths.has(filePath) && postState.atUri) { 178 180 // Check if the file truly doesn't exist (not just excluded by ignore patterns) 179 181 const absolutePath = path.resolve(configDir, filePath); 180 - 181 - // If file exists but wasn't scanned (e.g. draft or ignored) — skip 182 182 if (!(await fileExists(absolutePath))) { 183 183 deletedEntries.push({ filePath, atUri: postState.atUri }); 184 184 } 185 185 } 186 186 } 187 + 188 + // Detect unmatched PDS records: exist on PDS but have no matching local file 189 + const unmatchedEntries: Array<{ atUri: string; title: string }> = []; 187 190 188 191 // Shared agent — created lazily, reused across deletion and publishing 189 192 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; ··· 257 260 ); 258 261 } 259 262 260 - if (postsToPublish.length === 0) { 261 - log.success("All posts are up to date. Nothing to publish."); 262 - return; 263 + // Fetch PDS records and detect unmatched documents 264 + async function fetchUnmatchedRecords() { 265 + const ag = await getAgent(); 266 + s.start("Fetching documents from PDS..."); 267 + const pdsDocuments = await listDocuments(ag, config.publicationUri); 268 + s.stop(`Found ${pdsDocuments.length} documents on PDS`); 269 + 270 + const pathPrefix = config.pathPrefix || "/posts"; 271 + const postsByPath = new Map<string, BlogPost>(); 272 + for (const post of posts) { 273 + postsByPath.set(`${pathPrefix}/${post.slug}`, post); 274 + } 275 + const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 276 + for (const doc of pdsDocuments) { 277 + if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { 278 + unmatchedEntries.push({ 279 + atUri: doc.uri, 280 + title: doc.value.title || doc.value.path, 281 + }); 282 + } 283 + } 263 284 } 264 285 265 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 286 + if (postsToPublish.length === 0 && deletedEntries.length === 0) { 287 + await fetchUnmatchedRecords(); 288 + 289 + if (unmatchedEntries.length === 0) { 290 + log.success("All posts are up to date. Nothing to publish."); 291 + return; 292 + } 293 + } 266 294 267 295 // Bluesky posting configuration 268 296 const blueskyEnabled = config.bluesky?.enabled ?? false; ··· 270 298 const cutoffDate = new Date(); 271 299 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 272 300 273 - for (const { post, action, reason } of postsToPublish) { 274 - const icon = action === "create" ? "+" : "~"; 275 - const relativeFilePath = path.relative(configDir, post.filePath); 276 - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 301 + if (postsToPublish.length > 0) { 302 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 303 + 304 + for (const { post, action, reason } of postsToPublish) { 305 + const icon = action === "create" ? "+" : "~"; 306 + const relativeFilePath = path.relative(configDir, post.filePath); 307 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 277 308 278 - let bskyNote = ""; 279 - if (blueskyEnabled) { 280 - if (existingBskyPostRef) { 281 - bskyNote = " [bsky: exists]"; 282 - } else { 283 - const publishDate = new Date(post.frontmatter.publishDate); 284 - if (publishDate < cutoffDate) { 285 - bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 309 + let bskyNote = ""; 310 + if (blueskyEnabled) { 311 + if (existingBskyPostRef) { 312 + bskyNote = " [bsky: exists]"; 286 313 } else { 287 - bskyNote = " [bsky: will post]"; 314 + const publishDate = new Date(post.frontmatter.publishDate); 315 + if (publishDate < cutoffDate) { 316 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 317 + } else { 318 + bskyNote = " [bsky: will post]"; 319 + } 288 320 } 289 321 } 322 + 323 + let postUrl = ""; 324 + if (verbose) { 325 + const postPath = resolvePostPath( 326 + post, 327 + config.pathPrefix, 328 + config.pathTemplate, 329 + ); 330 + postUrl = `\n ${config.siteUrl}${postPath}`; 331 + } 332 + log.message( 333 + ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 334 + ); 290 335 } 336 + } 291 337 292 - let postUrl = ""; 293 - if (verbose) { 294 - const postPath = resolvePostPath( 295 - post, 296 - config.pathPrefix, 297 - config.pathTemplate, 298 - ); 299 - postUrl = `\n ${config.siteUrl}${postPath}`; 338 + if (deletedEntries.length > 0) { 339 + log.info( 340 + `\n${deletedEntries.length} deleted local files to remove from PDS:\n`, 341 + ); 342 + for (const { filePath } of deletedEntries) { 343 + log.message(` - ${filePath}`); 300 344 } 301 - log.message( 302 - ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 345 + } 346 + 347 + if (unmatchedEntries.length > 0) { 348 + log.info( 349 + `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`, 303 350 ); 351 + for (const { title } of unmatchedEntries) { 352 + log.message(` - ${title}`); 353 + } 304 354 } 305 355 306 356 if (dryRun) { ··· 316 366 317 367 if (!agent) { 318 368 throw new Error("agent is not connected"); 369 + } 370 + 371 + // Fetch PDS records to detect unmatched documents (if not already done) 372 + if (unmatchedEntries.length === 0) { 373 + await fetchUnmatchedRecords(); 319 374 } 320 375 321 376 // Publish posts ··· 513 568 } 514 569 } 515 570 571 + // Delete records for removed files 572 + let deletedCount = 0; 573 + for (const { filePath, atUri } of deletedEntries) { 574 + try { 575 + const ag = await getAgent(); 576 + s.start(`Deleting: ${filePath}`); 577 + await deleteRecord(ag, atUri); 578 + 579 + // Try to delete the corresponding Remanso note 580 + try { 581 + const noteAtUri = atUri.replace( 582 + "site.standard.document", 583 + "space.remanso.note", 584 + ); 585 + await deleteNote(ag, noteAtUri); 586 + } catch { 587 + // Note may not exist, ignore 588 + } 589 + 590 + delete state.posts[filePath]; 591 + s.stop(`Deleted: ${filePath}`); 592 + deletedCount++; 593 + } catch (error) { 594 + s.stop(`Failed to delete: ${filePath}`); 595 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 596 + } 597 + } 598 + 599 + // Delete unmatched PDS records (exist on PDS but no matching local file) 600 + let unmatchedDeletedCount = 0; 601 + for (const { atUri, title } of unmatchedEntries) { 602 + try { 603 + const ag = await getAgent(); 604 + s.start(`Deleting unmatched: ${title}`); 605 + await deleteRecord(ag, atUri); 606 + 607 + // Try to delete the corresponding Remanso note 608 + try { 609 + const noteAtUri = atUri.replace( 610 + "site.standard.document", 611 + "space.remanso.note", 612 + ); 613 + await deleteNote(ag, noteAtUri); 614 + } catch { 615 + // Note may not exist, ignore 616 + } 617 + 618 + s.stop(`Deleted unmatched: ${title}`); 619 + unmatchedDeletedCount++; 620 + } catch (error) { 621 + s.stop(`Failed to delete: ${title}`); 622 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 623 + } 624 + } 625 + 516 626 // Save state 517 627 await saveState(configDir, state); 518 628 519 629 // Summary 520 630 log.message("\n---"); 521 - if (deletedEntries.length > 0) { 522 - log.info(`Deleted: ${deletedEntries.length}`); 631 + const totalDeleted = deletedCount + unmatchedDeletedCount; 632 + if (totalDeleted > 0) { 633 + log.info(`Deleted: ${totalDeleted}`); 523 634 } 524 635 log.info(`Published: ${publishedCount}`); 525 636 log.info(`Updated: ${updatedCount}`);
+3
packages/cli/src/commands/sync.ts
··· 229 229 log.warn( 230 230 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 231 231 ); 232 + log.info( 233 + `Run 'sequoia publish' to delete unmatched records from your PDS.`, 234 + ); 232 235 } 233 236 234 237 if (dryRun) {