linkgnome 0.0.3__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.
linkgnome/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """LinkGnome - Terminal-based link aggregator for social feeds."""
2
+
3
+ __version__ = "0.0.3"
linkgnome/cache.py ADDED
@@ -0,0 +1,57 @@
1
+ """Caching layer for fetched feed data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from diskcache import Cache
10
+
11
+
12
+ class FeedCache:
13
+ """Cache for feed data."""
14
+
15
+ def __init__(self, cache_dir: Path | None = None, ttl_seconds: int = 600):
16
+ self.cache_dir = cache_dir or Path.home() / ".cache" / "linkgnome"
17
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
18
+ self.ttl_seconds = ttl_seconds
19
+ self._cache = Cache(str(self.cache_dir))
20
+
21
+ def get_feed(
22
+ self, platform: str, timeline_type: str
23
+ ) -> list[dict[str, Any]] | None:
24
+ """Get cached feed data if it hasn't expired."""
25
+ cache_key = f"{platform}:{timeline_type}"
26
+ cached = self._cache.get(cache_key)
27
+ if cached is None:
28
+ return None
29
+
30
+ data, timestamp = cached
31
+ if datetime.now().timestamp() - timestamp > self.ttl_seconds:
32
+ self._cache.delete(cache_key)
33
+ return None
34
+
35
+ return data
36
+
37
+ def set_feed(
38
+ self,
39
+ platform: str,
40
+ timeline_type: str,
41
+ posts: list[dict[str, Any]],
42
+ ) -> None:
43
+ """Cache feed data."""
44
+ cache_key = f"{platform}:{timeline_type}"
45
+ self._cache.set(
46
+ cache_key,
47
+ (posts, datetime.now().timestamp()),
48
+ expire=self.ttl_seconds,
49
+ )
50
+
51
+ def clear(self) -> None:
52
+ """Clear all cached data."""
53
+ self._cache.clear()
54
+
55
+ def close(self) -> None:
56
+ """Close the cache."""
57
+ self._cache.close()
linkgnome/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """CLI entry point for LinkGnome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from linkgnome.cache import FeedCache
9
+ from linkgnome.config import ConfigManager
10
+ from linkgnome.db import LinkgnomeDB
11
+ from linkgnome.setup import run_setup
12
+ from linkgnome.tui import run_tui
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group()
18
+ @click.pass_context
19
+ def main(ctx: click.Context) -> None:
20
+ """LinkGnome - Terminal-based link aggregator for social feeds."""
21
+ ctx.ensure_object(dict)
22
+ ctx.obj["config_manager"] = ConfigManager()
23
+
24
+
25
+ @main.command()
26
+ @click.pass_context
27
+ def setup(ctx: click.Context) -> None:
28
+ """Walk through interactive configuration."""
29
+ config_manager = ctx.obj["config_manager"]
30
+ run_setup(config_manager)
31
+
32
+
33
+ @main.command()
34
+ @click.option("--period", type=str, default=None, help="Time period (e.g., 24h, 7d)")
35
+ @click.option("--page", type=int, default=1, help="Page number (10 items per page)")
36
+ @click.option(
37
+ "--limit", type=int, default=None, help="Number of links per page (default: 10)"
38
+ )
39
+ @click.option(
40
+ "--platform",
41
+ type=click.Choice(["mastodon", "bluesky"]),
42
+ default=None,
43
+ help="Filter by platform",
44
+ )
45
+ @click.pass_context
46
+ def fetch(
47
+ ctx: click.Context, period: str | None, page: int, limit: int | None, platform: str | None
48
+ ) -> None:
49
+ """Fetch and display ranked links from your feeds."""
50
+ config_manager = ctx.obj["config_manager"]
51
+ settings = config_manager.get()
52
+
53
+ if not settings.mastodon.enabled and not settings.bluesky.enabled:
54
+ console.print(
55
+ "[bold yellow]No platforms configured. Run 'linkgnome setup' first.[/bold yellow]"
56
+ )
57
+ raise click.Abort()
58
+
59
+ hours = _parse_period(period) if period else settings.period_hours
60
+ page_size = limit or 10
61
+ run_tui(settings, hours=hours, page=page, page_size=page_size, platform_filter=platform)
62
+
63
+
64
+ @main.command()
65
+ @click.pass_context
66
+ def config(ctx: click.Context) -> None:
67
+ """Show current configuration."""
68
+ config_manager = ctx.obj["config_manager"]
69
+ settings = config_manager.get()
70
+
71
+ console.print("[bold]LinkGnome Configuration[/bold]\n")
72
+
73
+ console.print("[bold]Mastodon:[/bold]")
74
+ if settings.mastodon.enabled:
75
+ console.print(f" Instance: {settings.mastodon.instance_url}")
76
+ console.print(" Status: [green]Configured[/green]")
77
+ else:
78
+ console.print(" Status: [red]Not configured[/red]")
79
+
80
+ console.print("\n[bold]Bluesky:[/bold]")
81
+ if settings.bluesky.enabled:
82
+ console.print(f" Handle: {settings.bluesky.handle}")
83
+ console.print(" Status: [green]Configured[/green]")
84
+ else:
85
+ console.print(" Status: [red]Not configured[/red]")
86
+
87
+ console.print("\n[bold]Settings:[/bold]")
88
+ console.print(f" Period: {settings.period_hours} hours")
89
+ console.print(f" Page size: {settings.page_size} items")
90
+
91
+
92
+ @main.command()
93
+ @click.pass_context
94
+ def clear_cache(ctx: click.Context) -> None:
95
+ """Clear all cached data."""
96
+ console = Console()
97
+
98
+ # Clear FeedCache
99
+ feed_cache = FeedCache()
100
+ feed_cache.clear()
101
+ feed_cache.close()
102
+
103
+ # Clear database old posts
104
+ db = LinkgnomeDB()
105
+ deleted_count = db.clear_old_posts(0) # Clear all posts
106
+ db.close()
107
+
108
+ console.print("[green]Cache cleared successfully![/green]")
109
+ console.print(f"[yellow]Removed {deleted_count} old posts from database[/yellow]")
110
+
111
+
112
+ def _parse_period(period_str: str) -> int:
113
+ """Parse period string like '24h' or '7d' into hours."""
114
+ period_str = period_str.strip().lower()
115
+ if period_str.endswith("h"):
116
+ return int(period_str[:-1])
117
+ elif period_str.endswith("d"):
118
+ return int(period_str[:-1]) * 24
119
+ else:
120
+ return int(period_str)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
linkgnome/config.py ADDED
@@ -0,0 +1,169 @@
1
+ """Configuration management for LinkGnome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+ from pydantic_settings import (
10
+ BaseSettings,
11
+ PydanticBaseSettingsSource,
12
+ SettingsConfigDict,
13
+ TomlConfigSettingsSource,
14
+ )
15
+
16
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "linkgnome"
17
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
18
+ DEFAULT_PERIOD_HOURS = 24
19
+ DEFAULT_PAGE_SIZE = 42
20
+
21
+
22
+ class MastodonConfig(BaseModel):
23
+ """Mastodon configuration."""
24
+
25
+ enabled: bool = False
26
+ instance_url: str = ""
27
+ client_id: str = ""
28
+ client_secret: str = ""
29
+ access_token: str = ""
30
+
31
+
32
+ class BlueskyConfig(BaseModel):
33
+ """Bluesky configuration."""
34
+
35
+ enabled: bool = False
36
+ handle: str = ""
37
+ app_password: str = ""
38
+
39
+
40
+ class LinkgnomeSettings(BaseSettings):
41
+ """Main settings for LinkGnome."""
42
+
43
+ model_config = SettingsConfigDict(
44
+ env_prefix="LINKGNOME_",
45
+ env_file=".env",
46
+ )
47
+
48
+ mastodon: MastodonConfig = Field(default_factory=MastodonConfig)
49
+ bluesky: BlueskyConfig = Field(default_factory=BlueskyConfig)
50
+ period_hours: int = DEFAULT_PERIOD_HOURS
51
+ page_size: int = DEFAULT_PAGE_SIZE
52
+ cache_ttl_seconds: int = 600
53
+
54
+ @classmethod
55
+ def settings_customise_sources(
56
+ cls,
57
+ settings_cls: type[BaseSettings],
58
+ init_settings: PydanticBaseSettingsSource,
59
+ env_settings: PydanticBaseSettingsSource,
60
+ dotenv_settings: PydanticBaseSettingsSource,
61
+ file_secret_settings: PydanticBaseSettingsSource,
62
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
63
+ return (
64
+ init_settings,
65
+ file_secret_settings,
66
+ env_settings,
67
+ dotenv_settings,
68
+ )
69
+
70
+
71
+ class ConfigManager:
72
+ """Manages loading and saving configuration."""
73
+
74
+ def __init__(self, config_path: Path | None = None):
75
+ self.config_path = config_path or DEFAULT_CONFIG_FILE
76
+ self._settings: LinkgnomeSettings | None = None
77
+
78
+ def load(self) -> LinkgnomeSettings:
79
+ """Load settings from config file."""
80
+ if self._settings is not None:
81
+ return self._settings
82
+
83
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
84
+
85
+ if not self.config_path.exists():
86
+ self._settings = LinkgnomeSettings()
87
+ return self._settings
88
+
89
+ class TomlSettings(LinkgnomeSettings):
90
+ model_config = SettingsConfigDict(
91
+ env_prefix="LINKGNOME_",
92
+ env_file=".env",
93
+ )
94
+
95
+ @classmethod
96
+ def settings_customise_sources(
97
+ cls,
98
+ settings_cls: type[BaseSettings],
99
+ init_settings: PydanticBaseSettingsSource,
100
+ env_settings: PydanticBaseSettingsSource,
101
+ dotenv_settings: PydanticBaseSettingsSource,
102
+ file_secret_settings: PydanticBaseSettingsSource,
103
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
104
+ return (
105
+ TomlConfigSettingsSource(settings_cls, cls.config_path),
106
+ env_settings,
107
+ dotenv_settings,
108
+ )
109
+
110
+ @property
111
+ def config_path(self) -> Path:
112
+ return Path(_get_toml_path())
113
+
114
+ try:
115
+ toml_data = _parse_toml_file(self.config_path)
116
+ self._settings = LinkgnomeSettings(**toml_data)
117
+ except Exception:
118
+ self._settings = LinkgnomeSettings()
119
+
120
+ return self._settings
121
+
122
+ def save(self, settings: LinkgnomeSettings) -> None:
123
+ """Save settings to config file."""
124
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
125
+ data = settings.model_dump(mode="json")
126
+ toml_content = _dict_to_toml(data)
127
+ self.config_path.write_text(toml_content)
128
+ self._settings = settings
129
+
130
+ def get(self) -> LinkgnomeSettings:
131
+ """Get current settings, loading if necessary."""
132
+ if self._settings is None:
133
+ self.load()
134
+ return self._settings
135
+
136
+
137
+ def _get_toml_path() -> str:
138
+ return str(DEFAULT_CONFIG_FILE)
139
+
140
+
141
+ def _parse_toml_file(path: Path) -> dict[str, Any]:
142
+ """Parse a TOML file manually (minimal implementation)."""
143
+ import tomllib
144
+
145
+ with open(path, "rb") as f:
146
+ return tomllib.load(f)
147
+
148
+
149
+ def _dict_to_toml(data: dict[str, Any], indent: int = 0) -> str:
150
+ """Convert a dict to TOML string."""
151
+ lines = []
152
+ prefix = " " * indent
153
+
154
+ for key, value in data.items():
155
+ if isinstance(value, dict):
156
+ lines.append(f"{prefix}[{key}]")
157
+ lines.append(_dict_to_toml(value, indent))
158
+ lines.append("")
159
+ elif isinstance(value, bool):
160
+ lines.append(f"{prefix}{key} = {str(value).lower()}")
161
+ elif isinstance(value, int):
162
+ lines.append(f"{prefix}{key} = {value}")
163
+ elif isinstance(value, str):
164
+ escaped = value.replace('"', '\\"')
165
+ lines.append(f'{prefix}{key} = "{escaped}"')
166
+ else:
167
+ lines.append(f"{prefix}{key} = {value!r}")
168
+
169
+ return "\n".join(lines)
linkgnome/db.py ADDED
@@ -0,0 +1,278 @@
1
+ """SQLite database layer for LinkGnome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from linkgnome.fetchers.base import Platform, Post
12
+
13
+ DB_DIR = Path.home() / ".local" / "share" / "linkgnome"
14
+ DB_PATH = DB_DIR / "linkgnome.db"
15
+
16
+ SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS posts (
18
+ id TEXT PRIMARY KEY,
19
+ platform TEXT NOT NULL,
20
+ author TEXT NOT NULL,
21
+ author_display_name TEXT,
22
+ content TEXT,
23
+ created_at TEXT NOT NULL,
24
+ is_boost INTEGER DEFAULT 0,
25
+ original_post_id TEXT,
26
+ boosted_by TEXT,
27
+ boost_count INTEGER DEFAULT 0,
28
+ like_count INTEGER DEFAULT 0,
29
+ raw_data TEXT
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS post_urls (
33
+ post_id TEXT NOT NULL,
34
+ url TEXT NOT NULL,
35
+ PRIMARY KEY (post_id, url),
36
+ FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS url_metadata (
40
+ url TEXT PRIMARY KEY,
41
+ title TEXT,
42
+ status_code INTEGER,
43
+ fetched_at TEXT,
44
+ final_url TEXT
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS profiles (
48
+ platform TEXT NOT NULL,
49
+ handle TEXT NOT NULL,
50
+ display_name TEXT,
51
+ bio TEXT,
52
+ avatar_url TEXT,
53
+ last_updated TEXT,
54
+ PRIMARY KEY (platform, handle)
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at);
58
+ CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
59
+ CREATE INDEX IF NOT EXISTS idx_post_urls_post ON post_urls(post_id);
60
+ CREATE INDEX IF NOT EXISTS idx_post_urls_url ON post_urls(url);
61
+ """
62
+
63
+
64
+ class LinkgnomeDB:
65
+ """SQLite-backed storage for posts, URLs, metadata, and profiles."""
66
+
67
+ def __init__(self, db_path: Path | None = None):
68
+ self.db_path = db_path or DB_PATH
69
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
70
+ self._conn: sqlite3.Connection | None = None
71
+
72
+ @property
73
+ def conn(self) -> sqlite3.Connection:
74
+ if self._conn is None:
75
+ self._conn = sqlite3.connect(str(self.db_path))
76
+ self._conn.row_factory = sqlite3.Row
77
+ self._conn.execute("PRAGMA journal_mode=WAL")
78
+ self._conn.execute("PRAGMA foreign_keys=ON")
79
+ self._conn.executescript(SCHEMA)
80
+ self._conn.commit()
81
+ return self._conn
82
+
83
+ def save_posts(self, posts: list[Post]) -> int:
84
+ """Batch insert posts. Returns count of new posts added."""
85
+ conn = self.conn
86
+ rows_added = 0
87
+ for post in posts:
88
+ existing = conn.execute(
89
+ "SELECT id FROM posts WHERE id = ?", (post.id,)
90
+ ).fetchone()
91
+ if existing:
92
+ continue
93
+
94
+ conn.execute(
95
+ """INSERT INTO posts
96
+ (id, platform, author, author_display_name, content,
97
+ created_at, is_boost, original_post_id, boosted_by,
98
+ boost_count, like_count, raw_data)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
100
+ (
101
+ post.id,
102
+ post.platform.value,
103
+ post.author,
104
+ post.author_display_name,
105
+ post.content,
106
+ post.created_at.isoformat(),
107
+ int(post.is_boost),
108
+ post.original_post_id,
109
+ post.boosted_by,
110
+ post.boost_count,
111
+ post.like_count,
112
+ json.dumps(post.raw_data) if post.raw_data else None,
113
+ ),
114
+ )
115
+
116
+ for url in post.urls:
117
+ # Normalize URLs to prevent truncation issues
118
+ # Remove trailing ellipsis that might be present in Bluesky truncated URLs
119
+ normalized_url = url.rstrip('...')
120
+ conn.execute(
121
+ "INSERT OR IGNORE INTO post_urls (post_id, url) VALUES (?, ?)",
122
+ (post.id, normalized_url),
123
+ )
124
+
125
+ rows_added += 1
126
+
127
+ conn.commit()
128
+ return rows_added
129
+
130
+ def load_posts(
131
+ self,
132
+ platform: str | None = None,
133
+ since: datetime | None = None,
134
+ limit: int | None = None,
135
+ ) -> list[Post]:
136
+ """Load posts with their URLs."""
137
+ query = """
138
+ SELECT p.*, GROUP_CONCAT(DISTINCT pu.url) as urls
139
+ FROM posts p
140
+ LEFT JOIN post_urls pu ON p.id = pu.post_id
141
+ """
142
+ params: list[Any] = []
143
+ where_clauses = []
144
+
145
+ if platform:
146
+ where_clauses.append("p.platform = ?")
147
+ params.append(platform)
148
+ if since:
149
+ where_clauses.append("p.created_at >= ?")
150
+ params.append(since.isoformat())
151
+
152
+ if where_clauses:
153
+ query += " WHERE " + " AND ".join(where_clauses)
154
+
155
+ query += " GROUP BY p.id"
156
+
157
+ if limit:
158
+ query += " LIMIT ?"
159
+ params.append(limit)
160
+
161
+ rows = self.conn.execute(query, params).fetchall()
162
+ posts = []
163
+ for row in rows:
164
+ urls_str = row["urls"]
165
+ # Handle case where URL might contain commas or be malformed
166
+ if urls_str:
167
+ # Split by comma but be careful about URLs that might contain commas
168
+ urls = [url.strip() for url in urls_str.split(',')]
169
+ else:
170
+ urls = []
171
+ # Clean up any truncated URLs that may have been saved
172
+ cleaned_urls = [url.rstrip('...') for url in urls]
173
+ posts.append(
174
+ Post(
175
+ id=row["id"],
176
+ platform=Platform(row["platform"]),
177
+ author=row["author"],
178
+ author_display_name=row["author_display_name"] or "",
179
+ content=row["content"] or "",
180
+ urls=cleaned_urls,
181
+ created_at=datetime.fromisoformat(row["created_at"]),
182
+ is_boost=bool(row["is_boost"]),
183
+ original_post_id=row["original_post_id"],
184
+ boosted_by=row["boosted_by"],
185
+ boost_count=row["boost_count"],
186
+ like_count=row["like_count"],
187
+ raw_data=json.loads(row["raw_data"]) if row["raw_data"] else None,
188
+ )
189
+ )
190
+ return posts
191
+
192
+ def count_posts(self, since: datetime | None = None) -> int:
193
+ """Count posts matching criteria."""
194
+ query = "SELECT COUNT(*) as cnt FROM posts"
195
+ params: list[Any] = []
196
+ if since:
197
+ query += " WHERE created_at >= ?"
198
+ params.append(since.isoformat())
199
+ row = self.conn.execute(query, params).fetchone()
200
+ return row["cnt"] if row else 0
201
+
202
+ def clear_old_posts(self, keep_hours: int = 24) -> int:
203
+ """Remove posts older than the specified window. Returns count deleted."""
204
+ cutoff = (
205
+ datetime.now(timezone.utc) - timedelta(hours=keep_hours)
206
+ ).isoformat()
207
+ cursor = self.conn.execute(
208
+ "DELETE FROM posts WHERE created_at < ?", (cutoff,)
209
+ )
210
+ self.conn.execute(
211
+ "DELETE FROM post_urls WHERE post_id NOT IN (SELECT id FROM posts)"
212
+ )
213
+ self.conn.commit()
214
+ return cursor.rowcount
215
+
216
+ def save_url_metadata(
217
+ self,
218
+ url: str,
219
+ title: str | None,
220
+ status_code: int,
221
+ final_url: str | None = None,
222
+ ) -> None:
223
+ """Cache fetched metadata for a URL."""
224
+ now = datetime.now(timezone.utc).isoformat()
225
+ self.conn.execute(
226
+ """INSERT OR REPLACE INTO url_metadata
227
+ (url, title, status_code, fetched_at, final_url)
228
+ VALUES (?, ?, ?, ?, ?)""",
229
+ (url, title, status_code, now, final_url),
230
+ )
231
+ self.conn.commit()
232
+
233
+ def get_url_metadata(self, url: str) -> dict[str, Any] | None:
234
+ """Get cached metadata for a URL."""
235
+ row = self.conn.execute(
236
+ "SELECT * FROM url_metadata WHERE url = ?", (url,)
237
+ ).fetchone()
238
+ if row is None:
239
+ return None
240
+ return {
241
+ "title": row["title"],
242
+ "status_code": row["status_code"],
243
+ "fetched_at": row["fetched_at"],
244
+ "final_url": row["final_url"],
245
+ }
246
+
247
+ def save_profile(
248
+ self,
249
+ platform: str,
250
+ handle: str,
251
+ display_name: str | None = None,
252
+ bio: str | None = None,
253
+ avatar_url: str | None = None,
254
+ ) -> None:
255
+ """Save or update a profile."""
256
+ now = datetime.now(timezone.utc).isoformat()
257
+ self.conn.execute(
258
+ """INSERT OR REPLACE INTO profiles
259
+ (platform, handle, display_name, bio, avatar_url, last_updated)
260
+ VALUES (?, ?, ?, ?, ?, ?)""",
261
+ (platform, handle, display_name, bio, avatar_url, now),
262
+ )
263
+ self.conn.commit()
264
+
265
+ def get_profile(self, platform: str, handle: str) -> dict[str, Any] | None:
266
+ """Get profile data."""
267
+ row = self.conn.execute(
268
+ "SELECT * FROM profiles WHERE platform = ? AND handle = ?",
269
+ (platform, handle),
270
+ ).fetchone()
271
+ if row is None:
272
+ return None
273
+ return dict(row)
274
+
275
+ def close(self) -> None:
276
+ if self._conn:
277
+ self._conn.close()
278
+ self._conn = None
File without changes