diff --git a/favro_sync/__main__.py b/favro_sync/__main__.py index dce0556..57e9c9a 100644 --- a/favro_sync/__main__.py +++ b/favro_sync/__main__.py @@ -3,18 +3,14 @@ import tempfile import logging import requests_cache -import secret_loader from .favro_client import FavroClient, OrganizationId from .favro_fuse import start_favro_fuse +import secrets def main(): logging.basicConfig() - secrets = secret_loader.SecretLoader() - favro_org_id = secrets.load_or_fail('FAVRO_ORGANIZATION_ID') - favro_username = secrets.load_or_fail('FAVRO_USERNAME') - favro_password = secrets.load_or_fail('FAVRO_PASSWORD') read_only = False @@ -22,9 +18,9 @@ def main(): session = requests_cache.CachedSession( tmpdirname + '/http-cache.sqlite', expire_after=10,) client = FavroClient( - favro_org_id=OrganizationId(favro_org_id), - favro_username=favro_username, - favro_password=favro_password, + favro_org_id=OrganizationId(secrets.favro_org_id()), + favro_username=secrets.favro_username(), + favro_password=secrets.favro_password(), session=session, read_only=read_only, ) diff --git a/favro_sync/favro_client.py b/favro_sync/favro_client.py index dcfdb90..83cfce0 100644 --- a/favro_sync/favro_client.py +++ b/favro_sync/favro_client.py @@ -83,8 +83,9 @@ class FavroClient: response.raise_for_status() json = response.json() - # TODO: Pageination + # TODO: Add support for pageination for entity_json in json['entities']: + print(entity_json) card = Card.from_json(entity_json) self.cache.add_card(card) yield card diff --git a/favro_sync/favro_data_model.py b/favro_sync/favro_data_model.py index 2f3125d..c47da6e 100644 --- a/favro_sync/favro_data_model.py +++ b/favro_sync/favro_data_model.py @@ -28,6 +28,38 @@ class OrganizationId: raw_id: str +@dataclasses.dataclass(frozen=True) +class CardAssignment: + user: UserId + completed: bool + + @staticmethod + def from_json(json: dict[str, Any]) -> 'CardAssignment': + return CardAssignment( + UserId(json['userId']), + json['completed'], + ) + +@dataclasses.dataclass(frozen=True) +class TagId: + raw_id: str + +@dataclasses.dataclass(frozen=True) +class CardDependency: + card_id: CardId + card_common_id: CommonId + is_before: bool + reverse_card_id: CardId + + @staticmethod + def from_json(json: dict[str, Any]) -> 'CardDependency': + return CardDependency( + CardId(json['cardId']), + CommonId(json['cardCommonId']), + json['isBefore'], + CardId(json['reverseCardId']), + ) + @dataclasses.dataclass(frozen=True) class Card: card_id: CardId @@ -36,8 +68,9 @@ class Card: organization_id: OrganizationId is_archived: bool name: str - dependencies: list[None] # TODO - tags: list[None] # TODO + dependencies: list[CardDependency] + assignments: list[CardAssignment] + tags: list[TagId] todo_list_user_id: UserId | None todo_list_completed: bool | None creator_user_id: UserId @@ -45,23 +78,9 @@ class Card: detailed_description: str | None - """ TODO, fieds: - 'position': -399 - 'listPosition': -399 - - 'isLane': False - 'assignments': [{'userId': 'Faieomp8fuS8DrnyP' 'completed': True}] - 'tasksTotal': 0 - 'tasksDone': 0 - 'attachments': [] - 'customFields': - 'timeOnBoard': None - 'timeOnColumns': None - 'favroAttachments': [] - """ - @staticmethod def from_json(json: dict[str, Any]) -> 'Card': + todo_list_user_id = UserId(json['todoListUserId']) if 'todoListUserId' in json else None return Card( card_id=CardId(json['cardId']), seq_id=SeqId(json['sequentialId']), @@ -70,12 +89,11 @@ class Card: is_archived=json['archived'], organization_id=OrganizationId(json['organizationId']), name=json['name'], - todo_list_user_id=UserId(json['todoListUserId']) - if 'todoListUserId' in json - else None, + todo_list_user_id=todo_list_user_id, todo_list_completed=json.get('todoListCompleted'), dependencies=json['dependencies'], - tags=json['tags'], + tags=[TagId(tag) for tag in json['tags']], creator_user_id=UserId(json['createdByUserId']), creation_date=datetime.datetime.fromisoformat(json['createdAt']), + assignments = [CardAssignment.from_json(ass) for ass in json['assignments']], ) diff --git a/favro_sync/favro_fuse.py b/favro_sync/favro_fuse.py index a196643..09db0b3 100644 --- a/favro_sync/favro_fuse.py +++ b/favro_sync/favro_fuse.py @@ -9,7 +9,7 @@ import fuse from .favro_client import FavroClient from .favro_data_model import Card, SeqId -from .favro_markdown import format_card, CardContents, parse_card_contents +from .favro_markdown import CardContents, CardFileFormatter logger = getLogger(__name__) @@ -56,8 +56,9 @@ class CardThing(Thing): class FavroFuse(fuse.Fuse): """Favro Filesystem in Userspace.""" - def __init__(self, favro_client: FavroClient, **kwargs): + def __init__(self, favro_client: FavroClient, formatter: CardFileFormatter, **kwargs): self.favro_client = favro_client + self.formatter = formatter super().__init__(**kwargs) def getattr(self, path: str) -> FavroStat | int: @@ -72,7 +73,7 @@ class FavroFuse(fuse.Fuse): st.st_mode = stat.S_IFREG | 0o666 st.st_nlink = 1 - st.st_size = len(format_card(card)) + st.st_size = len(self.formatter.format_card(card)) st.st_ctime = int(card.creation_date.timestamp()) st.st_mtime = st.st_ctime # TODO else: @@ -101,7 +102,7 @@ class FavroFuse(fuse.Fuse): card = self.favro_client.get_card(thing.seq_id) - contents_str = format_card(card) + contents_str = self.formatter.format_card(card) contents = bytes(contents_str, 'utf8') slen = len(contents) @@ -122,13 +123,13 @@ class FavroFuse(fuse.Fuse): card = self.favro_client.get_card(thing.seq_id) # Splice contents - contents_str = format_card(card) + contents_str = self.formatter.format_card(card) contents = bytes(contents_str, 'utf8') contents = splice(contents, written_buffer, offset) contents_str = contents.decode('utf8') # Write to favro - card_updated = parse_card_contents(contents_str) + card_updated = self.formatter.parse_card_contents(contents_str) self.favro_client.update_card_contents(card.card_id, card_updated) # Return amount written @@ -143,7 +144,7 @@ class FavroFuse(fuse.Fuse): card = self.favro_client.get_card(thing.seq_id) # Splice contents - contents_str = format_card(card) + contents_str = self.formatter.format_card(card) contents = bytes(contents_str, 'utf8') old_size = len(contents) contents = contents[0:new_size] + b' ' * (old_size - new_size) @@ -151,7 +152,7 @@ class FavroFuse(fuse.Fuse): contents_str = contents.decode('utf8') # Write to favro - card_updated = parse_card_contents(contents_str) + card_updated = self.formatter.parse_card_contents(contents_str) self.favro_client.update_card_contents_locally(card.card_id, card_updated) # Return amount written @@ -179,6 +180,7 @@ def start_favro_fuse(favro_client: FavroClient): # TODO: server = FavroFuse( favro_client=favro_client, + formatter=CardFileFormatter(), version='%prog ' + fuse.__version__, usage=HELP, dash_s_do='setsingle', diff --git a/favro_sync/favro_markdown.py b/favro_sync/favro_markdown.py index cc7e6b8..3810086 100644 --- a/favro_sync/favro_markdown.py +++ b/favro_sync/favro_markdown.py @@ -13,60 +13,82 @@ from .favro_data_model import Card, SeqId @dataclasses.dataclass(frozen=True) class CardContents: - name: str - description: str + name: str | None + description: str | None + tags: list[str] + assignments: list[str] -markdown = marko.Markdown() -renderer = marko.md_renderer.MarkdownRenderer() +class CardFileFormatter: + """Component for formatting and parsing card files """ -OBSIDIAN_MODE = True + def __init__(self, obsidian_mode = True): + self.obsidian_mode = obsidian_mode + self.markdown = marko.Markdown() + self.renderer = marko.md_renderer.MarkdownRenderer() -def format_card_contents(card: CardContents) -> str: - ls = [] + def format_card_contents(self, card: CardContents) -> str: + ls = [] - # Frontmatter - if OBSIDIAN_MODE: - if card.name: + # Choose frontmatter data + frontmatter_data = {} + if card.name and self.obsidian_mode: + frontmatter_data['aliases'] = [card.name] + if card.tags: + frontmatter_data['tags'] = card.tags + if self.obsidian_mode: + frontmatter_data['tags'] = ['#'+t for t in frontmatter_data['tags']] + if card.assignments: + frontmatter_data['assignments'] = card.assignments + if self.obsidian_mode: + frontmatter_data['assignments'] = [f'[[{name}]]' for name in frontmatter_data['assignments']] + + # Frontmatter + if frontmatter_data: ls.append('---\n') - # TODO: Tags - ls.append('aliases:\n') - ls.append(' - ') - ls.append(card.name) - ls.append('\n') + for key, values in frontmatter_data.items(): + ls.append(key) + ls.append(':\n') + for v in values: + ls.append(' - ') + ls.append(v) + ls.append('\n') ls.append('---\n\n') - # Card name - if card.name: - ls.append('# ') - ls.append(card.name) - ls.append('\n\n') + # Card name + if card.name: + ls.append('# ') + ls.append(card.name) + ls.append('\n\n') - # Card contents - if card.description: - ls.append(card.description) - return ''.join(ls) + # Card contents + if card.description: + ls.append(card.description) + return ''.join(ls) -def parse_card_contents(contents: str) -> CardContents: - """ - 1. Strips frontmatter - 2. Parses header - 3. Finds content. - """ - fm = frontmatter.loads(contents) - del contents + def parse_card_contents(self, contents: str) -> CardContents: + """ + 1. Strips frontmatter + 2. Parses header + 3. Finds content. + """ + fm = frontmatter.loads(contents) + del contents - document = markdown.parse(fm.content.strip()) - name = None - for elem in document.children: - if isinstance(elem, marko.block.Heading): - name = renderer.render_children(elem) - document.children.remove(elem) - break + document = self.markdown.parse(fm.content.strip()) + name = None + for elem in document.children: + if isinstance(elem, marko.block.Heading): + name = self.renderer.render_children(elem) + document.children.remove(elem) + break - return CardContents( - name, - renderer.render_children(document).strip(), - ) + description = self.renderer.render_children(document).strip() + return CardContents( + name, + description, + tags = [], + assignments = [], + ) -def format_card(card: Card) -> str: - return format_card_contents(CardContents(card.name, card.detailed_description)) + def format_card(self, card: Card) -> str: + return self.format_card_contents(CardContents(card.name, card.detailed_description)) diff --git a/favro_sync/secrets.py b/favro_sync/secrets.py new file mode 100644 index 0000000..30cddd3 --- /dev/null +++ b/favro_sync/secrets.py @@ -0,0 +1,12 @@ +import secret_loader + +secrets = secret_loader.SecretLoader() + +def favro_org_id(): + return secrets.load_or_fail('FAVRO_ORGANIZATION_ID') + +def favro_username(): + return secrets.load_or_fail('FAVRO_USERNAME') + +def favro_password(): + return secrets.load_or_fail('FAVRO_PASSWORD') diff --git a/requirements.txt b/requirements.txt index 0847048..bf57780 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ requests requests-cache fuse-python secret_loader @ git+https://gitfub.space/Jmaa/secret_loader +marko +python-frontmatter diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..eb2779e --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +"""Testing package.""" diff --git a/test/test_client.py b/test/test_client.py new file mode 100644 index 0000000..367376b --- /dev/null +++ b/test/test_client.py @@ -0,0 +1,22 @@ + +from favro_sync import secrets +from favro_sync .favro_client import FavroClient, OrganizationId +from favro_sync .favro_fuse import start_favro_fuse + +# TODO: Skip if no secrets + +def test_create_client(): + client = FavroClient( + favro_org_id=OrganizationId(secrets.favro_org_id()), + favro_username=secrets.favro_username(), + favro_password=secrets.favro_password(), + read_only=True, + ) + assert client is not None + client.check_logged_in() + return client + +def test_get_card(): + client = test_create_client() + card = next(client.get_todo_list_cards()) + assert card is not None diff --git a/test/test_markdown_parsing.py b/test/test_markdown_parsing.py index f1ad66b..79b480e 100644 --- a/test/test_markdown_parsing.py +++ b/test/test_markdown_parsing.py @@ -1,4 +1,4 @@ -from favro_sync.favro_markdown import parse_card_contents, format_card_contents +from favro_sync.favro_markdown import CardFileFormatter EXAMPLE_TEXT_1 = ''' --- @@ -17,12 +17,11 @@ Test description 3. Derp '''.strip() +FORMATTER = CardFileFormatter() + def test_parse_and_render(): - card_contents = parse_card_contents(EXAMPLE_TEXT_1) + card_contents = FORMATTER.parse_card_contents(EXAMPLE_TEXT_1) assert card_contents.name == 'Hello World' assert '---' not in card_contents.description - print(card_contents) - assert False - - assert format_card_contents(card_contents) == EXAMPLE_TEXT_1 + assert FORMATTER.format_card_contents(card_contents) == EXAMPLE_TEXT_1