fgeo 0.1.0__py3-none-any.whl
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.
- fgeo/__init__.py +8 -0
- fgeo/__main__.py +6 -0
- fgeo/_version.py +24 -0
- fgeo/cli.py +70 -0
- fgeo/commands/__init__.py +0 -0
- fgeo/commands/brand.py +100 -0
- fgeo/commands/content.py +301 -0
- fgeo/commands/enable.py +824 -0
- fgeo/commands/goal.py +75 -0
- fgeo/commands/init.py +51 -0
- fgeo/commands/plan.py +191 -0
- fgeo/commands/platform.py +160 -0
- fgeo/commands/project.py +170 -0
- fgeo/commands/publish.py +1394 -0
- fgeo/commands/status.py +123 -0
- fgeo/commands/style.py +142 -0
- fgeo/config.py +52 -0
- fgeo/constants.py +13 -0
- fgeo/converters/__init__.py +1 -0
- fgeo/converters/wechat_html.py +539 -0
- fgeo/database.py +858 -0
- fgeo/publishers/__init__.py +5 -0
- fgeo/publishers/devto.py +136 -0
- fgeo/publishers/devto_quickpost.py +148 -0
- fgeo/publishers/juejin.py +319 -0
- fgeo/publishers/juejin_pin.py +198 -0
- fgeo/publishers/medium.py +542 -0
- fgeo/publishers/wechat.py +478 -0
- fgeo-0.1.0.dist-info/METADATA +99 -0
- fgeo-0.1.0.dist-info/RECORD +34 -0
- fgeo-0.1.0.dist-info/WHEEL +5 -0
- fgeo-0.1.0.dist-info/entry_points.txt +2 -0
- fgeo-0.1.0.dist-info/licenses/LICENSE +201 -0
- fgeo-0.1.0.dist-info/top_level.txt +1 -0
fgeo/__init__.py
ADDED
fgeo/__main__.py
ADDED
fgeo/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
fgeo/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""fgeo CLI — Generative Engine Optimization CLI
|
|
2
|
+
|
|
3
|
+
AI-powered content asset management and multi-platform distribution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from fgeo import __version__
|
|
12
|
+
from fgeo.commands.brand import brand_app
|
|
13
|
+
from fgeo.commands.content import content_app
|
|
14
|
+
from fgeo.commands.project import project_app
|
|
15
|
+
from fgeo.commands.goal import goal_app
|
|
16
|
+
from fgeo.commands.platform import platform_app
|
|
17
|
+
from fgeo.commands.plan import plan_app
|
|
18
|
+
from fgeo.commands.publish import publish_app
|
|
19
|
+
from fgeo.commands.status import status_command
|
|
20
|
+
from fgeo.commands.style import style_app
|
|
21
|
+
from fgeo.commands.enable import SUPPORTED_AGENTS
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="fgeo",
|
|
27
|
+
help="fgeo — Generative Engine Optimization CLI.\n\nAI-powered content asset management and multi-platform distribution.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
rich_markup_mode="rich",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Sub-command groups
|
|
33
|
+
app.add_typer(brand_app, name="brand")
|
|
34
|
+
app.add_typer(project_app, name="project")
|
|
35
|
+
app.add_typer(goal_app, name="goal")
|
|
36
|
+
app.add_typer(platform_app, name="platform")
|
|
37
|
+
app.add_typer(plan_app, name="plan")
|
|
38
|
+
app.add_typer(content_app, name="content")
|
|
39
|
+
app.add_typer(publish_app, name="publish")
|
|
40
|
+
app.add_typer(style_app, name="style")
|
|
41
|
+
|
|
42
|
+
# Top-level commands
|
|
43
|
+
app.command("status")(status_command)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def init() -> None:
|
|
48
|
+
"""Initialize fgeo — create ~/.fgeo global directory with config and database."""
|
|
49
|
+
from fgeo.commands.init import init as _init
|
|
50
|
+
|
|
51
|
+
_init()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def enable(
|
|
56
|
+
agent: str = typer.Argument(help=f"Agent to enable: {', '.join(SUPPORTED_AGENTS)} (or 'list')"),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Enable AI agent integration — sets up fcontext + fgeo skill instructions."""
|
|
59
|
+
from fgeo.commands.enable import enable as _enable
|
|
60
|
+
|
|
61
|
+
_enable(agent)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.callback(invoke_without_command=True)
|
|
65
|
+
def main(
|
|
66
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
67
|
+
) -> None:
|
|
68
|
+
if version:
|
|
69
|
+
console.print(f"fgeo {__version__}")
|
|
70
|
+
raise typer.Exit()
|
|
File without changes
|
fgeo/commands/brand.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""fgeo brand — Global author brand profile management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from fgeo.database import get_db
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
brand_app = typer.Typer(help="Manage global author brand profile (user-level, applies across all projects).")
|
|
14
|
+
|
|
15
|
+
BRAND_FIELDS = ["name", "positioning", "voice", "core_values", "topics"]
|
|
16
|
+
FIELD_LABELS = {
|
|
17
|
+
"name": "Author Name",
|
|
18
|
+
"positioning": "Positioning",
|
|
19
|
+
"voice": "Voice & Tone",
|
|
20
|
+
"core_values": "Core Values",
|
|
21
|
+
"topics": "Topic Domains",
|
|
22
|
+
}
|
|
23
|
+
FIELD_HINTS = {
|
|
24
|
+
"name": "e.g. Marvin Ma · MarvinTalk",
|
|
25
|
+
"positioning": "e.g. 产品技术人 × AI工具链布道者",
|
|
26
|
+
"voice": "e.g. 直接、有观点、技术感强、偶尔幽默",
|
|
27
|
+
"core_values": "e.g. 工具驱动生产力、AI Native、持续输出",
|
|
28
|
+
"topics": "e.g. AI工具、GEO/SEO、系统设计、Developer Tools",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@brand_app.command("show")
|
|
33
|
+
def show() -> None:
|
|
34
|
+
"""Show current brand profile."""
|
|
35
|
+
db = get_db()
|
|
36
|
+
brand = db.get_brand()
|
|
37
|
+
db.close()
|
|
38
|
+
|
|
39
|
+
is_empty = all(not brand.get(f) for f in BRAND_FIELDS)
|
|
40
|
+
|
|
41
|
+
if is_empty:
|
|
42
|
+
console.print(Panel(
|
|
43
|
+
"[yellow]Brand profile not set up yet.[/yellow]\n\n"
|
|
44
|
+
"Run [cyan]fgeo brand set <field> <value>[/cyan] to fill in your profile.\n"
|
|
45
|
+
"Fields: " + ", ".join(BRAND_FIELDS),
|
|
46
|
+
title="🎨 Brand Profile",
|
|
47
|
+
border_style="yellow",
|
|
48
|
+
))
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
table = Table(title="🎨 Brand Profile", show_header=False, box=None, padding=(0, 2))
|
|
52
|
+
table.add_column("Field", style="dim", min_width=14)
|
|
53
|
+
table.add_column("Value", style="bold")
|
|
54
|
+
|
|
55
|
+
for field in BRAND_FIELDS:
|
|
56
|
+
val = brand.get(field, "")
|
|
57
|
+
label = FIELD_LABELS[field]
|
|
58
|
+
table.add_row(label, val if val else "[dim](not set)[/dim]")
|
|
59
|
+
|
|
60
|
+
console.print(table)
|
|
61
|
+
if brand.get("updated_at"):
|
|
62
|
+
console.print(f" [dim]Last updated: {brand['updated_at'][:19]}[/dim]")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@brand_app.command("set")
|
|
66
|
+
def set_field(
|
|
67
|
+
field: str = typer.Argument(help=f"Field to set: {', '.join(BRAND_FIELDS)}"),
|
|
68
|
+
value: str = typer.Argument(help="New value"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Set a brand profile field."""
|
|
71
|
+
if field not in BRAND_FIELDS:
|
|
72
|
+
console.print(f"[red]Unknown field '{field}'. Choose from: {', '.join(BRAND_FIELDS)}[/red]")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
db = get_db()
|
|
76
|
+
result = db.set_brand(field, value)
|
|
77
|
+
db.close()
|
|
78
|
+
|
|
79
|
+
if not result:
|
|
80
|
+
console.print(f"[red]Failed to set brand field '{field}'.[/red]")
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
console.print(f"[green]✓[/green] brand.[cyan]{field}[/cyan] → {value}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@brand_app.command("init")
|
|
87
|
+
def init() -> None:
|
|
88
|
+
"""Print brand setup guide for AI-assisted onboarding."""
|
|
89
|
+
console.print(Panel(
|
|
90
|
+
"[bold]Brand onboarding guide[/bold]\n\n"
|
|
91
|
+
"This command is intended to be called by AI agents during onboarding.\n"
|
|
92
|
+
"The AI should interview the user and then call [cyan]fgeo brand set[/cyan] for each field.\n\n"
|
|
93
|
+
"[bold]Fields to fill:[/bold]\n"
|
|
94
|
+
+ "\n".join(
|
|
95
|
+
f" [cyan]fgeo brand set {f} \"...\"[/cyan] — {FIELD_HINTS[f]}"
|
|
96
|
+
for f in BRAND_FIELDS
|
|
97
|
+
),
|
|
98
|
+
title="🎨 Brand Init",
|
|
99
|
+
border_style="cyan",
|
|
100
|
+
))
|
fgeo/commands/content.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""fgeo content — Register, list, and manage content assets (v0.2 SQLite)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from fgeo.database import get_db
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
content_app = typer.Typer(help="Manage content assets — atomic pieces of platform-native content.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_frontmatter(path: Path) -> dict:
|
|
19
|
+
"""Extract frontmatter fields from a markdown file."""
|
|
20
|
+
result: dict = {}
|
|
21
|
+
try:
|
|
22
|
+
text = path.read_text(encoding="utf-8")
|
|
23
|
+
lines = text.split("\n")
|
|
24
|
+
if lines and lines[0].strip() == "---":
|
|
25
|
+
for line in lines[1:]:
|
|
26
|
+
if line.strip() == "---":
|
|
27
|
+
break
|
|
28
|
+
if ":" in line:
|
|
29
|
+
key, _, value = line.partition(":")
|
|
30
|
+
key = key.strip().lower()
|
|
31
|
+
value = value.strip().strip("\"'")
|
|
32
|
+
if key == "tags":
|
|
33
|
+
value = value.strip("[]")
|
|
34
|
+
result[key] = [t.strip().strip("\"'") for t in value.split(",") if t.strip()]
|
|
35
|
+
else:
|
|
36
|
+
result[key] = value
|
|
37
|
+
if "title" not in result:
|
|
38
|
+
for line in lines:
|
|
39
|
+
if line.startswith("# "):
|
|
40
|
+
result["title"] = line[2:].strip()
|
|
41
|
+
break
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@content_app.command("register")
|
|
48
|
+
def register(
|
|
49
|
+
path: Path = typer.Argument(help="Path to the content file"),
|
|
50
|
+
title: str = typer.Option("", "--title", "-t", help="Override title"),
|
|
51
|
+
project: str = typer.Option("", "--project", "-P", help="Project name"),
|
|
52
|
+
platform: str = typer.Option("", "--platform", help="Platform name"),
|
|
53
|
+
plan: str = typer.Option("", "--plan", help="Plan name"),
|
|
54
|
+
direction: str = typer.Option("", "--direction", "-d", help="Content direction"),
|
|
55
|
+
tags: str = typer.Option("", "--tags", help="Comma-separated tags"),
|
|
56
|
+
description: str = typer.Option("", "--desc", help="Content description"),
|
|
57
|
+
content_type: str = typer.Option("", "--type", help="Content type: article, video, slide, thread, short"),
|
|
58
|
+
status: str = typer.Option("draft", "--status", "-s", help="Initial status: planned, draft, review, published"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Register a content file into the fgeo database."""
|
|
61
|
+
if not path.exists():
|
|
62
|
+
console.print(f"[red]File not found: {path}[/red]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
path = path.resolve()
|
|
66
|
+
|
|
67
|
+
# Extract from frontmatter for markdown files
|
|
68
|
+
if path.suffix.lower() in (".md", ".markdown", ".mdx"):
|
|
69
|
+
fm = _extract_frontmatter(path)
|
|
70
|
+
if not title:
|
|
71
|
+
title = fm.get("title", "")
|
|
72
|
+
if not description:
|
|
73
|
+
description = fm.get("description", "")
|
|
74
|
+
if not tags:
|
|
75
|
+
fm_tags = fm.get("tags", [])
|
|
76
|
+
if isinstance(fm_tags, list):
|
|
77
|
+
tags = ",".join(fm_tags)
|
|
78
|
+
|
|
79
|
+
# Auto-detect content type from extension
|
|
80
|
+
if not content_type:
|
|
81
|
+
suffix = path.suffix.lower()
|
|
82
|
+
if suffix in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
|
|
83
|
+
content_type = "video"
|
|
84
|
+
elif suffix in (".pptx", ".key", ".pdf"):
|
|
85
|
+
content_type = "slide"
|
|
86
|
+
else:
|
|
87
|
+
content_type = "article"
|
|
88
|
+
|
|
89
|
+
if not title:
|
|
90
|
+
title = path.stem.replace("-", " ").replace("_", " ").strip()
|
|
91
|
+
|
|
92
|
+
db = get_db()
|
|
93
|
+
entry = db.register_content(
|
|
94
|
+
source_path=str(path),
|
|
95
|
+
title=title,
|
|
96
|
+
project_name=project,
|
|
97
|
+
platform_name=platform,
|
|
98
|
+
plan_name=plan,
|
|
99
|
+
direction=direction,
|
|
100
|
+
description=description,
|
|
101
|
+
content_type=content_type,
|
|
102
|
+
tags=tags,
|
|
103
|
+
status=status,
|
|
104
|
+
)
|
|
105
|
+
db.close()
|
|
106
|
+
|
|
107
|
+
console.print(
|
|
108
|
+
Panel.fit(
|
|
109
|
+
f"[bold green]Content registered![/bold green]\n\n"
|
|
110
|
+
f" ID: [cyan]{entry['id']}[/cyan]\n"
|
|
111
|
+
f" Title: {entry['title'] or '[dim](untitled)[/dim]'}\n"
|
|
112
|
+
f" Source: {entry['source_path']}\n"
|
|
113
|
+
f" Type: {entry['content_type']}\n"
|
|
114
|
+
f" Project: {project or '[dim](none)[/dim]'}\n"
|
|
115
|
+
f" Platform: {platform or '[dim](none)[/dim]'}\n"
|
|
116
|
+
f" Direction: {direction or '[dim](none)[/dim]'}\n"
|
|
117
|
+
f" Status: {entry['status']}",
|
|
118
|
+
title="📄 fgeo content",
|
|
119
|
+
border_style="green",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@content_app.command("list")
|
|
125
|
+
def list_content(
|
|
126
|
+
project: str = typer.Option("", "--project", "-P", help="Filter by project"),
|
|
127
|
+
platform: str = typer.Option("", "--platform", help="Filter by platform (requires --project)"),
|
|
128
|
+
status: str = typer.Option("", "--status", "-s", help="Filter by status: planned, draft, review, published"),
|
|
129
|
+
direction: str = typer.Option("", "--direction", "-d", help="Filter by direction"),
|
|
130
|
+
no_plan: bool = typer.Option(False, "--no-plan", help="Show only content not assigned to any plan"),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""List content assets."""
|
|
133
|
+
db = get_db()
|
|
134
|
+
entries = db.list_contents(
|
|
135
|
+
project_name=project,
|
|
136
|
+
platform_name=platform,
|
|
137
|
+
status=status,
|
|
138
|
+
direction=direction,
|
|
139
|
+
no_plan=no_plan,
|
|
140
|
+
)
|
|
141
|
+
db.close()
|
|
142
|
+
|
|
143
|
+
if not entries:
|
|
144
|
+
console.print("[yellow]No content found matching filters.[/yellow]")
|
|
145
|
+
console.print(" Run: [cyan]fgeo content register <file>[/cyan]")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
table = Table(title=f"📚 Content ({len(entries)} items)")
|
|
149
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
150
|
+
table.add_column("Title", style="bold")
|
|
151
|
+
table.add_column("Type", style="magenta")
|
|
152
|
+
table.add_column("Project", style="blue")
|
|
153
|
+
table.add_column("Platform", style="green")
|
|
154
|
+
table.add_column("Direction")
|
|
155
|
+
table.add_column("Status", style="yellow")
|
|
156
|
+
table.add_column("Created", style="dim")
|
|
157
|
+
|
|
158
|
+
for e in entries:
|
|
159
|
+
table.add_row(
|
|
160
|
+
e["id"],
|
|
161
|
+
(e["title"][:30] + "...") if len(e["title"] or "") > 30 else (e["title"] or ""),
|
|
162
|
+
e["content_type"],
|
|
163
|
+
e.get("project_name") or "-",
|
|
164
|
+
e.get("platform_name") or "-",
|
|
165
|
+
e["direction"] or "-",
|
|
166
|
+
e["status"],
|
|
167
|
+
e["created_at"][:10],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
console.print(table)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@content_app.command("show")
|
|
174
|
+
def show(
|
|
175
|
+
content_id: str = typer.Argument(help="Content ID"),
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Show content details."""
|
|
178
|
+
db = get_db()
|
|
179
|
+
entry = db.get_content(content_id)
|
|
180
|
+
db.close()
|
|
181
|
+
|
|
182
|
+
if not entry:
|
|
183
|
+
console.print(f"[red]Content not found: {content_id}[/red]")
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
source_exists = Path(entry["source_path"]).exists() if entry["source_path"] else False
|
|
187
|
+
|
|
188
|
+
console.print(
|
|
189
|
+
Panel.fit(
|
|
190
|
+
f" ID: [cyan]{entry['id']}[/cyan]\n"
|
|
191
|
+
f" Title: [bold]{entry['title'] or '(untitled)'}[/bold]\n"
|
|
192
|
+
f" Description: {entry['description'] or '(none)'}\n"
|
|
193
|
+
f" Source: {entry['source_path'] or '(none)'} {'✓' if source_exists else '✗'}\n"
|
|
194
|
+
f" Type: {entry['content_type']}\n"
|
|
195
|
+
f" Direction: {entry['direction'] or '(none)'}\n"
|
|
196
|
+
f" Tags: {entry['tags'] or '(none)'}\n"
|
|
197
|
+
f" Status: {entry['status']}\n"
|
|
198
|
+
f" URL: {entry['published_url'] or '(none)'}\n"
|
|
199
|
+
f" Published: {entry['published_at'] or '-'}\n"
|
|
200
|
+
f" Created: {entry['created_at']}\n"
|
|
201
|
+
f" Updated: {entry['updated_at']}",
|
|
202
|
+
title="📄 Content Detail",
|
|
203
|
+
border_style="blue",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@content_app.command("set")
|
|
209
|
+
def set_field(
|
|
210
|
+
content_id: str = typer.Argument(help="Content ID"),
|
|
211
|
+
field: str = typer.Argument(help="Field to update: title, description, direction, tags, status, published_url, etc."),
|
|
212
|
+
value: str = typer.Argument(help="New value"),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Update a content field."""
|
|
215
|
+
db = get_db()
|
|
216
|
+
result = db.update_content(content_id, field, value)
|
|
217
|
+
db.close()
|
|
218
|
+
|
|
219
|
+
if not result:
|
|
220
|
+
console.print(f"[red]Failed to update. Check content ID and field name ({field}).[/red]")
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
console.print(f"[green]✓[/green] Content {content_id}: {field} → {value}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@content_app.command("assign-plan")
|
|
227
|
+
def assign_plan(
|
|
228
|
+
project: str = typer.Argument(help="Project name"),
|
|
229
|
+
plan: str = typer.Argument(help="Plan name"),
|
|
230
|
+
content_id: str = typer.Option("", "--id", "-i", help="Assign a single content by ID (ignores --platform and --status)."),
|
|
231
|
+
platform: list[str] = typer.Option([], "--platform", "-p", help="Filter by platform name (repeatable). Omit to target all platforms."),
|
|
232
|
+
status: str = typer.Option("", "--status", "-s", help="Filter by content status (draft, published, planned, …). Omit to target all."),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Batch-assign a plan to all matching content in a project.
|
|
235
|
+
|
|
236
|
+
To assign a single content item, use --id:
|
|
237
|
+
fgeo content assign-plan myproj gtm-v1 --id cont-abc123
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
fgeo content assign-plan myproj gtm-v1
|
|
241
|
+
fgeo content assign-plan myproj gtm-v1 --platform devto --platform medium
|
|
242
|
+
fgeo content assign-plan myproj gtm-v1 --platform twitter --status published
|
|
243
|
+
"""
|
|
244
|
+
db = get_db()
|
|
245
|
+
try:
|
|
246
|
+
if content_id:
|
|
247
|
+
entry = db.get_content(content_id)
|
|
248
|
+
if not entry:
|
|
249
|
+
console.print(f"[red]Content not found: {content_id}[/red]")
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
plan_obj = db.get_plan(project, plan)
|
|
252
|
+
if not plan_obj:
|
|
253
|
+
console.print(f"[red]Plan not found: {plan} in project {project}[/red]")
|
|
254
|
+
raise typer.Exit(1)
|
|
255
|
+
db.update_content(content_id, "plan_id", plan_obj["id"])
|
|
256
|
+
console.print(f"[green]✓[/green] Assigned plan [bold]{plan}[/bold] to content {content_id}")
|
|
257
|
+
return
|
|
258
|
+
count = db.assign_plan_to_contents(
|
|
259
|
+
project_name=project,
|
|
260
|
+
plan_name=plan,
|
|
261
|
+
platform_names=list(platform) or None,
|
|
262
|
+
status=status,
|
|
263
|
+
)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
console.print(f"[red]{exc}[/red]")
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
finally:
|
|
268
|
+
db.close()
|
|
269
|
+
|
|
270
|
+
filters: list[str] = []
|
|
271
|
+
if platform:
|
|
272
|
+
filters.append(f"platform: {', '.join(platform)}")
|
|
273
|
+
if status:
|
|
274
|
+
filters.append(f"status: {status}")
|
|
275
|
+
filter_str = f" [dim]({', '.join(filters)})[/dim]" if filters else ""
|
|
276
|
+
console.print(f"[green]✓[/green] Assigned plan [bold]{plan}[/bold] to {count} content(s).{filter_str}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@content_app.command("remove")
|
|
280
|
+
def remove(
|
|
281
|
+
content_id: str = typer.Argument(help="Content ID to remove"),
|
|
282
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Remove a content entry from the registry."""
|
|
285
|
+
db = get_db()
|
|
286
|
+
entry = db.get_content(content_id)
|
|
287
|
+
|
|
288
|
+
if not entry:
|
|
289
|
+
console.print(f"[red]Content not found: {content_id}[/red]")
|
|
290
|
+
db.close()
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
|
|
293
|
+
if not force:
|
|
294
|
+
console.print(f" Removing: [bold]{entry['title'] or content_id}[/bold]")
|
|
295
|
+
if not typer.confirm("Are you sure?", default=False):
|
|
296
|
+
db.close()
|
|
297
|
+
raise typer.Abort()
|
|
298
|
+
|
|
299
|
+
db.remove_content(content_id)
|
|
300
|
+
db.close()
|
|
301
|
+
console.print(f"[green]✓[/green] Removed content: {content_id}")
|