crier 2.0.0__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crier-2.0.0 → crier-2.0.2}/PKG-INFO +1 -1
- {crier-2.0.0 → crier-2.0.2}/pyproject.toml +1 -1
- {crier-2.0.0 → crier-2.0.2}/src/crier/cli.py +9 -22
- {crier-2.0.0 → crier-2.0.2}/src/crier/mcp_server.py +242 -164
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/base.py +10 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/bluesky.py +1 -4
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/linkedin.py +1 -4
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/mastodon.py +2 -7
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/threads.py +1 -4
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/twitter.py +1 -4
- {crier-2.0.0 → crier-2.0.2}/src/crier/rewrite.py +17 -17
- {crier-2.0.0 → crier-2.0.2}/.gitignore +0 -0
- {crier-2.0.0 → crier-2.0.2}/LICENSE +0 -0
- {crier-2.0.0 → crier-2.0.2}/README.md +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/SKILL.md +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/__init__.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/checker.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/config.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/converters/__init__.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/converters/markdown.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/feed.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/llm/__init__.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/llm/openai_compat.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/llm/provider.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/__init__.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/buttondown.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/devto.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/discord.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/ghost.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/hashnode.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/medium.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/telegram.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/wordpress.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/registry.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/scheduler.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/skill.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/threading.py +0 -0
- {crier-2.0.0 → crier-2.0.2}/src/crier/utils.py +0 -0
|
@@ -20,7 +20,7 @@ from .config import (
|
|
|
20
20
|
)
|
|
21
21
|
from .converters import parse_markdown_file
|
|
22
22
|
from .platforms import PLATFORMS, get_platform
|
|
23
|
-
from .
|
|
23
|
+
from .rewrite import apply_rewrite
|
|
24
24
|
from .utils import (
|
|
25
25
|
is_in_content_paths as _is_in_content_paths,
|
|
26
26
|
parse_date_filter as _parse_date_filter_impl,
|
|
@@ -740,17 +740,7 @@ def publish(file: str, platform_args: tuple[str, ...], profile_name: str | None,
|
|
|
740
740
|
if rewrite_content:
|
|
741
741
|
is_rewritten = True
|
|
742
742
|
posted_content = rewrite_content
|
|
743
|
-
|
|
744
|
-
article = Article(
|
|
745
|
-
title=article.title,
|
|
746
|
-
body=rewrite_content, # Use rewritten content as body
|
|
747
|
-
description=article.description,
|
|
748
|
-
tags=article.tags,
|
|
749
|
-
canonical_url=article.canonical_url,
|
|
750
|
-
published=article.published,
|
|
751
|
-
cover_image=article.cover_image,
|
|
752
|
-
is_rewrite=True,
|
|
753
|
-
)
|
|
743
|
+
article = apply_rewrite(article, rewrite_content)
|
|
754
744
|
|
|
755
745
|
# Require canonical_url for registry tracking
|
|
756
746
|
if not article.canonical_url:
|
|
@@ -4135,17 +4125,12 @@ def schedule_run(dry_run: bool, json_output: bool):
|
|
|
4135
4125
|
console.print(f"[red]✗[/red] {post.id}: No API key for {platform_name}")
|
|
4136
4126
|
continue
|
|
4137
4127
|
|
|
4138
|
-
# Handle rewrite if specified
|
|
4128
|
+
# Handle rewrite if specified.
|
|
4129
|
+
# Use apply_rewrite so is_rewrite=True is set; short-form platforms
|
|
4130
|
+
# branch on that flag and would otherwise discard the body.
|
|
4131
|
+
is_rewritten = bool(post.rewrite)
|
|
4139
4132
|
if post.rewrite:
|
|
4140
|
-
article =
|
|
4141
|
-
title=article.title,
|
|
4142
|
-
body=post.rewrite,
|
|
4143
|
-
description=article.description,
|
|
4144
|
-
tags=article.tags,
|
|
4145
|
-
canonical_url=article.canonical_url,
|
|
4146
|
-
published=article.published,
|
|
4147
|
-
cover_image=article.cover_image,
|
|
4148
|
-
)
|
|
4133
|
+
article = apply_rewrite(article, post.rewrite)
|
|
4149
4134
|
|
|
4150
4135
|
# Publish
|
|
4151
4136
|
try:
|
|
@@ -4165,6 +4150,8 @@ def schedule_run(dry_run: bool, json_output: bool):
|
|
|
4165
4150
|
url=result.url,
|
|
4166
4151
|
title=article.title,
|
|
4167
4152
|
source_file=str(file_path),
|
|
4153
|
+
rewritten=is_rewritten,
|
|
4154
|
+
posted_content=post.rewrite if is_rewritten else None,
|
|
4168
4155
|
)
|
|
4169
4156
|
|
|
4170
4157
|
results.append({
|
|
@@ -83,13 +83,15 @@ def _validate_platform(name: str) -> str | None:
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
def _resolve_file(file_path: str) -> Path | None:
|
|
86
|
-
"""Resolve a file path against project root. Returns None if not found."""
|
|
86
|
+
"""Resolve a file path against project root. Returns None if not found or not a file."""
|
|
87
|
+
if not file_path:
|
|
88
|
+
return None
|
|
87
89
|
from .config import get_project_root
|
|
88
90
|
p = Path(file_path)
|
|
89
|
-
if p.is_absolute() and p.
|
|
91
|
+
if p.is_absolute() and p.is_file():
|
|
90
92
|
return p
|
|
91
93
|
resolved = get_project_root() / p
|
|
92
|
-
if resolved.
|
|
94
|
+
if resolved.is_file():
|
|
93
95
|
return resolved
|
|
94
96
|
return None
|
|
95
97
|
|
|
@@ -581,36 +583,23 @@ def crier_check(
|
|
|
581
583
|
# ============================================================================
|
|
582
584
|
|
|
583
585
|
|
|
584
|
-
|
|
585
|
-
def crier_publish(
|
|
586
|
+
def _prepare_publish(
|
|
586
587
|
file_path: str,
|
|
587
588
|
platform: str,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
confirmation_token: str | None = None,
|
|
592
|
-
) -> dict:
|
|
593
|
-
"""Publish a content file to a platform.
|
|
589
|
+
rewrite_content: str | None,
|
|
590
|
+
):
|
|
591
|
+
"""Validate inputs and load the article for crier_publish.
|
|
594
592
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
For short-form platforms (bluesky, mastodon), pass rewrite_content
|
|
599
|
-
with a condensed version. Crier appends the canonical URL automatically.
|
|
593
|
+
Shared by step 1 (preview + token) and step 2 (execute). Returns either
|
|
594
|
+
an error dict (caller should return it as-is) or a tuple of
|
|
595
|
+
``(resolved_path, article, api_key, is_rewritten, posted_content)``.
|
|
600
596
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
crier_publish("content/post/my-article/index.md", "devto")
|
|
604
|
-
# Step 2: execute
|
|
605
|
-
crier_publish("content/post/my-article/index.md", "devto",
|
|
606
|
-
confirmation_token="abc123")
|
|
607
|
-
# Dry run (no confirmation needed)
|
|
608
|
-
crier_publish("content/post/my-article/index.md", "devto", dry_run=True)
|
|
609
|
-
# With rewrite for short-form
|
|
610
|
-
crier_publish("content/post/my-article/index.md", "bluesky",
|
|
611
|
-
rewrite_content="Interesting take on X...",
|
|
612
|
-
rewrite_author="claude-code")
|
|
597
|
+
The rewrite, when provided, is applied here so both branches see the
|
|
598
|
+
final Article that will be posted.
|
|
613
599
|
"""
|
|
600
|
+
from .converters import parse_markdown_file
|
|
601
|
+
from .config import get_api_key, get_platform_mode
|
|
602
|
+
|
|
614
603
|
err = _validate_platform(platform)
|
|
615
604
|
if err:
|
|
616
605
|
return {"error": err}
|
|
@@ -619,13 +608,6 @@ def crier_publish(
|
|
|
619
608
|
if not resolved:
|
|
620
609
|
return {"error": f"File not found: {file_path}"}
|
|
621
610
|
|
|
622
|
-
from .converters import parse_markdown_file
|
|
623
|
-
from .config import get_api_key, get_platform_mode
|
|
624
|
-
from .platforms import get_platform
|
|
625
|
-
from .platforms.base import Article
|
|
626
|
-
from .registry import record_publication, record_failure, is_published
|
|
627
|
-
|
|
628
|
-
# Parse article
|
|
629
611
|
try:
|
|
630
612
|
article = parse_markdown_file(str(resolved))
|
|
631
613
|
except Exception as e:
|
|
@@ -634,7 +616,6 @@ def crier_publish(
|
|
|
634
616
|
if not article.title:
|
|
635
617
|
return {"error": "Article has no title"}
|
|
636
618
|
|
|
637
|
-
# Check API key
|
|
638
619
|
api_key = get_api_key(platform)
|
|
639
620
|
if not api_key:
|
|
640
621
|
return {"error": f"No API key configured for {platform}"}
|
|
@@ -643,106 +624,158 @@ def crier_publish(
|
|
|
643
624
|
if mode != "api":
|
|
644
625
|
return {"error": f"{platform} is in {mode} mode (not API). Use CLI for manual/import."}
|
|
645
626
|
|
|
646
|
-
# Apply rewrite if provided
|
|
647
627
|
is_rewritten = bool(rewrite_content)
|
|
648
628
|
posted_content = rewrite_content
|
|
649
629
|
if rewrite_content:
|
|
650
|
-
|
|
630
|
+
from .rewrite import apply_rewrite
|
|
631
|
+
article = apply_rewrite(article, rewrite_content)
|
|
632
|
+
|
|
633
|
+
return resolved, article, api_key, is_rewritten, posted_content
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _execute_publish(
|
|
637
|
+
resolved: Path,
|
|
638
|
+
article,
|
|
639
|
+
platform: str,
|
|
640
|
+
api_key: str,
|
|
641
|
+
is_rewritten: bool,
|
|
642
|
+
posted_content: str | None,
|
|
643
|
+
rewrite_author: str | None,
|
|
644
|
+
) -> dict:
|
|
645
|
+
"""Run the platform publish call and record the outcome.
|
|
646
|
+
|
|
647
|
+
Records success via record_publication and any failure (exception or
|
|
648
|
+
PublishResult.success=False) via record_failure, mirroring CLI publish
|
|
649
|
+
semantics. Returns the MCP-shaped result dict.
|
|
650
|
+
"""
|
|
651
|
+
from .platforms import get_platform
|
|
652
|
+
from .registry import record_failure, record_publication
|
|
653
|
+
|
|
654
|
+
canonical = article.canonical_url
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
platform_obj = get_platform(platform)(api_key)
|
|
658
|
+
result = platform_obj.publish(article)
|
|
659
|
+
except Exception as e:
|
|
660
|
+
if canonical:
|
|
661
|
+
record_failure(canonical, platform, str(e), article.title, str(resolved))
|
|
662
|
+
return {"error": f"Publish failed: {e}"}
|
|
663
|
+
|
|
664
|
+
if not result.success:
|
|
665
|
+
if canonical and result.error:
|
|
666
|
+
record_failure(canonical, platform, result.error, article.title, str(resolved))
|
|
667
|
+
return {"error": f"Publish failed: {result.error}"}
|
|
668
|
+
|
|
669
|
+
if canonical:
|
|
670
|
+
record_publication(
|
|
671
|
+
canonical_url=canonical,
|
|
672
|
+
platform=platform,
|
|
673
|
+
article_id=result.article_id,
|
|
674
|
+
url=result.url,
|
|
651
675
|
title=article.title,
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
published=article.published,
|
|
657
|
-
cover_image=article.cover_image,
|
|
658
|
-
is_rewrite=True,
|
|
676
|
+
source_file=str(resolved),
|
|
677
|
+
rewritten=is_rewritten,
|
|
678
|
+
rewrite_author=rewrite_author,
|
|
679
|
+
posted_content=posted_content,
|
|
659
680
|
)
|
|
660
681
|
|
|
661
|
-
|
|
682
|
+
return {
|
|
683
|
+
"success": True,
|
|
684
|
+
"platform": platform,
|
|
685
|
+
"title": article.title,
|
|
686
|
+
"article_id": result.article_id,
|
|
687
|
+
"url": result.url,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@mcp.tool()
|
|
692
|
+
def crier_publish(
|
|
693
|
+
file_path: str,
|
|
694
|
+
platform: str,
|
|
695
|
+
dry_run: bool = False,
|
|
696
|
+
rewrite_content: str | None = None,
|
|
697
|
+
rewrite_author: str | None = None,
|
|
698
|
+
confirmation_token: str | None = None,
|
|
699
|
+
) -> dict:
|
|
700
|
+
"""Publish a content file to a platform.
|
|
701
|
+
|
|
702
|
+
Two-step confirmation: call without token to get a preview, then
|
|
703
|
+
call again with the returned token to execute.
|
|
704
|
+
|
|
705
|
+
For short-form platforms (bluesky, mastodon), pass rewrite_content
|
|
706
|
+
with a condensed version. Crier appends the canonical URL automatically.
|
|
707
|
+
|
|
708
|
+
Examples:
|
|
709
|
+
# Step 1: preview
|
|
710
|
+
crier_publish("content/post/my-article/index.md", "devto")
|
|
711
|
+
# Step 2: execute
|
|
712
|
+
crier_publish("content/post/my-article/index.md", "devto",
|
|
713
|
+
confirmation_token="abc123")
|
|
714
|
+
# Dry run (no confirmation needed)
|
|
715
|
+
crier_publish("content/post/my-article/index.md", "devto", dry_run=True)
|
|
716
|
+
# With rewrite for short-form
|
|
717
|
+
crier_publish("content/post/my-article/index.md", "bluesky",
|
|
718
|
+
rewrite_content="Interesting take on X...",
|
|
719
|
+
rewrite_author="claude-code")
|
|
720
|
+
"""
|
|
721
|
+
# Step 2: token is the source of truth. Caller args are ignored to prevent
|
|
722
|
+
# token-substitution bypass; see the test_*_step2_token_overrides_caller_args
|
|
723
|
+
# regressions in tests/test_mcp.py.
|
|
724
|
+
if confirmation_token:
|
|
725
|
+
details = _consume_token(confirmation_token, "publish")
|
|
726
|
+
if not details:
|
|
727
|
+
return {"error": "Invalid or expired confirmation token. Request a new one."}
|
|
728
|
+
|
|
729
|
+
prepared = _prepare_publish(
|
|
730
|
+
details["file"],
|
|
731
|
+
details["platform"],
|
|
732
|
+
details.get("rewrite_content"),
|
|
733
|
+
)
|
|
734
|
+
if isinstance(prepared, dict):
|
|
735
|
+
return prepared
|
|
736
|
+
resolved, article, api_key, is_rewritten, posted_content = prepared
|
|
737
|
+
|
|
738
|
+
return _execute_publish(
|
|
739
|
+
resolved, article, details["platform"], api_key,
|
|
740
|
+
is_rewritten, posted_content, details.get("rewrite_author"),
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Step 1 / dry_run: validate, build preview, optionally mint a token.
|
|
744
|
+
prepared = _prepare_publish(file_path, platform, rewrite_content)
|
|
745
|
+
if isinstance(prepared, dict):
|
|
746
|
+
return prepared
|
|
747
|
+
resolved, article, _api_key, is_rewritten, _posted = prepared
|
|
748
|
+
|
|
749
|
+
from .registry import is_published
|
|
750
|
+
canonical = article.canonical_url
|
|
662
751
|
preview = {
|
|
663
752
|
"file": str(resolved),
|
|
664
753
|
"title": article.title,
|
|
665
754
|
"platform": platform,
|
|
666
|
-
"canonical_url":
|
|
755
|
+
"canonical_url": canonical,
|
|
667
756
|
"is_rewrite": is_rewritten,
|
|
668
757
|
"body_length": len(article.body) if article.body else 0,
|
|
669
|
-
"already_published": is_published(
|
|
670
|
-
article.canonical_url or "", platform
|
|
671
|
-
) if article.canonical_url else False,
|
|
758
|
+
"already_published": is_published(canonical, platform) if canonical else False,
|
|
672
759
|
}
|
|
673
760
|
|
|
674
761
|
if dry_run:
|
|
675
762
|
return {"dry_run": True, "preview": preview}
|
|
676
763
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if not details:
|
|
693
|
-
return {"error": "Invalid or expired confirmation token. Request a new one."}
|
|
694
|
-
|
|
695
|
-
# Restore rewrite from token (the caller only passes it in step 1)
|
|
696
|
-
if not rewrite_content and details.get("rewrite_content"):
|
|
697
|
-
rewrite_content = details["rewrite_content"]
|
|
698
|
-
rewrite_author = details.get("rewrite_author")
|
|
699
|
-
is_rewritten = True
|
|
700
|
-
posted_content = rewrite_content
|
|
701
|
-
article = Article(
|
|
702
|
-
title=article.title,
|
|
703
|
-
body=rewrite_content,
|
|
704
|
-
description=article.description,
|
|
705
|
-
tags=article.tags,
|
|
706
|
-
canonical_url=article.canonical_url,
|
|
707
|
-
published=article.published,
|
|
708
|
-
cover_image=article.cover_image,
|
|
709
|
-
is_rewrite=True,
|
|
710
|
-
)
|
|
711
|
-
|
|
712
|
-
# Publish
|
|
713
|
-
try:
|
|
714
|
-
platform_cls = get_platform(platform)
|
|
715
|
-
platform_obj = platform_cls(api_key)
|
|
716
|
-
result = platform_obj.publish(article)
|
|
717
|
-
except Exception as e:
|
|
718
|
-
if article.canonical_url:
|
|
719
|
-
record_failure(article.canonical_url, platform, str(e), article.title, str(resolved))
|
|
720
|
-
return {"error": f"Publish failed: {e}"}
|
|
721
|
-
|
|
722
|
-
if result.success:
|
|
723
|
-
if article.canonical_url:
|
|
724
|
-
record_publication(
|
|
725
|
-
canonical_url=article.canonical_url,
|
|
726
|
-
platform=platform,
|
|
727
|
-
article_id=result.article_id,
|
|
728
|
-
url=result.url,
|
|
729
|
-
title=article.title,
|
|
730
|
-
source_file=str(resolved),
|
|
731
|
-
rewritten=is_rewritten,
|
|
732
|
-
rewrite_author=rewrite_author,
|
|
733
|
-
posted_content=posted_content,
|
|
734
|
-
)
|
|
735
|
-
return {
|
|
736
|
-
"success": True,
|
|
737
|
-
"platform": platform,
|
|
738
|
-
"title": article.title,
|
|
739
|
-
"article_id": result.article_id,
|
|
740
|
-
"url": result.url,
|
|
741
|
-
}
|
|
742
|
-
else:
|
|
743
|
-
if article.canonical_url and result.error:
|
|
744
|
-
record_failure(article.canonical_url, platform, result.error, article.title, str(resolved))
|
|
745
|
-
return {"error": f"Publish failed: {result.error}"}
|
|
764
|
+
token = _create_token("publish", {
|
|
765
|
+
"file": str(resolved),
|
|
766
|
+
"platform": platform,
|
|
767
|
+
"rewrite_content": rewrite_content,
|
|
768
|
+
"rewrite_author": rewrite_author,
|
|
769
|
+
})
|
|
770
|
+
return {
|
|
771
|
+
"confirmation_required": True,
|
|
772
|
+
"preview": preview,
|
|
773
|
+
"confirmation_token": token,
|
|
774
|
+
"message": (
|
|
775
|
+
f"Call again with confirmation_token to publish "
|
|
776
|
+
f"'{article.title}' to {platform}"
|
|
777
|
+
),
|
|
778
|
+
}
|
|
746
779
|
|
|
747
780
|
|
|
748
781
|
@mcp.tool()
|
|
@@ -766,6 +799,20 @@ def crier_delete(
|
|
|
766
799
|
# Delete from all platforms
|
|
767
800
|
crier_delete("code-without-purpose", delete_all=True)
|
|
768
801
|
"""
|
|
802
|
+
from .registry import get_article
|
|
803
|
+
|
|
804
|
+
# Step 2: token is the source of truth. Caller args are ignored to prevent
|
|
805
|
+
# token-substitution bypass; see test_delete_step2_token_overrides_caller_key.
|
|
806
|
+
if confirmation_token:
|
|
807
|
+
details = _consume_token(confirmation_token, "delete")
|
|
808
|
+
if not details:
|
|
809
|
+
return {"error": "Invalid or expired confirmation token."}
|
|
810
|
+
article = get_article(details["key"])
|
|
811
|
+
if not article:
|
|
812
|
+
return {"error": f"Article not found: {details['key']}"}
|
|
813
|
+
return {"deleted": _execute_deletes(article, details["platforms"])}
|
|
814
|
+
|
|
815
|
+
# Step 1: validate, resolve targets, return preview + token.
|
|
769
816
|
if platform:
|
|
770
817
|
err = _validate_platform(platform)
|
|
771
818
|
if err:
|
|
@@ -774,77 +821,97 @@ def crier_delete(
|
|
|
774
821
|
if not platform and not delete_all:
|
|
775
822
|
return {"error": "Specify platform or delete_all=True"}
|
|
776
823
|
|
|
777
|
-
from .registry import get_article, record_deletion, get_publication_id
|
|
778
|
-
from .config import get_api_key, get_platform_mode
|
|
779
|
-
from .platforms import get_platform
|
|
780
|
-
|
|
781
824
|
article = get_article(key)
|
|
782
825
|
if not article:
|
|
783
826
|
return {"error": f"Article not found: {key}"}
|
|
784
827
|
|
|
785
|
-
|
|
786
|
-
|
|
828
|
+
target_platforms, err_msg = _resolve_delete_targets(article, platform, delete_all)
|
|
829
|
+
if err_msg:
|
|
830
|
+
return {"error": err_msg}
|
|
831
|
+
|
|
832
|
+
token = _create_token("delete", {"key": key, "platforms": target_platforms})
|
|
833
|
+
return {
|
|
834
|
+
"confirmation_required": True,
|
|
835
|
+
"preview": {
|
|
836
|
+
"title": article["title"],
|
|
837
|
+
"slug": article["slug"],
|
|
838
|
+
"platforms_to_delete": target_platforms,
|
|
839
|
+
},
|
|
840
|
+
"confirmation_token": token,
|
|
841
|
+
"message": f"Call again with confirmation_token to delete from {target_platforms}",
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _resolve_delete_targets(
|
|
846
|
+
article: dict,
|
|
847
|
+
platform: str | None,
|
|
848
|
+
delete_all: bool,
|
|
849
|
+
) -> tuple[list[str], str | None]:
|
|
850
|
+
"""Determine which platforms a delete should target.
|
|
851
|
+
|
|
852
|
+
Returns ``(target_platforms, error)``: at most one is non-empty.
|
|
853
|
+
"""
|
|
854
|
+
# Filter out failure-only rows (platform_id IS NULL) and soft-deleted rows.
|
|
855
|
+
# _article_row_to_dict surfaces all publication rows; we only want real
|
|
856
|
+
# publications. See the failure-row convention in CLAUDE.md.
|
|
857
|
+
def _is_real_publication(data: dict) -> bool:
|
|
858
|
+
return "deleted_at" not in data and bool(data.get("id"))
|
|
859
|
+
|
|
787
860
|
if delete_all:
|
|
788
|
-
|
|
861
|
+
targets = [
|
|
789
862
|
p for p, data in article.get("platforms", {}).items()
|
|
790
|
-
if
|
|
863
|
+
if _is_real_publication(data)
|
|
791
864
|
]
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
target_platforms = [platform]
|
|
865
|
+
if not targets:
|
|
866
|
+
return [], "Nothing to delete"
|
|
867
|
+
return targets, None
|
|
796
868
|
|
|
797
|
-
|
|
798
|
-
|
|
869
|
+
# Single platform path; caller ensured platform is set.
|
|
870
|
+
pdata = article.get("platforms", {}).get(platform)
|
|
871
|
+
if not pdata or not pdata.get("id"):
|
|
872
|
+
return [], f"'{article['title']}' not published to {platform}"
|
|
873
|
+
if "deleted_at" in pdata:
|
|
874
|
+
return [], f"'{article['title']}' on {platform} is already deleted"
|
|
875
|
+
return [platform], None
|
|
799
876
|
|
|
800
|
-
preview = {
|
|
801
|
-
"title": article["title"],
|
|
802
|
-
"slug": article["slug"],
|
|
803
|
-
"platforms_to_delete": target_platforms,
|
|
804
|
-
}
|
|
805
877
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
token = _create_token("delete", {"key": key, "platforms": target_platforms})
|
|
809
|
-
return {
|
|
810
|
-
"confirmation_required": True,
|
|
811
|
-
"preview": preview,
|
|
812
|
-
"confirmation_token": token,
|
|
813
|
-
"message": f"Call again with confirmation_token to delete from {target_platforms}",
|
|
814
|
-
}
|
|
878
|
+
def _execute_deletes(article: dict, target_platforms: list[str]) -> list[dict]:
|
|
879
|
+
"""Run platform delete calls for the given article, recording each outcome.
|
|
815
880
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
881
|
+
Always records a deletion in the registry, even when the platform call
|
|
882
|
+
fails or the platform is in non-API mode -- the user has already consented
|
|
883
|
+
via the confirmation token, so the registry should reflect that intent.
|
|
884
|
+
"""
|
|
885
|
+
from .config import get_api_key, get_platform_mode
|
|
886
|
+
from .platforms import get_platform
|
|
887
|
+
from .registry import get_publication_id, record_deletion
|
|
820
888
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
889
|
+
canonical = article.get("canonical_url") or article["slug"]
|
|
890
|
+
results: list[dict] = []
|
|
891
|
+
|
|
892
|
+
for plat in target_platforms:
|
|
824
893
|
pub_id = get_publication_id(canonical, plat)
|
|
825
894
|
api_key = get_api_key(plat)
|
|
826
|
-
mode = get_platform_mode(plat)
|
|
827
895
|
|
|
828
|
-
|
|
896
|
+
# Non-API mode, missing key, or missing platform_id -> only mark in registry.
|
|
897
|
+
if get_platform_mode(plat) != "api" or not api_key or not pub_id:
|
|
829
898
|
record_deletion(canonical, plat)
|
|
830
899
|
results.append({"platform": plat, "status": "marked_deleted"})
|
|
831
900
|
continue
|
|
832
901
|
|
|
833
902
|
try:
|
|
834
|
-
|
|
835
|
-
platform_obj = platform_cls(api_key)
|
|
836
|
-
del_result = platform_obj.delete(pub_id)
|
|
903
|
+
del_result = get_platform(plat)(api_key).delete(pub_id)
|
|
837
904
|
record_deletion(canonical, plat)
|
|
838
905
|
results.append({
|
|
839
906
|
"platform": plat,
|
|
840
907
|
"status": "deleted" if del_result.success else "marked_deleted",
|
|
841
|
-
"error":
|
|
908
|
+
"error": None if del_result.success else del_result.error,
|
|
842
909
|
})
|
|
843
910
|
except Exception as e:
|
|
844
911
|
record_deletion(canonical, plat)
|
|
845
912
|
results.append({"platform": plat, "status": "marked_deleted", "error": str(e)})
|
|
846
913
|
|
|
847
|
-
return
|
|
914
|
+
return results
|
|
848
915
|
|
|
849
916
|
|
|
850
917
|
@mcp.tool()
|
|
@@ -864,10 +931,11 @@ def crier_archive(key: str, archived: bool = True) -> dict:
|
|
|
864
931
|
return {"error": f"Article not found: {key}"}
|
|
865
932
|
|
|
866
933
|
success = set_archived(key, archived)
|
|
867
|
-
|
|
934
|
+
verb = "archive" if archived else "unarchive"
|
|
935
|
+
past = "archived" if archived else "unarchived"
|
|
868
936
|
if success:
|
|
869
|
-
return {"success": True, "message": f"'{article['title']}' {
|
|
870
|
-
return {"error": f"Failed to {
|
|
937
|
+
return {"success": True, "message": f"'{article['title']}' {past}"}
|
|
938
|
+
return {"error": f"Failed to {verb} '{key}'"}
|
|
871
939
|
|
|
872
940
|
|
|
873
941
|
# ============================================================================
|
|
@@ -963,6 +1031,11 @@ def crier_stats(key: str, platform: str | None = None) -> dict:
|
|
|
963
1031
|
crier_stats("code-without-purpose")
|
|
964
1032
|
crier_stats("code-without-purpose", platform="devto")
|
|
965
1033
|
"""
|
|
1034
|
+
if platform:
|
|
1035
|
+
err = _validate_platform(platform)
|
|
1036
|
+
if err:
|
|
1037
|
+
return {"error": err}
|
|
1038
|
+
|
|
966
1039
|
from .registry import get_cached_stats, get_article
|
|
967
1040
|
|
|
968
1041
|
article = get_article(key)
|
|
@@ -998,6 +1071,11 @@ def crier_stats_refresh(
|
|
|
998
1071
|
crier_stats_refresh("code-without-purpose")
|
|
999
1072
|
crier_stats_refresh("code-without-purpose", platform="devto")
|
|
1000
1073
|
"""
|
|
1074
|
+
if platform:
|
|
1075
|
+
err = _validate_platform(platform)
|
|
1076
|
+
if err:
|
|
1077
|
+
return {"error": err}
|
|
1078
|
+
|
|
1001
1079
|
from .registry import get_article, get_all_articles, save_stats
|
|
1002
1080
|
from .config import get_api_key, is_manual_mode_key, is_import_mode_key
|
|
1003
1081
|
from .platforms import get_platform
|
|
@@ -135,6 +135,16 @@ class Platform(ABC):
|
|
|
135
135
|
)
|
|
136
136
|
return None
|
|
137
137
|
|
|
138
|
+
def _append_canonical_url(self, text: str, article: Article) -> str:
|
|
139
|
+
"""Append the canonical URL to text if not already present.
|
|
140
|
+
|
|
141
|
+
Used by short-form platforms when publishing a rewrite to ensure
|
|
142
|
+
the link back to the canonical source is included.
|
|
143
|
+
"""
|
|
144
|
+
if article.canonical_url and article.canonical_url not in text:
|
|
145
|
+
return f"{text}\n\n{article.canonical_url}"
|
|
146
|
+
return text
|
|
147
|
+
|
|
138
148
|
def retry_request(
|
|
139
149
|
self,
|
|
140
150
|
method: str,
|
|
@@ -75,10 +75,7 @@ class Bluesky(Platform):
|
|
|
75
75
|
"""
|
|
76
76
|
if article.is_rewrite:
|
|
77
77
|
# Body provided (e.g., via --rewrite) — use it directly
|
|
78
|
-
text = article.body
|
|
79
|
-
# Append canonical URL if not already present
|
|
80
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
81
|
-
text = text + "\n\n" + article.canonical_url
|
|
78
|
+
text = self._append_canonical_url(article.body, article)
|
|
82
79
|
else:
|
|
83
80
|
# Auto-construct from metadata
|
|
84
81
|
text_parts = [article.title]
|
|
@@ -50,10 +50,7 @@ class LinkedIn(Platform):
|
|
|
50
50
|
Otherwise constructs from title + description + hashtags + URL.
|
|
51
51
|
"""
|
|
52
52
|
if article.is_rewrite:
|
|
53
|
-
|
|
54
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
55
|
-
text = text + "\n\n" + article.canonical_url
|
|
56
|
-
return text
|
|
53
|
+
return self._append_canonical_url(article.body, article)
|
|
57
54
|
|
|
58
55
|
parts = [article.title]
|
|
59
56
|
|
|
@@ -52,10 +52,7 @@ class Mastodon(Platform):
|
|
|
52
52
|
"""
|
|
53
53
|
if article.is_rewrite:
|
|
54
54
|
# Body provided (e.g., via --rewrite) — use it directly
|
|
55
|
-
text = article.body
|
|
56
|
-
# Append canonical URL if not already present
|
|
57
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
58
|
-
text = text + "\n\n" + article.canonical_url
|
|
55
|
+
text = self._append_canonical_url(article.body, article)
|
|
59
56
|
else:
|
|
60
57
|
# Auto-construct from metadata
|
|
61
58
|
text_parts = [article.title]
|
|
@@ -106,9 +103,7 @@ class Mastodon(Platform):
|
|
|
106
103
|
def update(self, article_id: str, article: Article) -> PublishResult:
|
|
107
104
|
"""Update an existing toot (Mastodon supports editing)."""
|
|
108
105
|
if article.is_rewrite:
|
|
109
|
-
text = article.body
|
|
110
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
111
|
-
text = text + "\n\n" + article.canonical_url
|
|
106
|
+
text = self._append_canonical_url(article.body, article)
|
|
112
107
|
else:
|
|
113
108
|
text_parts = [article.title]
|
|
114
109
|
if article.description:
|
|
@@ -170,10 +170,7 @@ class Threads(Platform):
|
|
|
170
170
|
Otherwise constructs from title + description + URL + hashtags.
|
|
171
171
|
"""
|
|
172
172
|
if article.is_rewrite:
|
|
173
|
-
|
|
174
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
175
|
-
text = text + "\n\n" + article.canonical_url
|
|
176
|
-
return text
|
|
173
|
+
return self._append_canonical_url(article.body, article)
|
|
177
174
|
|
|
178
175
|
parts = [article.title]
|
|
179
176
|
|
|
@@ -37,10 +37,7 @@ class Twitter(Platform):
|
|
|
37
37
|
Otherwise constructs from title + hashtags + URL.
|
|
38
38
|
"""
|
|
39
39
|
if article.is_rewrite:
|
|
40
|
-
|
|
41
|
-
if article.canonical_url and article.canonical_url not in text:
|
|
42
|
-
text = text + "\n\n" + article.canonical_url
|
|
43
|
-
return text
|
|
40
|
+
return self._append_canonical_url(article.body, article)
|
|
44
41
|
|
|
45
42
|
parts = [article.title]
|
|
46
43
|
|
|
@@ -4,11 +4,25 @@ Handles the LLM retry loop, truncation fallback, and Article construction
|
|
|
4
4
|
for auto-rewritten content. Used by both publish() and audit() in cli.py.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
8
|
|
|
9
9
|
from .platforms.base import Article
|
|
10
10
|
from .utils import truncate_at_sentence
|
|
11
11
|
|
|
12
|
+
__all__ = ["AutoRewriteResult", "apply_rewrite", "auto_rewrite_for_platform"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_rewrite(article: Article, new_body: str) -> Article:
|
|
16
|
+
"""Return a copy of article with body replaced and is_rewrite=True.
|
|
17
|
+
|
|
18
|
+
Single source of truth for "the article has been rewritten for a
|
|
19
|
+
platform". Used by both the auto-rewrite retry loop and the MCP
|
|
20
|
+
server's manual rewrite path. Using ``dataclasses.replace`` ensures
|
|
21
|
+
new Article fields are picked up automatically without manual
|
|
22
|
+
constructor maintenance.
|
|
23
|
+
"""
|
|
24
|
+
return replace(article, body=new_body, is_rewrite=True)
|
|
25
|
+
|
|
12
26
|
|
|
13
27
|
@dataclass
|
|
14
28
|
class AutoRewriteResult:
|
|
@@ -106,7 +120,7 @@ def auto_rewrite_for_platform(
|
|
|
106
120
|
)
|
|
107
121
|
return AutoRewriteResult(
|
|
108
122
|
success=True,
|
|
109
|
-
article=
|
|
123
|
+
article=apply_rewrite(article, final_text),
|
|
110
124
|
rewrite_text=final_text,
|
|
111
125
|
)
|
|
112
126
|
|
|
@@ -120,7 +134,7 @@ def auto_rewrite_for_platform(
|
|
|
120
134
|
)
|
|
121
135
|
return AutoRewriteResult(
|
|
122
136
|
success=True,
|
|
123
|
-
article=
|
|
137
|
+
article=apply_rewrite(article, truncated),
|
|
124
138
|
rewrite_text=truncated,
|
|
125
139
|
)
|
|
126
140
|
|
|
@@ -139,17 +153,3 @@ def auto_rewrite_for_platform(
|
|
|
139
153
|
success=False,
|
|
140
154
|
error=f"Auto-rewrite failed: {e}",
|
|
141
155
|
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _rewritten_article(original: Article, new_body: str) -> Article:
|
|
145
|
-
"""Create a new Article with rewritten body, preserving metadata."""
|
|
146
|
-
return Article(
|
|
147
|
-
title=original.title,
|
|
148
|
-
body=new_body,
|
|
149
|
-
description=original.description,
|
|
150
|
-
tags=original.tags,
|
|
151
|
-
canonical_url=original.canonical_url,
|
|
152
|
-
published=original.published,
|
|
153
|
-
cover_image=original.cover_image,
|
|
154
|
-
is_rewrite=True,
|
|
155
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|