Update todoist.recipe

- recipe_specific_options
- black formatting
This commit is contained in:
rga5321 2025-08-12 17:57:55 +00:00 committed by GitHub
parent f2bc31d77f
commit c2c1deda1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,15 +1,18 @@
#!/usr/bin/env python #!/usr/bin/env python
# vim:fileencoding=utf-8 # vim:ft=python tabstop=8 expandtab shiftwidth=4 softtabstop=4
from __future__ import print_function from __future__ import print_function
__version__ = '0.0.2'
__version__ = "0.0.3"
""" """
recipe repository and docs: https://github.com/rga5321/todoist2ebook 0.0.3: Parameters in recipe_specific_options
0.0.2: Calibre footer with the source URL. QR points to the article URL.
0.0.2: Calibre footer with the source URL. (adapted for calibre)
0.0.1: First working version 0.0.1: First working version
# Calibre parameters
Input them in command line as this example: ebook-convert Todoist.recipe output.epub --recipe-specific-option=ARCHIVE_DOWNLOADED:False --recipe-specific-option=TODOIST_PROJECT_ID:YOUR_PROJECT_ID --recipe-specific-option=TODOIST_API_KEY:YOUR_API_KEY --recipe-specific-option=URL_KEYWORD_EXCEPTIONS:jotdown,elpais.com/gastronomia
**URL_KEYWORD_EXCEPTIONS** (list of keywords such as, if the URL of the article contains any keyword, then the plugin will ignore the article) **URL_KEYWORD_EXCEPTIONS** (list of keywords such as, if the URL of the article contains any keyword, then the plugin will ignore the article)
@ -24,13 +27,25 @@ recipe repository and docs: https://github.com/rga5321/todoist2ebook
""" """
# CONFIGURATION ########################################################### # CONFIGURATION ###########################################################
URL_KEYWORD_EXCEPTIONS = ['XXXX','YYYYY'] import ast
ARCHIVE_DOWNLOADED = False
TODOIST_PROJECT_ID = 'XXXXXXX'
TODOIST_API_KEY = 'YYYYYY'
SITE_PACKAGE_PATH = ''
# Aux funcion. String to boolean
def parse_env_bool(val):
return str(val).strip().lower() in ("true", "1", "yes")
# Aux funcion. comma separated String to List
def parse_env_list(val):
try:
return ast.literal_eval(val)
except Exception:
return []
SITE_PACKAGE_PATH = ""
############################################################################# #############################################################################
from calibre.web.feeds.news import BasicNewsRecipe from calibre.web.feeds.news import BasicNewsRecipe
from collections import namedtuple from collections import namedtuple
from os import path from os import path
@ -39,33 +54,43 @@ from urllib.parse import urlparse
############################################################################# #############################################################################
SITE_PACKAGE_PATH = '' SITE_PACKAGE_PATH = ""
import json import json
import mechanize import mechanize
import re import re
from datetime import datetime from datetime import datetime
__license__ = 'GPL v3' __license__ = "GPL v3"
__copyright__ = '2025, ARG' __copyright__ = "2025, ARG"
class Todoist2ebook(BasicNewsRecipe): class Todoist2ebook(BasicNewsRecipe):
__author__ = 'ARG' recipe_specific_options = {
description = 'prueba' "ARCHIVE_DOWNLOADED": {
publisher = 'Todoist.com' "short": "Mark as read",
category = 'info, custom, Todoist' "long": "Mark as read",
"default": False,
},
"TODOIST_PROJECT_ID": {"short": "Proyect ID", "long": "Proyect ID"},
"TODOIST_API_KEY": {"short": "API key", "long": "API KEY"},
"URL_KEYWORD_EXCEPTIONS": {
"short": "URL keyword exceptions",
"long": 'List of keywords to ignore articles, e.g. ["example.com", "ignoreme.com"]',
"default": [],
},
}
__author__ = "ARG"
description = "prueba"
publisher = "Todoist.com"
category = "info, custom, Todoist"
# User-configurable settings ----------------------------------------------- # User-configurable settings -----------------------------------------------
archive_downloaded = ARCHIVE_DOWNLOADED
series_name = 'Todoist'
series_name = "Todoist"
todoist_project_id =TODOIST_PROJECT_ID publication_type = "magazine"
todoist_api_key = TODOIST_API_KEY
publication_type = 'magazine'
title = "Todoist" title = "Todoist"
# timefmt = '' # uncomment to remove date from the filenames, if commented then you will get something like `Todoist [Wed, 13 May 2020]` # timefmt = '' # uncomment to remove date from the filenames, if commented then you will get something like `Todoist [Wed, 13 May 2020]`
masthead_url = "https://raw.githubusercontent.com/rga5321/todoist2ebook/master/img/todoist-logo.png" masthead_url = "https://raw.githubusercontent.com/rga5321/todoist2ebook/master/img/todoist-logo.png"
@ -83,8 +108,38 @@ class Todoist2ebook(BasicNewsRecipe):
simultaneous_downloads = 10 simultaneous_downloads = 10
extra_css = '.touchscreen_navbar {display: none;}' extra_css = ".touchscreen_navbar {display: none;}"
extra_css = '.calibre_navbar { visibility: hidden; }' extra_css = ".calibre_navbar { visibility: hidden; }"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Init optional configuration parameters
self.archive_downloaded = parse_env_bool(
self.recipe_specific_options["ARCHIVE_DOWNLOADED"]
)
self.keyword_exceptions = parse_env_list(
self.recipe_specific_options["URL_KEYWORD_EXCEPTIONS"]
)
# Init mandatory configuration parameters
if (
"TODOIST_PROJECT_ID" in self.recipe_specific_options
and self.recipe_specific_options["TODOIST_PROJECT_ID"]
):
self.todoist_project_id = self.recipe_specific_options["TODOIST_PROJECT_ID"]
else:
self.abort_recipe_processing(
"TODOIST_PROJECT_ID mandatory parameter missing"
)
if (
"TODOIST_API_KEY" in self.recipe_specific_options
and self.recipe_specific_options["TODOIST_API_KEY"]
):
self.todoist_api_key = self.recipe_specific_options["TODOIST_API_KEY"]
else:
self.abort_recipe_processing("TODOIST_API_KEY mandatory parameter missing")
def parse_index(self): def parse_index(self):
@ -93,7 +148,7 @@ class Todoist2ebook(BasicNewsRecipe):
url = f"https://api.todoist.com/rest/v2/tasks?project_id={self.todoist_project_id}" url = f"https://api.todoist.com/rest/v2/tasks?project_id={self.todoist_project_id}"
headers = {"Authorization": f"Bearer {self.todoist_api_key}"} headers = {"Authorization": f"Bearer {self.todoist_api_key}"}
request = mechanize.Request(url, headers=headers); request = mechanize.Request(url, headers=headers)
response = self.browser.open(request) response = self.browser.open(request)
if response.code != 200: if response.code != 200:
@ -102,35 +157,40 @@ class Todoist2ebook(BasicNewsRecipe):
tasks = json.loads(data) tasks = json.loads(data)
articles_todoist = [] articles_todoist = []
url_regex = re.compile(r'\[([^\]]+)\]\(\s*(https?://[^\s\)]+)\s*\)') url_regex = re.compile(r"\[([^\]]+)\]\(\s*(https?://[^\s\)]+)\s*\)")
for task in tasks: for task in tasks:
match = url_regex.search(task['content']) match = url_regex.search(task["content"])
if match: if match:
title = match.group(1).strip() title = match.group(1).strip()
url = match.group(2).strip() url = match.group(2).strip()
date_added = task.get('created_at', datetime.now().isoformat()) date_added = task.get("created_at", datetime.now().isoformat())
articles_todoist.append({ articles_todoist.append(
'title': title or url, {
'url': url, "title": title or url,
'date_added': date_added, "url": url,
'item_id': task['id'] "date_added": date_added,
}) "item_id": task["id"],
}
)
if not articles_todoist: if not articles_todoist:
self.abort_recipe_processing('No unread articles in the Todoist project "{}"'.format(self.todoist_project_id)) self.abort_recipe_processing(
'No unread articles in the Todoist project "{}"'.format(
self.todoist_project_id
)
)
else: else:
for item in articles_todoist: for item in articles_todoist:
# If the URL contains any URL_KEYWORD_EXCEPTIONS, ignore article # If the URL contains any URL_KEYWORD_EXCEPTIONS, ignore article
if any(pattern in item['url'] for pattern in URL_KEYWORD_EXCEPTIONS): if any(pattern in item["url"] for pattern in self.keyword_exceptions):
print("Ignoring article due to keyword patterns:" + item['url']) print("Ignoring article due to keyword patterns:" + item["url"])
del item del item
else: else:
# Extract domain from the URL # Extract domain from the URL
domain = urlparse(item['url']).netloc.replace('www.', '') domain = urlparse(item["url"]).netloc.replace("www.", "")
url = item['url'] url = item["url"]
# Add the article under its domain # Add the article under its domain
if domain not in section_dict: if domain not in section_dict:
@ -138,7 +198,7 @@ class Todoist2ebook(BasicNewsRecipe):
else: else:
section_dict[domain].append(item) section_dict[domain].append(item)
print("Adding article: " + item['url'] + " to section: " + domain) print("Adding article: " + item["url"] + " to section: " + domain)
############ APPEND ARTS FOR EACH DOMAIN ############# ############ APPEND ARTS FOR EACH DOMAIN #############
# At this point the section_dict is completed # At this point the section_dict is completed
@ -147,34 +207,34 @@ class Todoist2ebook(BasicNewsRecipe):
arts = [] arts = []
for item in section_dict.get(section): for item in section_dict.get(section):
try: try:
title = item['title'] title = item["title"]
except KeyError: except KeyError:
title = 'error: title' title = "error: title"
try: try:
url = item['url'] url = item["url"]
except KeyError: except KeyError:
url = 'error: url' url = "error: url"
arts.append({ arts.append(
'title': title, {"title": title, "url": url, "date": item["date_added"]}
'url': url, )
'date': item['date_added']})
if ( if (
self.archive_downloaded self.archive_downloaded
and item['item_id'] not in self.to_archive and item["item_id"] not in self.to_archive
): ):
self.to_archive.append(item['item_id'] ) self.to_archive.append(item["item_id"])
if arts: if arts:
articles.append((section, arts)) articles.append((section, arts))
if not articles: if not articles:
self.abort_recipe_processing('No articles in the Todoist project account %s to download' % (self.todoist_project_id)) self.abort_recipe_processing(
"No articles in the Todoist project account %s to download"
% (self.todoist_project_id)
)
return articles return articles
def get_browser(self, *args, **kwargs): def get_browser(self, *args, **kwargs):
self.browser = BasicNewsRecipe.get_browser(self) self.browser = BasicNewsRecipe.get_browser(self)
return self.browser return self.browser
@ -189,8 +249,8 @@ class Todoist2ebook(BasicNewsRecipe):
url, url,
headers={ headers={
"Authorization": f"Bearer {self.todoist_api_key}", "Authorization": f"Bearer {self.todoist_api_key}",
"Content-Type": "application/json" "Content-Type": "application/json",
} },
) )
req.get_method = lambda: "POST" req.get_method = lambda: "POST"
@ -204,31 +264,27 @@ class Todoist2ebook(BasicNewsRecipe):
except Exception as e: except Exception as e:
print(f"Exception while closing task {task_id}: {e}") print(f"Exception while closing task {task_id}: {e}")
# TODO: This works with EPUB, but not mobi/azw3 # TODO: This works with EPUB, but not mobi/azw3
# BUG: https://bugs.launchpad.net/calibre/+bug/1838486 # BUG: https://bugs.launchpad.net/calibre/+bug/1838486
def postprocess_book(self, oeb, opts, log): def postprocess_book(self, oeb, opts, log):
oeb.metadata.add('series', self.series_name) oeb.metadata.add("series", self.series_name)
def _postprocess_html(self, soup, first_fetch, job_info): def _postprocess_html(self, soup, first_fetch, job_info):
title = soup.find('title').text # get title title = soup.find("title").text # get title
h1s = soup.findAll("h1") # get all h1 headers
h1s = soup.findAll('h1') # get all h1 headers
for h1 in h1s: for h1 in h1s:
if title in h1.text: if title in h1.text:
h1 = h1.clear() # clean this tag, so the h1 will be there only h1 = h1.clear() # clean this tag, so the h1 will be there only
h2s = soup.findAll('h2') # get all h2 headers h2s = soup.findAll("h2") # get all h2 headers
for h2 in h2s: for h2 in h2s:
if title in h2.text: if title in h2.text:
h2 = h2.clear() # clean this tag, so the h1 will be there only h2 = h2.clear() # clean this tag, so the h1 will be there only
body = soup.find('body') body = soup.find("body")
new_tag = soup.new_tag('h1') new_tag = soup.new_tag("h1")
new_tag.append(title) new_tag.append(title)
body.insert(0, new_tag) body.insert(0, new_tag)
@ -241,20 +297,24 @@ class Todoist2ebook(BasicNewsRecipe):
""" """
try: try:
from calibre.ebooks import calibre_cover from calibre.ebooks import calibre_cover
# Python 2/3 compatibility for unicode # Python 2/3 compatibility for unicode
try: try:
unicode_type = unicode unicode_type = unicode
except NameError: except NameError:
unicode_type = str unicode_type = str
title = self.title if isinstance(self.title, unicode_type) else \ title = (
self.title.encode('utf-8', 'replace').decode('utf-8', 'replace') self.title
if isinstance(self.title, unicode_type)
else self.title.encode("utf-8", "replace").decode("utf-8", "replace")
)
# print('>> title', title, file=sys.stderr) # print('>> title', title, file=sys.stderr)
date = strftime(self.timefmt) date = strftime(self.timefmt)
time = strftime('%a %d %b %Y %-H:%M') time = strftime("%a %d %b %Y %-H:%M")
img_data = calibre_cover(title, date, time) img_data = calibre_cover(title, date, time)
cover_file.write(img_data) cover_file.write(img_data)
cover_file.flush() cover_file.flush()
except: except:
self.log.exception('Failed to generate default cover') self.log.exception("Failed to generate default cover")
return False return False
return True return True