2024-09-26 19:51:53 +00:00
|
|
|
import dataclasses
|
2024-09-26 21:08:37 +00:00
|
|
|
import errno
|
|
|
|
import re
|
|
|
|
import stat
|
2024-09-26 16:49:28 +00:00
|
|
|
from collections.abc import Iterator
|
2024-09-27 09:12:14 +00:00
|
|
|
from logging import getLogger
|
2024-09-26 16:49:28 +00:00
|
|
|
|
2024-09-26 21:08:37 +00:00
|
|
|
import fuse
|
|
|
|
|
2024-09-26 21:07:46 +00:00
|
|
|
from .favro_client import FavroClient
|
2024-10-02 11:54:30 +00:00
|
|
|
from .favro_data_model import Card, SeqId, CustomFieldInfo, CustomFieldItemId, CustomField
|
2024-09-28 10:37:19 +00:00
|
|
|
from .favro_markdown import CardContents, CardFileFormatter
|
2024-09-26 16:49:28 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
################################################################################
|
|
|
|
# Constants
|
|
|
|
|
2024-09-27 09:12:14 +00:00
|
|
|
logger = getLogger(__name__)
|
2024-09-26 16:49:28 +00:00
|
|
|
|
2024-09-27 09:12:14 +00:00
|
|
|
fuse.fuse_python_api = (0, 2)
|
2024-09-26 21:08:37 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
OFFICIAL_URL = 'https://favro.com/organization/{org_id}?card=par-{seq_id}'
|
2024-10-01 09:12:17 +00:00
|
|
|
CARD_IDENTIFIER_FORMAT = 'PAR-{seq_id}'
|
|
|
|
CARD_FILENAME_FORMAT = CARD_IDENTIFIER_FORMAT + '.md'
|
2024-10-02 14:59:27 +00:00
|
|
|
CARD_FILENAME_REGEX = r'^PAR\-(\d+)\.md$'
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
################################################################################
|
|
|
|
# Formatting
|
2024-09-28 10:54:34 +00:00
|
|
|
|
2024-10-08 19:38:52 +00:00
|
|
|
def to_custom_field_value(custom_field: CustomField, field_def: CustomFieldInfo) -> str | None:
|
2024-10-02 11:54:30 +00:00
|
|
|
value: CustomFieldItemId | list[CustomFieldItemId] = custom_field.value
|
|
|
|
if field_def.type in {'Single select','Multiple select'}:
|
2024-10-02 11:34:49 +00:00
|
|
|
items = [field_def.get_field_item(item_id) for item_id in value]
|
|
|
|
items = [i for i in items if i]
|
|
|
|
return items[0].name
|
2024-10-02 11:54:30 +00:00
|
|
|
if field_def.type in {'Color'}:
|
|
|
|
return custom_field.color
|
2024-10-08 19:38:52 +00:00
|
|
|
return None
|
2024-10-02 11:34:49 +00:00
|
|
|
|
|
|
|
def to_custom_fields(card: Card, favro_client: FavroClient) -> dict[str,str]:
|
|
|
|
custom_fields = {}
|
|
|
|
for field_assignment in card.custom_fields:
|
|
|
|
field_def = favro_client.get_custom_field(field_assignment.custom_field_id)
|
2024-10-08 19:38:52 +00:00
|
|
|
str_value = to_custom_field_value(field_assignment, field_def)
|
|
|
|
if str_value is not None:
|
|
|
|
custom_fields[field_def.name] = str_value
|
|
|
|
del field_assignment, str_value
|
2024-10-02 11:34:49 +00:00
|
|
|
return custom_fields
|
|
|
|
|
2024-10-08 19:38:52 +00:00
|
|
|
def to_card_contents(card: Card, favro_client: FavroClient) -> CardContents:
|
2024-10-02 08:31:42 +00:00
|
|
|
tags = [favro_client.get_tag(tag_id).name for tag_id in card.tags]
|
|
|
|
assignments = [
|
2024-10-02 08:32:14 +00:00
|
|
|
favro_client.get_user(assignment.user).name for assignment in card.assignments
|
2024-10-02 08:31:42 +00:00
|
|
|
]
|
|
|
|
dependencies = [
|
|
|
|
CARD_FILENAME_FORMAT.format(
|
|
|
|
seq_id=favro_client.get_card_by_card_id(dep.card_id).seq_id.raw_id,
|
|
|
|
)
|
|
|
|
for dep in card.dependencies
|
|
|
|
if dep.is_before
|
|
|
|
]
|
|
|
|
return CardContents(
|
|
|
|
CARD_IDENTIFIER_FORMAT.format(seq_id=card.seq_id.raw_id),
|
|
|
|
card.name,
|
|
|
|
card.detailed_description,
|
|
|
|
tags,
|
|
|
|
assignments,
|
|
|
|
dependencies,
|
|
|
|
url=OFFICIAL_URL.format(
|
|
|
|
org_id=card.organization_id.raw_id,
|
|
|
|
seq_id=card.seq_id.raw_id,
|
|
|
|
),
|
|
|
|
todo_list_completed=card.todo_list_completed,
|
|
|
|
is_archived=card.is_archived,
|
|
|
|
start_date=card.start_date,
|
|
|
|
due_date=card.due_date,
|
2024-10-02 11:54:30 +00:00
|
|
|
custom_fields=to_custom_fields(card, favro_client),
|
2024-10-02 08:31:42 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
################################################################################
|
|
|
|
# FUSE
|
|
|
|
|
|
|
|
class FavroStat(fuse.Stat):
|
|
|
|
def __init__(self):
|
|
|
|
self.st_mode = 0
|
|
|
|
self.st_ino = 0
|
|
|
|
self.st_dev = 0
|
|
|
|
self.st_nlink = 0
|
|
|
|
self.st_uid = 0
|
|
|
|
self.st_gid = 0
|
|
|
|
self.st_size = 0
|
|
|
|
self.st_atime = 0
|
|
|
|
self.st_mtime = 0
|
|
|
|
self.st_ctime = 0
|
|
|
|
|
|
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class FileSystemItem:
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class RootFileSystemItem(FileSystemItem):
|
|
|
|
|
|
|
|
@staticmethod
|
2024-10-08 19:38:52 +00:00
|
|
|
def from_path_segment(_segment: str) -> 'RootFileSystemItem':
|
2024-10-02 14:59:27 +00:00
|
|
|
return RootFileSystemItem()
|
|
|
|
|
2024-10-02 15:17:14 +00:00
|
|
|
def __str__(self):
|
|
|
|
return '/'
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
2024-10-02 15:17:14 +00:00
|
|
|
class CollectionFileSystemItem(FileSystemItem):
|
|
|
|
collection_name: str
|
2024-10-02 14:59:27 +00:00
|
|
|
|
2024-10-08 19:38:52 +00:00
|
|
|
def __post_init__(self):
|
|
|
|
assert '/' not in self.collection_name
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
@staticmethod
|
2024-10-02 15:17:14 +00:00
|
|
|
def from_path_segment(segment: str) -> 'CollectionFileSystemItem':
|
|
|
|
return CollectionFileSystemItem(segment)
|
2024-10-02 14:59:27 +00:00
|
|
|
|
2024-10-02 15:17:14 +00:00
|
|
|
def __str__(self):
|
|
|
|
return self.collection_name
|
2024-10-02 14:59:27 +00:00
|
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class CardFileSystemItem(FileSystemItem):
|
|
|
|
seq_id: SeqId
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def from_path_segment(segment: str) -> 'CardFileSystemItem | None':
|
|
|
|
if m := re.match(CARD_FILENAME_REGEX, segment):
|
|
|
|
return CardFileSystemItem(SeqId(int(m.group(1))))
|
|
|
|
return None
|
|
|
|
|
2024-10-02 15:17:14 +00:00
|
|
|
def __str__(self):
|
|
|
|
return CARD_FILENAME_FORMAT.format(seq_id=self.seq_id.raw_id)
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
|
|
|
|
def path_to_file_system_item(path_str: str, path_components: list[type[FileSystemItem]]) -> FileSystemItem | None:
|
|
|
|
path = re.findall(r'[^/]+', path_str)
|
|
|
|
component = path_components[len(path)]
|
|
|
|
return component.from_path_segment(path[-1] if path else None)
|
|
|
|
|
|
|
|
|
2024-09-26 16:49:28 +00:00
|
|
|
class FavroFuse(fuse.Fuse):
|
2024-09-30 11:57:34 +00:00
|
|
|
"""Favro FileSystem in Userspace."""
|
2024-09-26 16:49:28 +00:00
|
|
|
|
2024-09-28 10:54:34 +00:00
|
|
|
def __init__(
|
2024-09-28 12:13:51 +00:00
|
|
|
self,
|
|
|
|
favro_client: FavroClient,
|
|
|
|
formatter: CardFileFormatter,
|
|
|
|
**kwargs,
|
2024-09-28 10:54:34 +00:00
|
|
|
):
|
2024-09-26 16:49:28 +00:00
|
|
|
self.favro_client = favro_client
|
2024-09-28 10:37:19 +00:00
|
|
|
self.formatter = formatter
|
2024-09-30 11:57:34 +00:00
|
|
|
self.wiped_cards = set()
|
2024-10-02 15:17:14 +00:00
|
|
|
self.path_components = [RootFileSystemItem, CollectionFileSystemItem, CardFileSystemItem]
|
2024-09-26 16:49:28 +00:00
|
|
|
super().__init__(**kwargs)
|
|
|
|
|
2024-09-27 14:13:03 +00:00
|
|
|
def getattr(self, path: str) -> FavroStat | int:
|
2024-10-02 14:59:27 +00:00
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-09-27 14:13:03 +00:00
|
|
|
st = FavroStat()
|
2024-10-02 15:17:14 +00:00
|
|
|
if isinstance(file_system_item, RootFileSystemItem | CollectionFileSystemItem):
|
2024-09-26 16:49:28 +00:00
|
|
|
st.st_mode = stat.S_IFDIR | 0o755
|
|
|
|
st.st_nlink = 2
|
2024-10-02 14:59:27 +00:00
|
|
|
elif isinstance(file_system_item, CardFileSystemItem):
|
|
|
|
card = self.favro_client.get_card(file_system_item.seq_id)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
|
|
|
st.st_mode = stat.S_IFREG | 0o666
|
2024-09-26 16:49:28 +00:00
|
|
|
st.st_nlink = 1
|
2024-09-28 10:53:34 +00:00
|
|
|
st.st_size = len(self._format_card_file(card))
|
2024-09-26 19:51:53 +00:00
|
|
|
st.st_ctime = int(card.creation_date.timestamp())
|
2024-09-26 21:08:37 +00:00
|
|
|
st.st_mtime = st.st_ctime # TODO
|
2024-09-26 16:49:28 +00:00
|
|
|
else:
|
|
|
|
return -errno.ENOENT
|
|
|
|
return st
|
|
|
|
|
|
|
|
def readdir(self, path: str, offset: int) -> Iterator[fuse.Direntry]:
|
2024-10-02 15:17:14 +00:00
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
|
|
|
|
2024-09-26 16:49:28 +00:00
|
|
|
yield fuse.Direntry('.')
|
|
|
|
yield fuse.Direntry('..')
|
|
|
|
|
2024-10-02 15:17:14 +00:00
|
|
|
if isinstance(file_system_item, RootFileSystemItem):
|
|
|
|
for collection in self.favro_client.get_collections():
|
2024-10-08 19:38:52 +00:00
|
|
|
collection_name = collection.name.replace('/', '')
|
|
|
|
yield fuse.Direntry(str(CollectionFileSystemItem(collection_name)))
|
2024-10-02 15:17:14 +00:00
|
|
|
del collection
|
2024-10-08 19:38:52 +00:00
|
|
|
|
2024-10-02 15:17:14 +00:00
|
|
|
elif isinstance(file_system_item, CollectionFileSystemItem):
|
|
|
|
|
|
|
|
# TODO: move into own function
|
|
|
|
for collection in self.favro_client.get_collections():
|
2024-10-08 19:38:52 +00:00
|
|
|
if collection.name.replace('/', '') == file_system_item.collection_name:
|
2024-10-02 15:17:14 +00:00
|
|
|
collection_id = collection.collection_id
|
|
|
|
del collection
|
|
|
|
|
|
|
|
for card in self.favro_client.get_cards(collection_id=collection_id):
|
|
|
|
yield fuse.Direntry(str(CardFileSystemItem(card.seq_id)))
|
|
|
|
del card
|
2024-09-26 16:49:28 +00:00
|
|
|
|
2024-09-28 10:53:34 +00:00
|
|
|
def open(self, path: str, flags: int) -> int | None:
|
2024-10-02 14:59:27 +00:00
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
|
|
|
if not isinstance(file_system_item, CardFileSystemItem):
|
2024-09-26 16:49:28 +00:00
|
|
|
return -errno.ENOENT
|
2024-09-26 19:51:53 +00:00
|
|
|
return None
|
2024-09-26 16:49:28 +00:00
|
|
|
|
|
|
|
def read(self, path: str, size: int, offset: int) -> bytes | int:
|
2024-10-02 14:59:27 +00:00
|
|
|
# Check that this is a card file_system_item.
|
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
|
|
|
if not isinstance(file_system_item, CardFileSystemItem):
|
2024-09-26 16:49:28 +00:00
|
|
|
return -errno.ENOENT
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
card = self.favro_client.get_card(file_system_item.seq_id)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-09-28 10:53:34 +00:00
|
|
|
contents_str = self._format_card_file(card)
|
2024-09-26 19:51:53 +00:00
|
|
|
contents = bytes(contents_str, 'utf8')
|
|
|
|
|
|
|
|
slen = len(contents)
|
2024-09-26 16:49:28 +00:00
|
|
|
if offset < slen:
|
|
|
|
if offset + size > slen:
|
|
|
|
size = slen - offset
|
2024-09-26 21:08:37 +00:00
|
|
|
buf = contents[offset : offset + size]
|
2024-09-26 16:49:28 +00:00
|
|
|
else:
|
|
|
|
buf = b''
|
|
|
|
return buf
|
|
|
|
|
2024-09-26 19:51:53 +00:00
|
|
|
def write(self, path: str, written_buffer: bytes, offset: int) -> int:
|
2024-10-02 14:59:27 +00:00
|
|
|
# Check that this is a card file_system_item.
|
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
|
|
|
if not isinstance(file_system_item, CardFileSystemItem):
|
2024-09-26 19:51:53 +00:00
|
|
|
return -errno.ENOENT
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
card = self.favro_client.get_card(file_system_item.seq_id)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
|
|
|
# Splice contents
|
2024-09-28 10:53:34 +00:00
|
|
|
contents_str = self._format_card_file(card)
|
2024-09-26 19:51:53 +00:00
|
|
|
contents = bytes(contents_str, 'utf8')
|
|
|
|
contents = splice(contents, written_buffer, offset)
|
|
|
|
contents_str = contents.decode('utf8')
|
|
|
|
|
|
|
|
# Write to favro
|
2024-09-28 10:37:19 +00:00
|
|
|
card_updated = self.formatter.parse_card_contents(contents_str)
|
2024-09-27 14:13:03 +00:00
|
|
|
self.favro_client.update_card_contents(card.card_id, card_updated)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
self.wiped_cards.remove(file_system_item.seq_id)
|
2024-09-30 11:57:34 +00:00
|
|
|
|
2024-09-26 19:51:53 +00:00
|
|
|
# Return amount written
|
|
|
|
return len(written_buffer)
|
|
|
|
|
|
|
|
def truncate(self, path: str, new_size: int):
|
2024-10-02 14:59:27 +00:00
|
|
|
# Check that this is a card file_system_item.
|
|
|
|
file_system_item = path_to_file_system_item(path, self.path_components)
|
|
|
|
if not isinstance(file_system_item, CardFileSystemItem):
|
2024-09-26 19:51:53 +00:00
|
|
|
return -errno.ENOENT
|
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
card = self.favro_client.get_card(file_system_item.seq_id)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
|
|
|
# Splice contents
|
2024-09-28 10:53:34 +00:00
|
|
|
contents_str = self._format_card_file(card)
|
2024-09-26 19:51:53 +00:00
|
|
|
contents = bytes(contents_str, 'utf8')
|
2024-09-26 21:04:26 +00:00
|
|
|
old_size = len(contents)
|
|
|
|
contents = contents[0:new_size] + b' ' * (old_size - new_size)
|
|
|
|
assert len(contents) == old_size
|
2024-09-26 19:51:53 +00:00
|
|
|
contents_str = contents.decode('utf8')
|
|
|
|
|
|
|
|
# Write to favro
|
2024-09-28 10:37:19 +00:00
|
|
|
card_updated = self.formatter.parse_card_contents(contents_str)
|
2024-09-27 14:13:03 +00:00
|
|
|
self.favro_client.update_card_contents_locally(card.card_id, card_updated)
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-10-02 14:59:27 +00:00
|
|
|
self.wiped_cards.add(file_system_item.seq_id)
|
2024-09-30 11:57:34 +00:00
|
|
|
|
2024-09-26 19:51:53 +00:00
|
|
|
# Return amount written
|
|
|
|
return 0
|
|
|
|
|
2024-09-28 10:53:34 +00:00
|
|
|
def _format_card_file(self, card: Card) -> str:
|
2024-09-30 11:57:34 +00:00
|
|
|
if card.seq_id in self.wiped_cards:
|
|
|
|
return ''
|
2024-10-02 08:31:42 +00:00
|
|
|
card_contents = to_card_contents(card, self.favro_client)
|
2024-09-28 10:53:34 +00:00
|
|
|
return self.formatter.format_card_contents(card_contents)
|
2024-09-26 21:08:37 +00:00
|
|
|
|
2024-09-28 10:54:34 +00:00
|
|
|
|
2024-09-26 19:51:53 +00:00
|
|
|
def splice(original_buffer: bytes, input_buffer: bytes, offset: int) -> bytes:
|
2024-09-26 21:08:37 +00:00
|
|
|
return (
|
|
|
|
original_buffer[0 : offset - 1]
|
|
|
|
+ input_buffer
|
|
|
|
+ original_buffer[offset + len(input_buffer) + 1 : len(original_buffer)]
|
|
|
|
)
|
|
|
|
|
2024-09-26 19:51:53 +00:00
|
|
|
|
2024-09-26 21:08:37 +00:00
|
|
|
HELP = (
|
|
|
|
"""
|
2024-09-26 16:49:28 +00:00
|
|
|
Userspace hello example
|
|
|
|
|
2024-09-26 21:08:37 +00:00
|
|
|
"""
|
|
|
|
+ fuse.Fuse.fusage
|
|
|
|
)
|
|
|
|
|
2024-09-26 16:49:28 +00:00
|
|
|
|
|
|
|
def start_favro_fuse(favro_client: FavroClient):
|
|
|
|
# TODO:
|
|
|
|
server = FavroFuse(
|
2024-09-26 21:08:37 +00:00
|
|
|
favro_client=favro_client,
|
2024-09-28 10:37:19 +00:00
|
|
|
formatter=CardFileFormatter(),
|
2024-09-26 21:08:37 +00:00
|
|
|
version='%prog ' + fuse.__version__,
|
|
|
|
usage=HELP,
|
|
|
|
dash_s_do='setsingle',
|
|
|
|
)
|
2024-09-26 16:49:28 +00:00
|
|
|
server.parse(errex=1)
|
|
|
|
server.main()
|