feat: add recommended flag with priority sorting

- Add `recommended` field to AddonInfo model (omitted from output when false)
- Rename `_load_repo_tags` to `_load_repo_config` to load all addon metadata
- Sort output: recommended addons first, then by stars descending
- Mark 5 initial recommended addons: zotero-pdf-translate, zotero-better-notes,
  zotero-better-bibtex, jasminum, paper-chat-for-zotero
- Add 4 test cases for recommended field serialization and deserialization

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ytshen 2026-04-11 11:51:32 +08:00
parent 21bd6bd625
commit adcb028cfe
8 changed files with 49 additions and 18 deletions

View File

@ -1 +1 @@
{"tags": ["metadata"]} {"tags": ["metadata"], "recommended": true}

View File

@ -1 +1 @@
{"tags": ["integration"]} {"tags": ["integration"], "recommended": true}

View File

@ -1 +1 @@
{"tags": ["ai"]} {"tags": ["ai"], "recommended": true}

View File

@ -1 +1 @@
{"tags": ["notes"]} {"tags": ["notes"], "recommended": true}

View File

@ -1 +1 @@
{"tags": ["reader"]} {"tags": ["reader"], "recommended": true}

View File

@ -156,6 +156,7 @@ class AddonInfo:
stars: Optional[int] = None stars: Optional[int] = None
author: Author = field(default_factory=Author) author: Author = field(default_factory=Author)
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
recommended: bool = False
@property @property
def owner(self) -> Optional[str]: def owner(self) -> Optional[str]:
@ -193,6 +194,8 @@ class AddonInfo:
result["author"] = author_dict result["author"] = author_dict
if self.tags: if self.tags:
result["tags"] = self.tags result["tags"] = self.tags
if self.recommended:
result["recommended"] = True
return result return result
@classmethod @classmethod
@ -219,4 +222,5 @@ class AddonInfo:
stars=data.get("stars"), stars=data.get("stars"),
author=author, author=author,
tags=data.get("tags", []), tags=data.get("tags", []),
recommended=data.get("recommended", False),
) )

View File

@ -72,8 +72,10 @@ class CacheScraper:
for url in self.config.previous_info_urls: for url in self.config.previous_info_urls:
result = self.fallback.apply_fallback(result, url) result = self.fallback.apply_fallback(result, url)
# Sort by stars (descending) # Sort: recommended first, then by stars (descending)
result.sort(key=lambda x: x.get("stars") or 0, reverse=True) result.sort(
key=lambda x: (not x.get("recommended", False), -(x.get("stars") or 0))
)
# Save output # Save output
self._save_output(result) self._save_output(result)
@ -102,25 +104,24 @@ class CacheScraper:
return repos return repos
def _load_repo_tags(self, repo: str) -> list[str]: def _load_repo_config(self, repo: str) -> dict:
"""Load tags for a repository from its input file. """Load metadata for a repository from its input file.
The file may contain JSON like {"tags": ["ai", "notes"]} or be empty. The file may contain JSON like {"tags": ["ai"], "recommended": true} or be empty.
Returns empty list if no tags are defined. Returns empty dict if no metadata is defined.
""" """
filename = repo.replace("/", "@", 1) filename = repo.replace("/", "@", 1)
config_file = self.config.input_dir / filename config_file = self.config.input_dir / filename
if not config_file.exists(): if not config_file.exists():
return [] return {}
try: try:
content = config_file.read_text(encoding="utf-8").strip() content = config_file.read_text(encoding="utf-8").strip()
if not content: if not content:
return [] return {}
data = json.loads(content) return json.loads(content)
return data.get("tags", [])
except Exception as e: except Exception as e:
logger.debug(f"Failed to parse tags from {config_file}: {e}") logger.debug(f"Failed to parse config from {config_file}: {e}")
return [] return {}
def _process_repo(self, repo: str) -> Optional[AddonInfo]: def _process_repo(self, repo: str) -> Optional[AddonInfo]:
"""Process a single repository using cache. """Process a single repository using cache.
@ -153,11 +154,13 @@ class CacheScraper:
return None return None
# Create AddonInfo # Create AddonInfo
repo_config = self._load_repo_config(repo)
addon_info = AddonInfo( addon_info = AddonInfo(
repo=repo, repo=repo,
releases=releases, releases=releases,
name=name, name=name,
tags=self._load_repo_tags(repo), tags=repo_config.get("tags", []),
recommended=repo_config.get("recommended", False),
) )
# Fetch author info # Fetch author info

View File

@ -211,3 +211,27 @@ class TestAddonInfo:
data = {"repo": "owner/repo"} data = {"repo": "owner/repo"}
addon = AddonInfo.from_dict(data) addon = AddonInfo.from_dict(data)
assert addon.tags == [] assert addon.tags == []
def test_to_dict_includes_recommended_when_true(self):
"""Test to_dict includes recommended field only when True."""
addon = AddonInfo(repo="owner/repo", recommended=True)
result = addon.to_dict()
assert result["recommended"] is True
def test_to_dict_omits_recommended_when_false(self):
"""Test to_dict omits recommended key when False (backward compat)."""
addon = AddonInfo(repo="owner/repo", recommended=False)
result = addon.to_dict()
assert "recommended" not in result
def test_from_dict_parses_recommended(self):
"""Test from_dict parses recommended field."""
data = {"repo": "owner/repo", "recommended": True}
addon = AddonInfo.from_dict(data)
assert addon.recommended is True
def test_from_dict_recommended_defaults_to_false(self):
"""Test from_dict defaults recommended to False when key absent."""
data = {"repo": "owner/repo"}
addon = AddonInfo.from_dict(data)
assert addon.recommended is False